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 ccc0b847be..0000000000 --- a/.eslintrc.base.json +++ /dev/null @@ -1,260 +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", - "prettier" - ], - "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-unmodified-loop-condition": "warn", - "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", - "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"] }], - "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-this-alias": "off", - "@typescript-eslint/no-unnecessary-condition": "off", - "@typescript-eslint/no-unnecessary-type-assertion": "off", // TODO@eamodio revisit - "@typescript-eslint/no-unsafe-assignment": "off", // TODO@eamodio revisit - "@typescript-eslint/no-unsafe-call": "off", // TODO@eamodio revisit - "@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": ["warn", { "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.json b/.eslintrc.json deleted file mode 100644 index 61265a66e7..0000000000 --- a/.eslintrc.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": [".eslintrc.base.json"], - "env": { - "browser": true, - "node": true - }, - "parserOptions": { - "project": "tsconfig.eslint.json" - } -} 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/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index b685e95dcd..d9d5ca7ad5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -5,11 +5,13 @@ about: Create a report to help us improve - + - Extension version: - VSCode Version: - OS: +- Repository Clone Configuration (single repository/fork of an upstream repository): +- GitHub Product (GitHub.com/GitHub Enterprise version x.x.x): Steps to Reproduce: diff --git a/.github/commands.json b/.github/commands.json new file mode 100644 index 0000000000..bd3fd7c13a --- /dev/null +++ b/.github/commands.json @@ -0,0 +1,477 @@ +[ + { + "type": "comment", + "name": "question", + "action": "updateLabels", + "addLabel": "*question" + }, + { + "type": "comment", + "name": "dev-question", + "action": "updateLabels", + "addLabel": "*dev-question" + }, + { + "type": "label", + "name": "*question", + "action": "close", + "reason": "not_planned", + "comment": "We closed this issue because it is a question about using VS Code rather than an issue or feature request. Please search for help on [StackOverflow](https://aka.ms/vscodestackoverflow), where the community has already answered thousands of similar questions. You may find their [guide on asking a new question](https://aka.ms/vscodestackoverflowquestion) helpful if your question has not already been asked. See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting).\n\nHappy Coding!" + }, + { + "type": "label", + "name": "*dev-question", + "action": "close", + "reason": "not_planned", + "comment": "We have a great extension developer community over on [GitHub discussions](https://github.com/microsoft/vscode-discussions/discussions) and [Slack](https://vscode-dev-community.slack.com/) where extension authors help each other. This is a great place for you to ask questions and find support.\n\nHappy Coding!" + }, + { + "type": "label", + "name": "*extension-candidate", + "action": "close", + "reason": "not_planned", + "comment": "We try to keep VS Code lean and we think the functionality you're asking for is great for a VS Code extension. Maybe you can already find one that suits you in the [VS Code Marketplace](https://aka.ms/vscodemarketplace). Just in case, in a few simple steps you can get started [writing your own extension](https://aka.ms/vscodewritingextensions). See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting).\n\nHappy Coding!" + }, + { + "type": "label", + "name": "*not-reproducible", + "action": "close", + "reason": "not_planned", + "comment": "We closed this issue because we are unable to reproduce the problem with the steps you describe. Chances are we've already fixed your problem in a recent version of VS Code. If not, please ask us to reopen the issue and provide us with more detail. Our [issue reporting guidelines](https://aka.ms/vscodeissuereporting) might help you with that.\n\nHappy Coding!" + }, + { + "type": "label", + "name": "*out-of-scope", + "action": "close", + "reason": "not_planned", + "comment": "We closed this issue because we [don't plan to address it](https://aka.ms/vscode-out-of-scope) in the foreseeable future. If you disagree and feel that this issue is crucial: we are happy to listen and to reconsider.\n\nIf you wonder what we are up to, please see our [roadmap](https://aka.ms/vscoderoadmap) and [issue reporting guidelines](https://aka.ms/vscodeissuereporting).\n\nThanks for your understanding, and happy coding!" + }, + { + "type": "label", + "name": "wont-fix", + "action": "close", + "reason": "not_planned", + "comment": "We closed this issue because we [don't plan to address it](https://github.com/microsoft/vscode/wiki/Issue-Grooming#wont-fix-bugs).\n\nThanks for your understanding, and happy coding!" + }, + { + "type": "comment", + "name": "causedByExtension", + "action": "updateLabels", + "addLabel": "*caused-by-extension" + }, + { + "type": "label", + "name": "*caused-by-extension", + "action": "close", + "reason": "not_planned", + "comment": "This issue is caused by an extension, please file it with the repository (or contact) the extension has linked in its overview in VS Code or the [marketplace](https://aka.ms/vscodemarketplace) for VS Code. See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting). If you don't know which extension is causing the problem, you can run `Help: Start extension bisect` from the command palette (F1) to help identify the problem extension.\n\nHappy Coding!" + }, + { + "type": "label", + "name": "*as-designed", + "action": "close", + "reason": "not_planned", + "comment": "The described behavior is how it is expected to work. If you disagree, please explain what is expected and what is not in more detail. See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting).\n\nHappy Coding!" + }, + { + "type": "comment", + "name": "duplicate", + "action": "updateLabels", + "addLabel": "*duplicate" + }, + { + "type": "label", + "name": "*duplicate", + "action": "close", + "reason": "not_planned", + "comment": "Thanks for creating this issue! We figured it's covering the same as another one we already have. Thus, we closed this one as a duplicate. You can search for [similar existing issues](${duplicateQuery}). See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting).\n\nHappy Coding!" + }, + { + "type": "comment", + "name": "verified", + "allowUsers": [ + "@author" + ], + "action": "updateLabels", + "addLabel": "verified", + "removeLabel": "author-verification-requested", + "requireLabel": "author-verification-requested", + "disallowLabel": "unreleased" + }, + { + "type": "comment", + "name": "confirm", + "action": "updateLabels", + "addLabel": "confirmed", + "removeLabel": "confirmation-pending" + }, + { + "type": "comment", + "name": "confirmationPending", + "action": "updateLabels", + "addLabel": "confirmation-pending", + "removeLabel": "confirmed" + }, + { + "type": "comment", + "name": "needsMoreInfo", + "action": "updateLabels", + "addLabel": "~info-needed" + }, + { + "type": "comment", + "name": "needsPerfInfo", + "addLabel": "info-needed", + "comment": "Thanks for creating this issue regarding performance! Please follow this guide to help us diagnose performance issues: https://github.com/microsoft/vscode/wiki/Performance-Issues \n\nHappy Coding!" + }, + { + "type": "comment", + "name": "jsDebugLogs", + "action": "updateLabels", + "addLabel": "info-needed", + "comment": "Please collect trace logs using the following instructions:\n\n> If you're able to, add `\"trace\": true` to your `launch.json` and reproduce the issue. The location of the log file on your disk will be written to the Debug Console. Share that with us.\n>\n> ⚠️ This log file will not contain source code, but will contain file paths. You can drop it into https://microsoft.github.io/vscode-pwa-analyzer/index.html to see what it contains. If you'd rather not share the log publicly, you can email it to connor@xbox.com" + }, + { + "type": "comment", + "name": "closedWith", + "action": "close", + "reason": "completed", + "addLabel": "unreleased" + }, + { + "type": "comment", + "name": "spam", + "action": "close", + "reason": "not_planned", + "addLabel": "invalid" + }, + { + "type": "comment", + "name": "a11ymas", + "action": "updateLabels", + "addLabel": "a11ymas" + }, + { + "type": "label", + "name": "*off-topic", + "action": "close", + "reason": "not_planned", + "comment": "Thanks for creating this issue. We think this issue is unactionable or unrelated to the goals of this project. Please follow our [issue reporting guidelines](https://aka.ms/vscodeissuereporting).\n\nHappy Coding!" + }, + { + "type": "comment", + "name": "extPython", + "action": "close", + "reason": "not_planned", + "addLabel": "*caused-by-extension", + "comment": "It looks like this is caused by the Python extension. Please file the issue to the [Python extension repository](https://github.com/microsoft/vscode-python). Make sure to check their issue reporting template and provide them relevant information such as the extension version you're using. See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting) for more information.\n\nHappy Coding!" + }, + { + "type": "comment", + "name": "extJupyter", + "action": "close", + "reason": "not_planned", + "addLabel": "*caused-by-extension", + "comment": "It looks like this is caused by the Jupyter extension. Please file the issue to the [Jupyter extension repository](https://github.com/microsoft/vscode-jupyter). Make sure to check their issue reporting template and provide them relevant information such as the extension version you're using. See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting) for more information.\n\nHappy Coding!" + }, + { + "type": "comment", + "name": "extC", + "action": "close", + "reason": "not_planned", + "addLabel": "*caused-by-extension", + "comment": "It looks like this is caused by the C extension. Please file the issue to the [C extension repository](https://github.com/microsoft/vscode-cpptools). Make sure to check their issue reporting template and provide them relevant information such as the extension version you're using. See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting) for more information.\n\nHappy Coding!" + }, + { + "type": "comment", + "name": "extC++", + "action": "close", + "reason": "not_planned", + "addLabel": "*caused-by-extension", + "comment": "It looks like this is caused by the C++ extension. Please file the issue to the [C++ extension repository](https://github.com/microsoft/vscode-cpptools). Make sure to check their issue reporting template and provide them relevant information such as the extension version you're using. See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting) for more information.\n\nHappy Coding!" + }, + { + "type": "comment", + "name": "extCpp", + "action": "close", + "reason": "not_planned", + "addLabel": "*caused-by-extension", + "comment": "It looks like this is caused by the C++ extension. Please file the issue to the [C++ extension repository](https://github.com/microsoft/vscode-cpptools). Make sure to check their issue reporting template and provide them relevant information such as the extension version you're using. See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting) for more information.\n\nHappy Coding!" + }, + { + "type": "comment", + "name": "extTS", + "action": "close", + "reason": "not_planned", + "addLabel": "*caused-by-extension", + "comment": "It looks like this is caused by the TypeScript language service. Please file the issue to the [TypeScript repository](https://github.com/microsoft/TypeScript/). Make sure to check their [contributing guidelines](https://github.com/microsoft/TypeScript/blob/master/CONTRIBUTING.md) and provide relevant information such as the extension version you're using. See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting) for more information.\n\nHappy Coding!" + }, + { + "type": "comment", + "name": "extJS", + "action": "close", + "reason": "not_planned", + "addLabel": "*caused-by-extension", + "comment": "It looks like this is caused by the TypeScript/JavaScript language service. Please file the issue to the [TypeScript repository](https://github.com/microsoft/TypeScript/). Make sure to check their [contributing guidelines](https://github.com/microsoft/TypeScript/blob/master/CONTRIBUTING.md) and provide relevant information such as the extension version you're using. See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting) for more information.\n\nHappy Coding!" + }, + { + "type": "comment", + "name": "extC#", + "action": "close", + "reason": "not_planned", + "addLabel": "*caused-by-extension", + "comment": "It looks like this is caused by the C# extension. Please file the issue to the [C# extension repository](https://github.com/OmniSharp/omnisharp-vscode.git). Make sure to check their issue reporting template and provide them relevant information such as the extension version you're using. See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting) for more information.\n\nHappy Coding!" + }, + { + "type": "comment", + "name": "extGo", + "action": "close", + "addLabel": "*caused-by-extension", + "comment": "It looks like this is caused by the Go extension. Please file the issue to the [Go extension repository](https://github.com/golang/vscode-go). Make sure to check their issue reporting template and provide them relevant information such as the extension version you're using. See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting) for more information.\n\nHappy Coding!" + }, + { + "type": "comment", + "name": "extPowershell", + "action": "close", + "reason": "not_planned", + "addLabel": "*caused-by-extension", + "comment": "It looks like this is caused by the PowerShell extension. Please file the issue to the [PowerShell extension repository](https://github.com/PowerShell/vscode-powershell). Make sure to check their issue reporting template and provide them relevant information such as the extension version you're using. See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting) for more information.\n\nHappy Coding!" + }, + { + "type": "comment", + "name": "extLiveShare", + "action": "close", + "reason": "not_planned", + "addLabel": "*caused-by-extension", + "comment": "It looks like this is caused by the LiveShare extension. Please file the issue to the [LiveShare repository](https://github.com/MicrosoftDocs/live-share). Make sure to check their [contributing guidelines](https://github.com/MicrosoftDocs/live-share/blob/master/CONTRIBUTING.md) and provide relevant information such as the extension version you're using. See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting) for more information.\n\nHappy Coding!" + }, + { + "type": "comment", + "name": "extDocker", + "action": "close", + "reason": "not_planned", + "addLabel": "*caused-by-extension", + "comment": "It looks like this is caused by the Docker extension. Please file the issue to the [Docker extension repository](https://github.com/microsoft/vscode-docker). Make sure to check their issue reporting template and provide them relevant information such as the extension version you're using. See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting) for more information.\n\nHappy Coding!" + }, + { + "type": "comment", + "name": "extJava", + "action": "close", + "reason": "not_planned", + "addLabel": "*caused-by-extension", + "comment": "It looks like this is caused by the Java extension. Please file the issue to the [Java extension repository](https://github.com/redhat-developer/vscode-java). Make sure to check their [troubleshooting instructions](https://github.com/redhat-developer/vscode-java/wiki/Troubleshooting) and provide relevant information such as the extension version you're using. See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting) for more information.\n\nHappy Coding!" + }, + { + "type": "comment", + "name": "extJavaDebug", + "action": "close", + "reason": "not_planned", + "addLabel": "*caused-by-extension", + "comment": "It looks like this is caused by the Java Debugger extension. Please file the issue to the [Java Debugger repository](https://github.com/microsoft/vscode-java-debug). Make sure to check their issue reporting template and provide them relevant information such as the extension version you're using. See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting) for more information.\n\nHappy Coding!" + }, + { + "type": "comment", + "name": "extCodespaces", + "action": "close", + "reason": "not_planned", + "addLabel": "*caused-by-extension", + "comment": "It looks like this is caused by the Codespaces extension. Please file the issue in the [Codespaces Discussion Forum](http://aka.ms/ghcs-feedback). Make sure to check their issue reporting template and provide them relevant information such as the extension version you're using. See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting) for more information.\n\nHappy Coding!" + }, + { + "type": "comment", + "name": "extCopilot", + "action": "close", + "reason": "not_planned", + "addLabel": "*caused-by-extension", + "comment": "It looks like this is caused by the Copilot extension. Please file the issue in the [Copilot Discussion Forum](https://github.com/community/community/discussions/categories/copilot). Make sure to check their issue reporting template and provide them relevant information such as the extension version you're using. See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting) for more information.\n\nHappy Coding!" + }, + { + "type": "comment", + "name": "gifPlease", + "action": "comment", + "addLabel": "info-needed", + "comment": "Thanks for reporting this issue! Unfortunately, it's hard for us to understand what issue you're seeing. Please help us out by providing a screen recording showing exactly what isn't working as expected. While we can work with most standard formats, `.gif` files are preferred as they are displayed inline on GitHub. You may find https://gifcap.dev helpful as a browser-based gif recording tool.\n\nIf the issue depends on keyboard input, you can help us by enabling screencast mode for the recording (`Developer: Toggle Screencast Mode` in the command palette). Lastly, please attach this file via the GitHub web interface as emailed responses will strip files out from the issue.\n\nHappy coding!" + }, + { + "type": "comment", + "name": "confirmPlease", + "action": "comment", + "addLabel": "info-needed", + "comment": "Please diagnose the root cause of the issue by running the command `F1 > Help: Troubleshoot Issue` and following the instructions. Once you have done that, please update the issue with the results.\n\nHappy Coding!" + }, + { + "__comment__": "Allows folks on the team to label issues by commenting: `\\label My-Label` ", + "type": "comment", + "name": "label", + "allowUsers": [] + }, + { + "type": "comment", + "name": "assign" + }, + { + "type": "label", + "name": "*workspace-trust-docs", + "action": "close", + "reason": "not_planned", + "comment": "This issue appears to be the result of the new workspace trust feature shipped in June 2021. This security-focused feature has major impact on the functionality of VS Code. Due to the volume of issues, we ask that you take some time to review our [comprehensive documentation](https://aka.ms/vscode-workspace-trust) on the feature. If your issue is still not resolved, please let us know." + }, + { + "type": "label", + "name": "~verification-steps-needed", + "action": "updateLabels", + "addLabel": "verification-steps-needed", + "removeLabel": "~verification-steps-needed", + "comment": "Friendly ping! Looks like this issue requires some further steps to be verified. Please provide us with the steps necessary to verify this issue." + }, + { + "type": "label", + "name": "~info-needed", + "action": "updateLabels", + "addLabel": "info-needed", + "removeLabel": "~info-needed", + "comment": "Thanks for creating this issue! We figured it's missing some basic information or in some other way doesn't follow our [issue reporting guidelines](https://aka.ms/vscodeissuereporting). Please take the time to review these and update the issue.\n\nFor Copilot Issues, be sure to visit our [Copilot-specific guidelines](https://github.com/microsoft/vscode/wiki/Copilot-Issues) page for details on the necessary information.\n\nHappy Coding!" + }, + { + "type": "label", + "name": "~version-info-needed", + "action": "updateLabels", + "addLabel": "info-needed", + "removeLabel": "~version-info-needed", + "comment": "Thanks for creating this issue! We figured it's missing some basic information, such as a version number, or in some other way doesn't follow our [issue reporting guidelines](https://aka.ms/vscodeissuereporting). Please take the time to review these and update the issue.\n\nHappy Coding!" + }, + { + "type": "label", + "name": "~confirmation-needed", + "action": "updateLabels", + "addLabel": "info-needed", + "removeLabel": "~confirmation-needed", + "comment": "Please diagnose the root cause of the issue by running the command `F1 > Help: Troubleshoot Issue` and following the instructions. Once you have done that, please update the issue with the results.\n\nHappy Coding!" + }, + { + "type": "label", + "name": "~chat-rate-limiting", + "removeLabel": "~chat-rate-limiting", + "action": "close", + "reason": "not_planned", + "comment": "This issue is a duplicate of https://github.com/microsoft/vscode/issues/253124. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." + }, + { + "type": "label", + "name": "~chat-request-failed", + "removeLabel": "~chat-request-failed", + "action": "close", + "reason": "not_planned", + "comment": "This issue is a duplicate of https://github.com/microsoft/vscode/issues/253136. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." + }, + { + "type": "label", + "name": "~chat-rai-content-filters", + "removeLabel": "~chat-rai-content-filters", + "action": "close", + "reason": "not_planned", + "comment": "This issue is a duplicate of https://github.com/microsoft/vscode/issues/253130. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." + }, + { + "type": "label", + "name": "~chat-public-code-blocking", + "removeLabel": "~chat-public-code-blocking", + "action": "close", + "reason": "not_planned", + "comment": "This issue is a duplicate of https://github.com/microsoft/vscode/issues/253129. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." + }, + { + "type": "label", + "name": "~chat-lm-unavailable", + "removeLabel": "~chat-lm-unavailable", + "action": "close", + "reason": "not_planned", + "comment": "This issue is a duplicate of https://github.com/microsoft/vscode/issues/253137. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." + }, + { + "type": "label", + "name": "~chat-authentication", + "removeLabel": "~chat-authentication", + "action": "close", + "reason": "not_planned", + "comment": "Please look at the following meta issue: https://github.com/microsoft/vscode/issues/253132, if the bug you are experiencing is not there, please comment on this closed issue thread so we can re-open it.", + "assign": [ + "TylerLeonhardt" + ] + }, + { + "type": "label", + "name": "~chat-no-response-returned", + "removeLabel": "~chat-no-response-returned", + "action": "close", + "reason": "not_planned", + "comment": "Please look at the following meta issue: https://github.com/microsoft/vscode/issues/253126. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." + }, + { + "type": "label", + "name": "~chat-billing", + "removeLabel": "~chat-billing", + "addLabel": "chat-billing", + "action": "close", + "reason": "not_planned", + "comment": "Please look at the following meta issue: https://github.com/microsoft/vscode/issues/252230. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." + }, + { + "type": "label", + "name": "~chat-infinite-response-loop", + "removeLabel": "~chat-infinite-response-loop", + "addLabel": "chat-infinite-response-loop", + "action": "close", + "reason": "not_planned", + "comment": "Please look at the following meta issue: https://github.com/microsoft/vscode/issues/253134. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." + }, + { + "type": "label", + "name": "~spam", + "removeLabel": "~spam", + "addLabel": "spam", + "action": "close", + "reason": "not_planned", + "comment": "Thank you for your submission. This issue has been closed as it doesn't meet our community guidelines or appears to be spam.\n\n**If you believe this was closed in error:**\n- Please review our [Code of Conduct](https://opensource.microsoft.com/codeofconduct/)\n- Ensure your issue contains a clear description of the problem or feature request\n- Feel free to open a new issue with appropriate detail if this was a legitimate concern\n\n**For legitimate issues, please include:**\n- Clear description of the problem\n- Steps to reproduce (for bugs)\n- Expected vs actual behavior\n- VS Code version and environment details\n\nThank you for helping us maintain a welcoming and productive community." + }, + { + "type": "label", + "name": "~capi", + "addLabel": "capi", + "removeLabel": "~capi", + "assign": [ + "samvantran", + "sharonlo" + ], + "comment": "Thank you for creating this issue! Please provide one or more `requestIds` to help the platform team investigate. You can follow instructions [found here](https://github.com/microsoft/vscode/wiki/Copilot-Issues#language-model-requests-and-responses) to locate the `requestId` value.\n\nHappy Coding!" + }, + { + "type": "label", + "name": "*edu", + "action": "close", + "reason": "not_planned", + "comment": "We closed this issue because it seems to be about coursework or grading. This issue tracker is for issues about VS Code itself. For coursework-related issues, or issues related to your course's specific VS Code setup, please consider engaging directly with your course instructor.\n\nHappy Coding!" + }, + { + "type": "comment", + "name": "edu", + "action": "updateLabels", + "addLabel": "*edu" + }, + { + "type": "label", + "name": "~agent-behavior", + "action": "close", + "reason": "not_planned", + "addLabel": "agent-behavior", + "removeLabel": "~agent-behavior", + "comment": "Unfortunately I think you are hitting a AI quality issue that is not actionable enough for us to track a bug. We would recommend that you try other available models and look at the [Tips and tricks for Copilot in VS Code](https://code.visualstudio.com/docs/copilot/copilot-tips-and-tricks) doc page.\n\nWe are constantly improving AI quality in every release, thank you for the feedback! If you believe this is a technical bug, we recommend you report a new issue including logs described on the [Copilot Issues](https://github.com/microsoft/vscode/wiki/Copilot-Issues) wiki page." + }, + { + "type": "label", + "name": "~accessibility-sla", + "addLabel": "accessibility-sla", + "removeLabel": "~accessibility-sla", + "comment": "The Visual Studio and VS Code teams have an agreement with the Accessibility team that 3:1 contrast is enough for inside the editor." + } +] diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..0c48ce8f75 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,32 @@ +## General Guidelines +- **Follow the existing code style**: Use single quotes, semicolons, and 2-space indentation. See `.eslintrc.base.json` for detailed linting rules. +- **TypeScript**: Use modern TypeScript features. Prefer explicit types where clarity is needed, but do not over-annotate. +- **React/JSX**: Webviews use React-style JSX with custom factories (`vscpp`, `vscppf`). +- **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`. +- **Configuration**: All user-facing settings must be declared in `package.json` under `contributes.configuration`. +- **Activation Events**: Only add new activation events if absolutely necessary. +- **Webviews**: Place webview code in the `webviews/` directory. Use the shared `common/` code where possible. +- **Commands**: Register new commands in `package.json` and implement them in `src/commands.ts` or a relevant module. +- **Logging**: Use the `Logger` utility for all logging purposes. Don't use console.log or similar methods directly. +- **Test Running**: Use tools over tasks over scripts to run tests. + +## Specific Feature Practices +- **Commands**: When adding a new command, consider whether it should be available in the command palette, context menus, or both. Add the appropriate menu entries in `package.json` to ensure the command is properly included, or excluded (command palette), from menus. + +## Pull Request Guidelines +- Never touch the yarn.lock file. +- Run `yarn run lint` and also `npm run hygiene` and fix any errors or warnings before committing. + +## Testing +- Use `describe` and `it` blocks from Mocha for structuring tests. +- Tests MUST run product code. +- Tests should be placed near the code they are testing, in a `test` folder. Name test fies with a `.test.ts` suffix. + +--- +_Last updated: 2025-06-20_ diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 0000000000..c9f9ff8872 --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,68 @@ +name: "Copilot Setup Steps" + +# Automatically run the setup steps when they are changed to allow for easy validation, and +# allow manual testing through the repository's "Actions" tab +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. + copilot-setup-steps: + runs-on: ubuntu-latest + + # Set the permissions to the lowest permissions possible needed for your steps. + # Copilot will be given its own token for its operations. + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "yarn" + + - name: Install dependencies + run: yarn install --frozen-lockfile + env: + # Skip Playwright browser downloads to avoid installation failures + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + + - name: Install Playwright browsers (if needed) + run: | + if [ -d "node_modules/playwright" ]; then + echo "Installing Playwright browsers..." + npx playwright install --with-deps + else + echo "Playwright not found, skipping browser installation" + fi + continue-on-error: true + + - name: Update VS Code type definitions + run: yarn update-dts + + - name: Basic build verification + run: | + echo "Verifying basic setup..." + if [ ! -d "node_modules" ]; then + echo "❌ node_modules not found" + exit 1 + fi + if [ ! -f "node_modules/.bin/tsc" ]; then + echo "❌ TypeScript compiler not found" + exit 1 + fi + if [ ! -f "node_modules/.bin/webpack" ]; then + echo "❌ Webpack not found" + exit 1 + fi + echo "✅ Basic setup verification successful" diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100644 new mode 100755 index 05e7278194..41bddbd059 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -2,3 +2,4 @@ . "$(dirname -- "$0")/_/husky.sh" npm run hygiene +npm run lint \ No newline at end of file diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 88d17f1d7e..0000000000 --- a/.prettierignore +++ /dev/null @@ -1,4 +0,0 @@ -.vscode-test -dist -**/@types/* - diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index f4654a36a2..0000000000 --- a/.prettierrc +++ /dev/null @@ -1,23 +0,0 @@ -{ - "arrowParens": "avoid", - "endOfLine": "lf", - "trailingComma": "all", - "printWidth": 120, - "singleQuote": true, - "tabWidth": 4, - "useTabs": true, - "overrides": [ - { - "files": ".prettierrc", - "options": { "parser": "json" } - }, - { - "files": "*.md", - "options": { "tabWidth": 2 } - }, - { - "files": "*.yml", - "options": { "tabWidth": 2 } - } - ] -} diff --git a/.readme/demo.gif b/.readme/demo.gif index 2e4c1f1ebc..7f127666a1 100644 Binary files a/.readme/demo.gif and b/.readme/demo.gif differ diff --git a/.vscode-test.mjs b/.vscode-test.mjs new file mode 100644 index 0000000000..c3406cbc00 --- /dev/null +++ b/.vscode-test.mjs @@ -0,0 +1,29 @@ + +import { defineConfig } from "@vscode/test-cli"; + +/** + * @param {string} label + */ +function generateConfig(label) { + /** @type {import('@vscode/test-cli').TestConfiguration} */ + let config = { + label, + files: ["dist/**/*.test.js"], + version: "insiders", + srcDir: "src", + launchArgs: [ + "--enable-proposed-api", + "--disable-extension=GitHub.vscode-pull-request-github-insiders", + ], + // env, + mocha: { + ui: "bdd", + color: true, + timeout: 25000 + }, + }; + + return config; +} + +export default defineConfig(generateConfig("Local Tests")); diff --git a/.vscode/extensions.json b/.vscode/extensions.json index d0c9bfad48..cd6d08e737 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -2,6 +2,6 @@ "recommendations": [ "dbaeumer.vscode-eslint", "amodio.tsl-problem-matcher", - "esbenp.prettier-vscode" + "ms-vscode.extension-test-runner" ] } diff --git a/.vscode/launch.json b/.vscode/launch.json index 7adba73f4c..07d36ff613 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -17,7 +17,8 @@ "smartStep": true, "sourceMaps": true, "outFiles": [ - "${workspaceFolder}/dist/*.js" + "${workspaceFolder}/dist/*.js", + "${workspaceFolder}" ], "debugWebviews": true }, @@ -91,24 +92,21 @@ "--extensionDevelopmentPath=${workspaceFolder}", "--extensionTestsPath=${workspaceFolder}/out/src/test", "--disable-extensions", + "--user-data-dir=${workspaceFolder}/.vscode-test" ], "preLaunchTask": "npm: test:preprocess", "smartStep": true, - "sourceMaps": true, - "outFiles": [ - "${workspaceFolder}/dist/test/*.js" - ] + "sourceMaps": true }, { "type": "node", "request": "launch", "name": "Attach Web Test", - "program": "${workspaceFolder}/node_modules/vscode-test-web/out/index.js", + "program": "${workspaceFolder}/node_modules/@vscode/test-web/out/server/index.js", "args": [ "--extensionTestsPath=dist/browser/test/index.js", "--extensionDevelopmentPath=.", "--browserType=chromium", - "--attach=9229" ], "cascadeTerminateToConfigurations": [ "Launch Web Test" diff --git a/.vscode/settings.json b/.vscode/settings.json index 62614fba5c..71538296dc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,14 @@ ".eslintrc.json": "jsonc", ".eslintrc.*.json": "jsonc" }, + "json.schemas": [ + { + "fileMatch": [ + "**/.eslintrc.*.json" + ], + "url": "https://json.schemastore.org/eslintrc" + } + ], "files.trimTrailingWhitespace": true, "gitlens.advanced.blame.customArguments": ["--ignore-revs-file", ".gitignore-revs"] } diff --git a/.vscodeignore b/.vscodeignore index fb7da1db3a..ec86cb30a1 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,4 +1,5 @@ .github +.husky .readme/** .vscode/** .vscode-test/** @@ -11,6 +12,7 @@ node_modules/** scripts/** src/** webviews/** +**/test/** .eslintcache* .eslintignore .eslintrc* @@ -27,8 +29,10 @@ azure-pipeline.* yarn.lock **/*.map **/*.svg +!**/git-pull-request_webview.svg **/*.ts *.vsix **/*.bak package.insiders.json README.insiders.md +tsfmt.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 26d67bb8e3..8f33008770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,1154 @@ # Changelog +## 0.128.0 + +### Changes + +- "Apply Suggestion with Copilot" is now available from the "Comments" view, in addition to the comment widget in the editor. When run from this context, it will open the Chat view. +- When a PR branch is automatically deleted after merge, a notification is shown to inform you that the branch was deleted and you were switched to another branch. +- The issue URL in the issue webview is now copyable via the right-click context menu. +- The "Pull Request" status bar item reflects the currently selected repo when using `"scm.repositories.selectionMode": "single"`. + +### Fixes + +- timelineItem nodes can be null. https://github.com/microsoft/vscode-pull-request-github/issues/8447 +- Branch auto delete does not work with merge queues. https://github.com/microsoft/vscode-pull-request-github/issues/8435 +- Error: we couldn't find commit. https://github.com/microsoft/vscode-pull-request-github/issues/8401 +- Multi-diff button for current pull request is gone. https://github.com/microsoft/vscode-pull-request-github/issues/8387 +- Do not create notifications for copilot PRs which are in progress. https://github.com/microsoft/vscode-pull-request-github/issues/8380 +- Error viewing PR descriptions in ghe. https://github.com/microsoft/vscode-pull-request-github/issues/8378 +- Marking a file as viewed on an older commit marks the file as viewed for all versions of the file. https://github.com/microsoft/vscode-pull-request-github/issues/8313 +- Use the GH GraphQL API UpdatePullRequestBranch for pulling in updates when there are no conflicts. https://github.com/microsoft/vscode-pull-request-github/issues/8231 +- "Copilot started work" should not be the same message for CCA and CCR. https://github.com/microsoft/vscode-pull-request-github/issues/8211 +- "Edit Description" button does nothing when using the Create Issue From Selection command. https://github.com/microsoft/vscode-pull-request-github/issues/8184 +- Avatar does not show in PR details page. https://github.com/microsoft/vscode-pull-request-github/issues/8152 + +## 0.126.0 + +### Changes + +- A multi-diff editor can be opened for a pull request by URI. For example, `vscode-insiders://github.vscode-pull-request-github/open-pull-request-changes?uri=https://github.com/microsoft/vscode-css-languageservice/pull/460`. +- There's an option to "Checkout on Codespace" from the pull request description webview. +- ctrl/cmd + R when the pull request description webview is focused will refresh the webview. +- You can "Reveal in Explorer" from files in the "Changes in Pull Request" view and the "Pull Requests" views. +- Commit SHAs in PR file comments will be linkified. +- Set `"githubPullRequests.pullRequestDescription": "branchName"` to have the pull request title pre-filled with the branch name when creating a new pull request. +- The command "GitHub Issues: Open Issue on GitHub" can be used when your cursor is in an issue reference (e.g., `#1234`) in a text file to open the issue on GitHub. +- Set `"githubPullRequests.commentExpandState": "collapsePreexisting"` to have pre-existing comments collapsed by default when opening a pull request description webview, while new comments remain expanded. +- Choose which PR template to use when creating a new pull request (requires that `"githubPullRequests.pullRequestDescription": "template"` is set). + +![Button to change pull request template](./documentation/changelog/0.126.0/change-pr-template.png) + +- Metadata from issue templates (e.g., `assignees`, `labels`) is now applied when creating a new issue from a template. +- Issues created from `TODO` comments are assigned to the current user by default. +- `"githubPullRequests.createOnPublishBranch"` can be set to `"always"` to always create a pull request when publishing a branch. +- Open pull requests can be converted to drafts from the pull request description webview. + +![Convert to draft](./documentation/changelog/0.126.0/convert-to-draft.png) + +- We attempt to unwrap shortened commit lines in the pull request description webview to improve readability. +- Copilot can generate descriptions for existing pull requests. "Edit" the description then use the sparkle icon to have Copilot generate a description. + +![Generate description for an existing pull request](./documentation/changelog/0.126.0/generate-existing-description.png) + +- Instead of checking out the default branch when you're done with a PR, you can configure that the PR base branch is checked out with `"githubPullRequests.postDone": "checkoutPullRequestBaseBranch"`. +- You can change the base branch of a PR from the pull request description webview. + +![Change base branch](./documentation/changelog/0.126.0/change-base-branch.png) + +- PR branches can be configured to be automatically deleted when the PR is merged from this extension with the setting `"githubPullRequests.deleteBranchAfterMerge": true`. +- When opening a folder for the first time, existing PR branches will be discovered and added to the "Local Pull Request Branches" view. + +### Fixes + +- Switch branch quick pick is not showing all branches. https://github.com/microsoft/vscode-pull-request-github/issues/8351 +- Auto-merge options aren't properly updated when base repo changes. https://github.com/microsoft/vscode-pull-request-github/issues/8195 +- Required check item renders strangely. https://github.com/microsoft/vscode-pull-request-github/issues/8176 +- Request review from Copilot. https://github.com/microsoft/vscode-pull-request-github/issues/6830 +- Make clear in which repo a pull request is stored. https://github.com/microsoft/vscode-pull-request-github/issues/6674 +- Show Changes Since Last Review should not consider pending reviews as "Last Review". https://github.com/microsoft/vscode-pull-request-github/issues/6226 +- Extension mistakenly thinks I'm using GH Enterprise with global "url aliasing". https://github.com/microsoft/vscode-pull-request-github/issues/4551 + +## 0.124.1 + +### Fixes + +- Want links to repo from recent agent sessions in empty workspace. https://github.com/microsoft/vscode/issues/281345 + +## 0.124.0 + +### Changes + +- The active pull request or issue webview title is now included as implicit context when using Copilot Chat. You can click on the implicit context item to include all the PR information in your prompt. + +![Pull request title as implicit context in Chat](./documentation/changelog/0.124.0/pull-request-implicit-context.png) + +- Pull request and issue context can also be manually added to Chat from the "Add Context" menu. + +![Explicit Chat context](./documentation/changelog/0.124.0/explicit-chat-context.png) + +- Single button for marking Copilot pull requests as ready for review, approved, and automerge (if enabled). + +![Single button to mark Copilot pull requests](./documentation/changelog/0.124.0/single-button-copilot-pr.png) + +- The "Copy link" action for individual comments shows in the pull request description webview. +- Comments that are part of an un-submitted review (only visible to you) now show with at "comment draft" icon in the editor gutter and in the Comments view. +- For commits where checks have run, the commit status icon now shows next to each commit in the pull request description webview. + +### Fixes + +- Comments don't show when PR is on non-default repo. https://github.com/microsoft/vscode-pull-request-github/issues/8050 +- ignoreSubmodules is honored differently for Pull Requests and Issues view. https://github.com/microsoft/vscode-pull-request-github/issues/7741 + +**_Thank You_** + +* [@vicky1999 (Vignesh)](https://github.com/vicky1999) + * fix: message wrapping in narrow editor panes [PR #8121](https://github.com/microsoft/vscode-pull-request-github/pull/8121) + * feat: Display commit status icon for each commit [PR #8142](https://github.com/microsoft/vscode-pull-request-github/pull/8142) + * feat: Add copy comment link button in PR overview [PR #8150](https://github.com/microsoft/vscode-pull-request-github/pull/8150) + +## 0.122.1 + +### Fixes + +- Only one reviewer can be seen on the PR page. https://github.com/microsoft/vscode-pull-request-github/issues/8131 +- Drop down not doing anything. https://github.com/microsoft/vscode-pull-request-github/issues/8149 +- Pull in icon fixes. https://github.com/microsoft/vscode-pull-request-github/issues/8159 + +## 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 + +- Long coding agent problem statement results in unrecoverable error (Truncate coding agent problem_statement). https://github.com/microsoft/vscode-pull-request-github/issues/7861 + +## 0.118.1 + +### Fixes + +- _No logs available for this session_ race condition. https://github.com/microsoft/vscode-pull-request-github/issues/7783 + +## 0.118.0 + +### Changes + +- There's a new code action "Delegate to coding agent" which shows on `TODO` comments. The "to do" keywords are configurable using the existing setting ``. + +![Todo comment with "delegate to coding agent" action](./documentation/changelog/0.118.0/delegate-to-coding-agent-action.png) + +- More of the new Copilot coding agent user entry points prompt for sign in if you aren't already signed in to GitHub (GitHub Copilot Coding Agent view in Chat Sessions, `#copilotCodingAgent` tool, "Delegate to coding agent" button). +- Some of the individual extension views used the same icon, making it difficult to distinguish between them if you drag them into their own view container. To solve this, several views use a new icon: "Pull Requests" uses `github-inverted`, "Active Pull Request" tree view uses `diff-multiple`, "Active Pull Request" webview view uses `code-review`. +- The "sidebar" content (reviewers, assignees, labels, etc.) in the pull request description webview have always moved above the pull request body when the webview width is narrow. Now, it also collapses into a compact, readonly view, which can be expanded to make modifications. + +![Collapsed sidbar content on a narrow view](./documentation/changelog/0.118.0/collapsed-sidbar-content.png) + +- Pull request and issue webviews restore after reload. +- The new `#openPullRequest` tool in Copilot Chat lets you reference the pull request currently open in a webview. The `#activePullRequest` tool continues to reference the pull request currently checked out. +- The "Edit Query" command in the "Pull Requests" view has an option to edit the query with Copilot. +- Setting `"githubPullRequests.ignoreSubmodules": true` will cause the extension to ignore submodules when looking for pull requests. +- In the "Issues" view, you can right click on an issue and "Assign to Coding Agent". + +### Fixes + +- Only update coding agent PRs when view is open. https://github.com/microsoft/vscode-pull-request-github/issues/7643 +- Chat participant not honoring selected tools and thinks they are all selected. https://github.com/microsoft/vscode-pull-request-github/issues/7637 +- Red "closed" on closed issues is confusing. https://github.com/microsoft/vscode-pull-request-github/issues/7628 +- github-pull-request_activePullRequest returns empty comments array. https://github.com/microsoft/vscode-pull-request-github/issues/7601 +- Allows me to believe I assigned an issue on a repo where I lack that permission. https://github.com/microsoft/vscode-pull-request-github/issues/7534 +- clicked comment with no contents gave weird state. https://github.com/microsoft/vscode-pull-request-github/issues/7476 +- In GH PR review page, headers have redundant url content. https://github.com/microsoft/vscode-pull-request-github/issues/7509 +- Spurious error when checking out a PR with untracked files. https://github.com/microsoft/vscode-pull-request-github/issues/7294 + +**_Thank You_** + +* [@krassowski (Michał Krassowski)](https://github.com/krassowski): Fix typo "will be replace" → "will be replaced" [PR #7540](https://github.com/microsoft/vscode-pull-request-github/pull/7540) + +## 0.116.1 + +### Fixes + +- Closing a PR causes a flurry of search API calls. https://github.com/microsoft/vscode-pull-request-github/issues/7537 +- Opening a PR description can cause a flurry of GitHub search API calls. https://github.com/microsoft/vscode-pull-request-github/issues/7542 + +## 0.116.0 + +### Changes + +- `#copilotCodingAgent` renders the pull requests it creates as a PR card. + +![pull request card in chat](./documentation/changelog/0.116.0/pr-card-in-chat.png) + +- When checking out a Copilot-authored PR, the Chat view no longer opens. +- You can dismiss the activity bar badge that indicates that Copilot has udpates to a PR by opening the PR description. +- We've simplified the button bar on the pull request description. + +![simplified button bar in pull request header](./documentation/changelog/0.116.0/simplified-pr-header-buttons.png) + +![pull request copy actions moved to link context menu](./documentation/changelog/0.116.0/pr-header-copy-actions.png) + +- You can see a summary of the Copilot coding agent's status in the "Copilot on My Behalf" tree item + +![coding agent summary](./documentation/changelog/0.116.0/coding-agent-status.png) + +- The commit links in the pull request description will open in VS Code in the multidiff editor instead of going to GitHub.com. +- The `[WIP]` prefix that Copilot adds to PR titles is no longer shown in the Pull Requests view. +- Using `@githubpr` is now sticky and will be pre-populated into the chat input for subsequent messages. +- Changes in a PR are pre-fetched when the PR description is opened. +- Pull requested created by Copilot will have `@copilot` as placeholder text in comment inputs. +- If your issue queries (setting `githubIssues.queries`) return no issues, a suggestion to configure your queries is offered. + +![suggestion in scm input to configure queries](./documentation/changelog/0.116.0/suggest-configure-queries.png) + +- The "Checkout Pull Request by Number" command will also accept a pull URL. + +### Fixes + +- Improve PR list view performance. https://github.com/microsoft/vscode-pull-request-github/issues/7141 +- "Cancel coding agent" could use status. https://github.com/microsoft/vscode-pull-request-github/issues/7451 +- Icon missing from the tools picker for coding agent. https://github.com/microsoft/vscode-pull-request-github/issues/7446 +- Copy GitHub Permalink doesn't work for GitHub Managed User (ghe.com). https://github.com/microsoft/vscode-pull-request-github/issues/7389 +- Closing a pull request doesn't remove it from the copilot on my behalf section. https://github.com/microsoft/vscode-pull-request-github/issues/7364 +- `@githubpr` doesn't know PR assignees. https://github.com/microsoft/vscode-pull-request-github/issues/7349 +- "Copilot on My Behalf" tooltip. https://github.com/microsoft/vscode-pull-request-github/issues/7276 +- Unassigning myself from a PR removes all comments from the PR editor. https://github.com/microsoft/vscode-pull-request-github/issues/7218 +- GitHub warning icons aren't well aligned in PR view. https://github.com/microsoft/vscode-pull-request-github/issues/7219 +- pr.openDescription command error. https://github.com/microsoft/vscode/issues/253900 +- Can't assign Copilot when creating new issue from GHPRI directly. https://github.com/microsoft/vscode-pull-request-github/issues/7033 +- Create PR shows error if there has been a previous PR on that branch. https://github.com/microsoft/vscode-pull-request-github/issues/7018 +- Changing around assignees for PRs causes timeline to hide until refresh. https://github.com/microsoft/vscode-pull-request-github/issues/7012 +- Can times in the timeline update periodically? https://github.com/microsoft/vscode-pull-request-github/issues/7006 +- Pull requests view should refresh if a new PR suddenly appears linked in an issue. https://github.com/microsoft/vscode-pull-request-github/issues/6898 +- Opening Issue editor should be instantaneous. https://github.com/microsoft/vscode-pull-request-github/issues/6863 + +## 0.114.2 + +### Fixes + +- Copilot never shows as assignee. https://github.com/microsoft/vscode-pull-request-github/issues/7324 + +## 0.114.1 + +### Fixes + +- Element with id Local Pull Request Branches is already registered. https://github.com/microsoft/vscode-pull-request-github/issues/7264 + +## 0.114.0 + +### Changes + +- We have expanded the integration with GitHub's [Copilot coding agent](https://docs.github.com/en/copilot/how-tos/agents/copilot-coding-agent) (enablement [instructions](https://docs.github.com/en/copilot/how-tos/agents/copilot-coding-agent/enabling-copilot-coding-agent)). You can see the status of all your Coding Agent pull requests in the "Pull Requests" view, and you'll get a badge indicating when a pull request from the Coding Agent has changes. + +![Pull Requests view with Copilot status](./documentation/changelog/0.114.0/copilot-pr-status.png) + +- Links for viewing the Coding Agent session log now open within VS Code instead of opening in the browser. + +![Coding Agent Session Log](./documentation/changelog/0.114.0/session-log.png) + +- The `#activePullRequest` tool in Copilot chat now knows more about the active pull request: changes and Coding Agent session information. This tool is automatically attached to chat when opening a pull request created through the coding agent experience, so you can maintain the context and keep working on the pull request if needed to. + +- When checking out a pull request which doesn't have any diff from the parent branch, the pull request description will be opened, instead of the changes when `"githubPullRequests.focusedMode"` is set to `"multiDiff"` or `"firstDiff"`. + +- You can start a new Coding Agent session by invoking the `#copilotCodingAgent` tool in chat. This tool automatically pushes pending changes to a remote branch and initiates a coding agent session from that branch along with the user's instruction. **Experimental:** Deeper UI integration can be enabled with the `setting(githubPullRequests.codingAgent.uiIntegration)` setting. Once enabled, a new **Delegate to coding agent** button appears in the Chat view for repositories that have the coding agent enabled. + +![Coding Agent Start](./documentation/changelog/0.114.0/coding-agent-start.png) + +### Fixes + +- Leaving a comment shows a pending comment box with an empty input. https://github.com/microsoft/vscode-pull-request-github/issues/7200 +- Lack of 👀 reaction in PR view is important for coding agent. https://github.com/microsoft/vscode-pull-request-github/issues/7213 +- Don't use comment icon to mean quote. https://github.com/microsoft/vscode-pull-request-github/issues/7185 +- PR view always expands and fetches "All Open". https://github.com/microsoft/vscode-pull-request-github/issues/7150 +- Expect option to Open issue in editor after creating new issue. https://github.com/microsoft/vscode-pull-request-github/issues/7034 +- Consider setting a default githubIssues.issueCompletionFormatScm. https://github.com/microsoft/vscode-pull-request-github/issues/7017 +- Times are inconsistent with .com. https://github.com/microsoft/vscode-pull-request-github/issues/7007 +- Padawan Start/Stop Events/Icons. https://github.com/microsoft/vscode-pull-request-github/issues/7004 +- Can't check out a local pull request branch. https://github.com/microsoft/vscode-pull-request-github/issues/6994 +- Unable to get the currently logged-in user. https://github.com/microsoft/vscode-pull-request-github/issues/6971 +- Stuck at creating fork. https://github.com/microsoft/vscode-pull-request-github/issues/6968 + +**_Thank You_** + +* [@dyhagho (Dyhagho Briceño)](https://github.com/dyhagho): fix: Allow Github.com auth when `github-enterprise.uri` is set [PR #7002](https://github.com/microsoft/vscode-pull-request-github/pull/7002) + +## 0.112.0 + +### Changes +- Images in comments from private repositories are now shown in pull request file comments. +- The "Notifications" view is now shown by default, collapsed. +- Issue and pull request links in the timeline an in the issue/pull request body are now opened in VS Code, rather than going to the browser. +- The "Assigned to Me" query in the "Pull Requests" view has been removed, and the "Local Pull Request Branches" and "All Open" queries can be removed using the setting `githubPullRequests.queries`. For repositories with Copilot, a "Copilot on My Behalf" query is added when the setting is unconfigured. +- Unassigned events are now shown in the timeline. +- Copilot "start working", "stop working", and "View Session" are now shown in the timeline. + +![Copilot start and stop working](./documentation/changelog/0.112.0/copilot-start-stop.png) + +### Fixes + +- Interference with interactive rebase. https://github.com/microsoft/vscode-pull-request-github/issues/4904 +- Closed PRs get associated with new branches of the same name. https://github.com/microsoft/vscode-pull-request-github/issues/6711 +- Fails to open PR's description in some repos on GitHub Enterprise. https://github.com/microsoft/vscode-pull-request-github/issues/6736 +- Support closing an issue. https://github.com/microsoft/vscode-pull-request-github/issues/6864 +- Pull request diff shows outdated diff. https://github.com/microsoft/vscode-pull-request-github/issues/6889 + +**_Thank You_** + +* [@kabel (Kevin Abel)](https://github.com/kabel): Allow verified GitHub emails when none are private [PR #6921](https://github.com/microsoft/vscode-pull-request-github/pull/6921) + +## 0.110.0 + +### Changes + +- In preparation for the release of [Project Padawan](https://github.blog/news-insights/product-news/github-copilot-the-agent-awakens/), we added support for assigning to Copilot in the issue webview and @-mentioning Copilot in comments within files. +- There's a new tool and context available in Copilot chat: `#activePullRequest`. This tool gives Copilot chat information about the pull request you have currently open in a webview (or checked out if no webview is open). + +![Ask Copilot to address the comments in the active pull request](./documentation/changelog/0.110.0/copilot-address-comments.png) + +- The issue webview will show when an issue is opened from the "Issues" view or from the notifications view. + +![Issue webview](./documentation/changelog/0.110.0/issue-webview.png) + +- We revisited the top level actions in the Notifications view to make it cleaner, and aligned the display of the Pull Requests view and the Issues view so that they're more consistent. + +- There's a new warning before you try to create a pull request when there's already a pull request open for the same branch. + +![Warning shown when there's already a pull request for a branch](./documentation/changelog/0.110.0/already-pr-branch.png) + +- Pull Request webviews and issue webviews are refreshed every 60 seconds when they are the active tab. +- The default action when adding a comment in a file is now to start a review rather than submit a single comment. +- There's a new action on the Notifications view to mark all pull request notifications with "trivial" updates as done. Enable the action with `githubPullRequests.experimental.notificationsMarkPullRequests`. +- Comment reactions are shown as readonly in the pull request webview (previously not shown at all). + +### Fixes + +- Extension fails to detect PR branch when using gh co . https://github.com/microsoft/vscode-pull-request-github/issues/6378 +- Extension fails to detect PR branch when using gh co - v0.109.2025040408. https://github.com/microsoft/vscode-pull-request-github/issues/6761 +- Element with id All Openhttps://github.com/microsoft/vscode/pull/238345 is already registered. https://github.com/microsoft/vscode-pull-request-github/issues/6615 +- Creating a new issue with keyboard only is disrupted by system dialog. https://github.com/microsoft/vscode-pull-request-github/issues/6666 +- GraphQL error invalid email address when merging PRs. https://github.com/microsoft/vscode-pull-request-github/issues/6696 +- Usability of PR Summarization in Chat. https://github.com/microsoft/vscode-pull-request-github/issues/6698 +- deleting branch after squashing PR not working anymore since vscode 1.98.0. https://github.com/microsoft/vscode-pull-request-github/issues/6699 +- Comments sometimes not resolvable. https://github.com/microsoft/vscode-pull-request-github/issues/6702 +- Can't search for full name when assigning issues. https://github.com/microsoft/vscode-pull-request-github/issues/6748 +- removed request for code owners. https://github.com/microsoft/vscode-pull-request-github/issues/6788 + +**_Thank You_** + +* [@kabel (Kevin Abel)](https://github.com/kabel): Fix merge email confirmation when git config fails [PR #6797](https://github.com/microsoft/vscode-pull-request-github/pull/6797) +* [@timrogers (Tim Rogers)](https://github.com/timrogers): When `copilot-swe-agent` is the author of a comment, render with the Copilot identity [PR #6794](https://github.com/microsoft/vscode-pull-request-github/pull/6794) + +## 0.108.0 + +### Changes + +- Notifications in the experimental Notifications view can be marked as done. + +### Fixes + +- NewIssue.md template doesn't autocomplete Assignees anymore. https://github.com/microsoft/vscode-pull-request-github/issues/6709 + +**_Thank You_** + +* [@aedm (Gábor Gyebnár)](https://github.com/aedm): Adds `sanitizedLowercaseIssueTitle` to settings docs [PR #6690](https://github.com/microsoft/vscode-pull-request-github/pull/6690) + +## 0.106.0 + +### Changes + +- You can provide custom instructions for GitHub Copilot when generating pull request titles and descriptions using the setting `github.copilot.chat.pullRequestDescriptionGeneration.instructions`. You can point the setting to a file in your workspace, or you can provide instructions inline in your settings: + +```json +{ + "github.copilot.chat.pullRequestDescriptionGeneration.instructions": [ + { + "text": "Prefix every PR title with an emoji." + }, + ], +} +``` + +- We've added validation so that it's harder to accidentally set a `github.com` URL as the GitHub Enterprise URL. + +### Fixes + +- Unable to authenticate using Github Enterprise. https://github.com/microsoft/vscode-pull-request-github/issues/6426 +- Cannot add Reviewer to PR once github-actions is added as a reviewer. Cannot add Reviewer to PR once github-actions is added as a reviewer +- On PR to submodule parent package was forked instead of submodule. https://github.com/microsoft/vscode-pull-request-github/issues/6492 +- Email address for merge commit is not remembered (and cannot be set). https://github.com/microsoft/vscode-pull-request-github/issues/6593 +- Copy GitHub Permalink doesn't work with custom SSH. https://github.com/microsoft/vscode-pull-request-github/issues/6668 + +**_Thank You_** + +* [@christianvuerings (Christian Vuerings)](https://github.com/christianvuerings): Fix Copy GitHub Permalink with custom SSH [PR #6669](https://github.com/microsoft/vscode-pull-request-github/pull/6669) + +## 0.104.1 + +### Fixes + +- Suggest a fix with Copilot results in an error Tool copilot_codebase was not contributed. https://github.com/microsoft/vscode-pull-request-github/issues/6632 + +## 0.104.0 + +### Changes + +- The Pull Requests view supports global queries. All old queries will be migrated when you open your workspace to include the current repo as part of the query. Global query support enables you to use the `org` and `repo` properties. +- As part of the support for global queries, we also now have a `today` variable. This variable can be used to refer to the current day, or it can be used with a minus modifier. Together with the global query support, you can now make queries such as "my PRs in my work org that were created in the last 7 days": + +```json + { + "label": "My work last 7 days", + "query": "org:microsoft author:${user} is:closed created:>=${today-7d}" + } +``` +- The context menu in the Pull Requests view has been cleaned up. +- The "pull request" icon shows in the the editor tab for the pull request descriptions. + +![Pull request icon in editor tab](./documentation/changelog/0.104.0/pr-icon-tab.png) + +- `::` style emojis are now supported in comments. +- You can now search with `ctrl+f` in the pull request description webview. +- You can multi-select files in the "Changes in Pull Request" tree view and toggle the selected checkboxes with one click. +- All non-outdated comments for a pull request will show in the "Comments" view when you open the pull-request's description, even if you don't have the PR checked out. They will hide again when all files related to the pull request are closed. +- The "Changes in Pull Request" view has a shortcut for toggling editor commenting. + +![The eye icon as a shortcut to toggle off editor commenting](./documentation/changelog/0.104.0/toggle-editor-commenting.png) + +- Python is no longer excluded from `@` and `#` completions by default. +- There's a new command to copy a pull request link: "Copy Pull Request Link". +- `git.showInlineOpenFileAction` is now respected in the "Changes in Pull Request" view. +- The "Resolve Conversation" and "Unresolve Conversation" command can now be used from keybindings. + +### Fixes + +- Files changed doesn't properly reflect changes against non base branch. https://github.com/microsoft/vscode-pull-request-github/issues/5545 +- Projects quickpick should not have checkboxes when there are no projects. https://github.com/microsoft/vscode-pull-request-github/issues/5757 +- Added projects need separation. https://github.com/microsoft/vscode-pull-request-github/issues/5792 +- Make "Make a Suggestion" more clear. https://github.com/microsoft/vscode-pull-request-github/issues/6040 +- fetching pull requests failed in infinite loop when proxy is unavailable. https://github.com/microsoft/vscode-pull-request-github/issues/6063 +- Using "Create Pull Request" command clears entered data. https://github.com/microsoft/vscode-pull-request-github/issues/6114 +- Non GitHub remotes for submodules causes authentication to fail. https://github.com/microsoft/vscode-pull-request-github/issues/6140 +- "Go to Next Diff in Pull Request" command fails with error. https://github.com/microsoft/vscode-pull-request-github/issues/6237 +- Keyboard Focus is not clearly visible on cancel button. https://github.com/microsoft/vscode-pull-request-github/issues/6449 +- Users are not able to access "Reviewers", "Assignees", "Labels", "Project", link present under project and "Milestone" controls via keyboard. https://github.com/microsoft/vscode-pull-request-github/issues/6450 +- Keyboard focus order is not proper on "Description" and "Create github pull request" screen. https://github.com/microsoft/vscode-pull-request-github/issues/6451 +- NVDA is not announcing any update when user presses ENTER on "Show" and "Hide" control. https://github.com/microsoft/vscode-pull-request-github/issues/6453 +- Review/Comment Suggestions are offset by one line if you make local changes first. https://github.com/microsoft/vscode-pull-request-github/issues/6495 +- When listing workflows running as checks against a PR, include workflow name, not just job name. https://github.com/microsoft/vscode-pull-request-github/issues/6497 +- Diffing OUTDATED comments with HEAD doesn't work in github.dev. https://github.com/microsoft/vscode-pull-request-github/issues/6500 +- error when adding file comment to renamed file w/o other changes. https://github.com/microsoft/vscode-pull-request-github/issues/6516 +- Cannot leave comments on hunks in large diffs. https://github.com/microsoft/vscode-pull-request-github/issues/6524 +- Share menu multiple selection support. https://github.com/microsoft/vscode-pull-request-github/issues/6542 +- Comments don't show on non-checked out PR when closing and re-opening the file from the PRs veiw. https://github.com/microsoft/vscode-pull-request-github/issues/6571 +- Create Pull Request Suggestions silently fails when the suggestion is on the first line. https://github.com/microsoft/vscode-pull-request-github/issues/6603 + +**_Thank You_** + +* [@mikeseese (Mike Seese)](https://github.com/mikeseese): Add opt-in to always prompt for repo for issue creation and add comment to issue file specifying the repo [PR #6115](https://github.com/microsoft/vscode-pull-request-github/pull/6115) +* [@NellyWhads (Nelly Whads)](https://github.com/NellyWhads): Remove the python language user mention exception [PR #6525](https://github.com/microsoft/vscode-pull-request-github/pull/6525) +* [@Ronny-zzl (Zhang)](https://github.com/Ronny-zzl): Don't show hover cards for @-mentioned users in JSDocs in jsx and tsx files [PR #6531](https://github.com/microsoft/vscode-pull-request-github/pull/6531) + +## 0.102.0 + +### Changes + +- The command **GitHub Pull Requests: Close All Pull Request Editors** will close all PR related diff editors and PR original file editors. +- Summarizing a notification with Copilot will print a link to the issue in the Chat view. + +![Issue link shown in Copilot summary](./documentation/changelog/0.102.0/issue-link-in-summary.png) + +### Fixes + +- Enterprise 3.9: GraphQL error Fragment on Bot can't be spread inside RequestedReviewer. https://github.com/microsoft/vscode-pull-request-github/issues/6441 +- Suggestions end up in unsubmittable pending state. https://github.com/microsoft/vscode-pull-request-github/issues/6494 +- Comments not possible to save within a submodule. https://github.com/microsoft/vscode-pull-request-github/issues/6096 +- Globe action to open issue on github.com is missing. https://github.com/microsoft/vscode-pull-request-github/issues/6510 +- PR creation flow is not smooth anymore. https://github.com/microsoft/vscode-pull-request-github/issues/6386 +- PR view buttons overflow in narrow viewports. https://github.com/microsoft/vscode-pull-request-github/issues/6335 + +## 0.100.3 + +### Fixes + +- Can't start a review from a comment due to GraphQL error. https://github.com/microsoft/vscode-pull-request-github/issues/6467 +> Note: This breaks Enterprise 3.9 and earlier again. + +## 0.100.1 + +### Fixes + +- Enterprise 3.9: GraphQL error Fragment on Bot can't be spread inside RequestedReviewer. https://github.com/microsoft/vscode-pull-request-github/issues/6441 + +## 0.100.0 + +### Changes + +This month, our focus was on integrating Copilot into GitHub Pull Requests, using the new VS Code extension API, to showcase how Copilot can be added to an extension. These features are behind several settings. To try everything out, you can set the following settings: +- `"githubPullRequests.experimental.chat": true` +- `"githubPullRequests.experimental.notificationsView": true` + +#### Copilot integration + +- The new `@githubpr` Chat Participant can search for issues on GitHub. + +![Copilot issue search for most open bugs in November](./documentation/changelog/0.100.0/copilot-issue-search-most-bugs.png) + +- When displaying issues, `@githubpr` will show a markdown table and try to pick the best columns to show based on the search. + +![Copilot issue search for closed October bugs](./documentation/changelog/0.100.0/copilot-issue-search.png) + +- Each issue listed in the "Issues" view has a new action, "Summarize With Copilot", that will reveal the Chat panel and summarize the selected issue. We also added another action, "Fix With Copilot", that will summarize the selected issue and will use the workspace context to suggest a fix for it. +- We have added an experimental "Notifications" view that lists the user's unread notifications across repositories. By default the notifications are sorted by most recently updated descending, but you can use the "Sort by Priority using Copilot" action from the view title's `...` menu to have Copilot prioritize the notifications. Clicking on each notification trigger an action to summarize the notification using Copilot. The view also contains easily accessible action to mark a notification as read, as well as open the notification on GitHub.com. + +### Fixes + +- Gift icon is confusing to me here. https://github.com/microsoft/vscode-pull-request-github/issues/6289 +- Cannot get PR to show that is from a fork and main branch. https://github.com/microsoft/vscode-pull-request-github/issues/6267 +- Summary review comment buttons do not disable while review is submitting. https://github.com/microsoft/vscode-pull-request-github/issues/6261 +- Refreshing a PR doesn't refresh comments. https://github.com/microsoft/vscode-pull-request-github/issues/6252 +- Adding a new Review doesn't update the Tree Control Node. https://github.com/microsoft/vscode-pull-request-github/issues/6251 +- pr.markFileAsViewed doesn't update the parent nodes. https://github.com/microsoft/vscode-pull-request-github/issues/6248 +- Infinite error dialogs with GH account mixup. https://github.com/microsoft/vscode-pull-request-github/issues/6245 +- PRs do not refresh after changing account preferences in dropdown. https://github.com/microsoft/vscode-pull-request-github/issues/6244 +- Extension should still work if only upstream requires SAML. https://github.com/microsoft/vscode-pull-request-github/issues/6159 +- Checkbox likes to play Simon Says. https://github.com/microsoft/vscode-pull-request-github/issues/3972 + +## 0.98.0 + +### Fixes + +- Can't approve/reject PRs when in draft mode. https://github.com/microsoft/vscode-pull-request-github/issues/6174 +- Disallow Make a suggestion button press if already have a suggestion in the comment. https://github.com/microsoft/vscode-pull-request-github/issues/6195 +- Untracked files in GHPRI view don't have green text decoration. https://github.com/microsoft/vscode-internalbacklog/issues/5025 +- Don't show error "We couldn't find commit" on outdated comments. https://github.com/microsoft/vscode-pull-request-github/issues/1691 +- Element with id xxx is already registered. https://github.com/microsoft/vscode-pull-request-github/issues/6218 +- Diff Comment with HEAD button in Comments view sometimes disappears. https://github.com/microsoft/vscode-pull-request-github/issues/6157 + +**_Thank You_** + +* [@ixzhao](https://github.com/ixzhao): fix quote reply [PR #6230](https://github.com/microsoft/vscode-pull-request-github/pull/6230) + +## 0.96.0 + +### Changes + +- Local changes to a checked-out PR can be quickly converted to suggestion comments from both the SCM view and from the diff editor context menu. + +![Convert local changes to suggestions](./documentation/changelog/0.96.0/convert-to-suggestions.gif) + +### Fixes + +- Use the editor font code for the diffs. https://github.com/microsoft/vscode-pull-request-github/issues/6146 +- Sort shorter paths to the top. https://github.com/microsoft/vscode-pull-request-github/issues/6143 +- Error git config --local branch.main.github-pr-owner-number. https://github.com/microsoft/vscode-pull-request-github/issues/6134 +- The "accessibility.underlineLinks": true setting is ignored. https://github.com/microsoft/vscode-pull-request-github/issues/6122 + +**_Thank You_** + +* [@jmg-duarte (Jose Duarte)](https://github.com/jmg-duarte) + * Use editor font for diff [PR #6148](https://github.com/microsoft/vscode-pull-request-github/pull/6148) + * Make code blocks use the editor's font family by default [PR #6149](https://github.com/microsoft/vscode-pull-request-github/pull/6149) + +## 0.94.0 + +### Changes + +- You can create revert PRs from the PR description. The PR branch doesn't need to be checked out to create a revert PR. + +![Create a revert PR](./documentation/changelog/0.94.0/create-revert-pr.gif) + +- As a nice side effect of the enabling reverts, you can now see PRs whose branch has been deleted in the "Pull Requests" view. +- The "Open Pull Request on GitHub.com" action shows even when there are PRs from multiple repos checked out. +- `img` tags in code comments will now properly show the image for public repos. + +### Fixes + +- Failed to execute git when deleting branches and remotes. https://github.com/microsoft/vscode-pull-request-github/issues/6051 +- Use notification progress when deleting branches and remotes. https://github.com/microsoft/vscode-pull-request-github/issues/6050 +- Sign in failed: Error: No auth flow succeeded. https://github.com/microsoft/vscode-pull-request-github/issues/6056 +- Extension gets rate limited in a many-repo setup: http forbidden response when adding reviewer after creating pull request. https://github.com/microsoft/vscode-pull-request-github/issues/6042 +- File can't be opened, redirects me to browser. https://github.com/microsoft/vscode-pull-request-github/issues/5827 + +**_Thank You_** + +* [@Santhoshmani1 (Santhosh Mani )](https://github.com/Santhoshmani1): Feature : Add open pr on github from pr description node [PR #6020](https://github.com/microsoft/vscode-pull-request-github/pull/6020) + + +## 0.92.0 + +### Changes + +- Dates are listed in the "Commits" subtree for checked out PRs + + ![commits subtree with dates](./documentation/changelog/0.92.0/date-of-commits.png) + +### Fixes + +- Extension asks for commenting ranges on a file that got deleted in PR. https://github.com/microsoft/vscode-pull-request-github/issues/6046 +- An error occurred while loading the image (renamed picture). https://github.com/microsoft/vscode-pull-request-github/issues/6008 +- GitHub Issue trigger - [ ] does not work. https://github.com/microsoft/vscode-pull-request-github/issues/6007 +- PR Title generation surrounded by "quotes". https://github.com/microsoft/vscode-pull-request-github/issues/6002 +- Unresolve comment does not move focus to it. https://github.com/microsoft/vscode-pull-request-github/issues/5973 + +## 0.90.0 + +### Changes + +- There's a new command available when a PR description is opened: **GitHub Pull Requests: Focus Pull Request Description Review Input**. This command will scroll the PR description to the final comment input box and focus into the input box. + +### Fixes + +- The at sign after the backquote is converted to markdown. https://github.com/microsoft/vscode-pull-request-github/issues/5965 +- Can height of checks area in PR description have a max. https://github.com/microsoft/vscode-pull-request-github/issues/5947 +- Make conflicts hint actionable. https://github.com/microsoft/vscode-pull-request-github/issues/5942 +- Links with an @ are rendered incorrectly. https://github.com/microsoft/vscode-pull-request-github/issues/5924 + +## 0.88.1 + +### Fixes + +- GraphQL error: Invalid email address on EMU. https://github.com/microsoft/vscode-pull-request-github/issues/5842 + +## 0.88.0 + +### Changes + +- Experimental conflict resolution for non-checked out PRs is available when enabled by the hidden setting `"githubPullRequests.experimentalUpdateBranchWithGitHub": true`. This feature allows you to resolve conflicts in a PR without checking out the branch locally. The feature is still experimental and will not work in all cases. +- There's an Accessibility Help Dialog that shows when "Open Accessibility Help" is triggered from the "Pull Requests" and "Issues" views. + + ![Accessibility help dialog](./documentation/changelog/0.88.0/accessibility-help.png) +- All review action buttons will show in the Active Pull Request sidebar view when there's enough space. + + ![Show all review actions in sidebar](./documentation/changelog/0.88.0/show-all-review-actions.gif) + +### Fixes + +- Show some loading indicator when switching PR descriptions. https://github.com/microsoft/vscode-pull-request-github/issues/5954 +- Many ripgrep. https://github.com/microsoft/vscode-pull-request-github/issues/5923 +- The icon for un-resolve is too close to undo. https://github.com/microsoft/vscode-pull-request-github/issues/5868 +- PR order does not match multi-root order anymore. https://github.com/microsoft/vscode-pull-request-github/issues/5789 +- Handle renamed files. https://github.com/microsoft/vscode-pull-request-github/issues/5767 + +## 0.86.1 + +### Fixes + +- Create PR viewlet clears itself after changing base repository and branch. https://github.com/microsoft/vscode-pull-request-github/issues/5878 +- Field 'mergeQueueEntry' doesn't exist on type 'PullRequest'. https://github.com/microsoft/vscode-pull-request-github/issues/5808 + +## 0.86.0 + +### Changes + +- The new `auto` value for `githubPullRequests.createDefaultBaseBranch` will use the upstream's default branch as the base branch for fork repositories. +- Outdated comments now show a badge in the Comments view. + + ![Outdated comment in view](./documentation/changelog/0.86.0/outdated-comment.png) +- Colors for checks and Xs on PR page. https://github.com/microsoft/vscode-pull-request-github/issues/5754 +- Comment threads in the Comments view now have inline actions and context menu actions. Outdated comments have a "Diff Comment with HEAD" action which is particularly useful for understanding what changed on an outdated comment. + + ![Comment thread context menu](./documentation/changelog/0.86.0/context-menu-comment.png) + ![Comment thread inline actions](./documentation/changelog/0.86.0/inline-action-comments-view.png) + +### Fixes + +- Trim leading whitespace in PR description. https://github.com/microsoft/vscode-pull-request-github/issues/5780 +- Flickering When Editing A Comment. https://github.com/microsoft/vscode-pull-request-github/issues/5762 + +**_Thank You_** + +* [@ipcjs (ipcjs)](https://github.com/ipcjs): fix: make `review.openLocalFile` support triggering from the keyboard. [PR #5840](https://github.com/microsoft/vscode-pull-request-github/pull/5840) +* [@mohamedamara1 (Mohamed Amara)](https://github.com/mohamedamara1): fixed ID of IssueOverviewPanel [PR #5822](https://github.com/microsoft/vscode-pull-request-github/pull/5822) + +## 0.84.0 + +- There is no extension version 0.84.0 because of a version increase mistake. + +## 0.82.0 + +### Changes + +- There is an option to choose which email to associate a merge or squash commit with. + + ![Commit associated with email](./documentation/changelog/0.82.0/email-for-commit.png) +- The setting `githubPullRequests.labelCreated` can be used to configure the labels that are automatically added to PRs that are created. +- When the cursor is in a comment widget input, the keybinding `ctrl+k m` or (`cmd+k m` on mac) can be used to insert a suggestion. +- Video previews now show in the PR description. +- The activity bar entry has been renamed from "GitHub" to "GitHub Pull Requests". The extension has been renamed from "GitHub Pull Requests and Issues" to "GitHub Pull Requests". +- "Owner level" PR templates are now supported. This means that a PR template can be defined in the `.github` repository of an organization or user and it will be used for all repositories owned by that organization or user which do not have a repository-specific PR template. +- Projects can be added to a PR from the "Create" view. Projects can also be added to new issues. + +### Fixes + +- [Accessibility] Remove redundant prefix-style info from PR change view. https://github.com/microsoft/vscode-pull-request-github/issues/5705 +- Append PR number to merge commit message. https://github.com/microsoft/vscode-pull-request-github/issues/5690 +- Update with merge commitbutton does not go away despite merging. https://github.com/microsoft/vscode-pull-request-github/issues/5661 +- "Bad credentials" with no additional information to help resolve. https://github.com/microsoft/vscode-pull-request-github/issues/5576 + +**_Thank You_** + +* [@Malix-off (Malix)](https://github.com/Malix-off): Fix #5693 [PR #5694](https://github.com/microsoft/vscode-pull-request-github/pull/5694) +* [@umakantv (Umakant Vashishtha)](https://github.com/umakantv): Feature: Auto Populate Labels [PR #5679](https://github.com/microsoft/vscode-pull-request-github/pull/5679) + +## 0.80.1 + +### Fixes + +- Suggested changes are not easily distinguishable. https://github.com/microsoft/vscode-pull-request-github/issues/5667 + +## 0.80.0 + +### Changes + +- Issue queries shown in the "Issues" view can be grouped by repository and milestone using the new `groupBy` property in the `githubIssues.queries` setting. + ```json + "githubIssues.queries": [ + { + "label": "Current", + "query": "assignee:alexr00 is:open sort:updated-desc milestone:\"February 2024\" sort:updated-desc", + "groupBy": [ + "milestone", + "repository" + ] + } + ], + ``` + + ![Group by repository and milestone](/documentation/changelog/0.80.0/group-by-milestone-repo.png) +- The setting `githubPullRequests.createDefaultBaseBranch` can be used to set the default base branch when creating a PR. By default, the branch that the current branch was created from is used (so long as that branch exists on the remote). Setting `repositoryDefault` will cause the repository's default branch to be used instead. +- Added files are opened in a regular editor instead of a diff editor when viewing changes in a PR. +- Merge commits are skipped when choosing a default PR title and description. This is to avoid the case where the merge commit message is used as the PR title and description. +- GitHub permalinks in comments for a checked out PR can now be opened in VS Code instead of just taking you to the browser. + + ![Open permalink locally](/documentation/changelog/0.80.0/open-link-locally.gif) +- The base branch can be merged into a checked out PR branch from the Pull Request Description. + + ![Merge base branch into PR branch](/documentation/changelog/0.80.0/merge-base-into-pr.png) +-The setting `githubPullRequests.pullPullRequestBranchBeforeCheckout` also has new options to automatically fetch the base and merge it into the PR branch at checkout time. +- Merge conflicts can be resolved from the Pull Request Description when the PR is checked out. + + ![Resolve merge conflicts](/documentation/changelog/0.80.0/resolve-merge-conflicts.png) +- The hover on reactions now shows who left the reaction. + + ![Reaction hover](/documentation/changelog/0.80.0/reaction-hover.png) +- Issue templates are now available when creating an issue. + +- Setting `"githubPullRequests.focusedMode": "multiDiff"` will open the multi-diff editor with all the files in the PR upon checkout. + + ![Multi-diff editor for a PR](/documentation/changelog/0.80.0/multi-diff-editor.png) + +### Fixes + +- Comments for not-checked-out PRs should be removed from Comments view when no files from the PR are open. https://github.com/microsoft/vscode-pull-request-github/issues/5619 +- [Accessibility] No alert message is spoken to screen reader when completing a review. https://github.com/microsoft/vscode-pull-request-github/issues/5526 +- [Accessibility] Semantic heading tag is missing in issue and PR webview comments. https://github.com/microsoft/vscode-pull-request-github/issues/5524 +- Custom tree checkboxes have unexpected delayed reaction. https://github.com/microsoft/vscode-pull-request-github/issues/5676 +- Close Pull request Button is not working. https://github.com/microsoft/vscode-pull-request-github/issues/5598 +- Default Create Option: createDraft. https://github.com/microsoft/vscode-pull-request-github/issues/5584 +- collapses an open PR review tree on refresh. https://github.com/microsoft/vscode-pull-request-github/issues/5556 +- Queries apart from "All Open" don't work, output is full of rate limit errors. https://github.com/microsoft/vscode-pull-request-github/issues/5496 +- Opening multi-root workspace triggers rate-limiting error. https://github.com/microsoft/vscode-pull-request-github/issues/4351 +- Narrator is not announcing the state of Expanded/collapsed for "Create with Option" arrow button. https://github.com/microsoft/vscode-pull-request-github/issues/5483 +- Usabilty: At High contrast(Desert) mode for "Cancel,Create,Create with Option" button keyboard focus is not visible clearly.https://github.com/microsoft/vscode-pull-request-github/issues/5482 +- Create-PR view: sparkle icon doesn't visually indicate that it has focus. https://github.com/microsoft/vscode-pull-request-github/issues/5471 +- Unable to select default branch (main) on upstream repo, when working off a fork. https://github.com/microsoft/vscode-pull-request-github/issues/5470 +- Task list checkboxes aren't rendered. https://github.com/microsoft/vscode-pull-request-github/issues/5310 +- Copy Permalink fails frequently, seemingly on the first copy of the day. https://github.com/microsoft/vscode-pull-request-github/issues/5185 +- SCM title menu Create Pull Request action is unavailable when in a Remote window. https://github.com/microsoft/vscode-pull-request-github/issues/3911 +- Scroll position is not maintained. https://github.com/microsoft/vscode-pull-request-github/issues/1202 + +**_Thank You_** + +* [@Balastrong (Leonardo Montini)](https://github.com/Balastrong): Create issue from markdown template [PR #5503](https://github.com/microsoft/vscode-pull-request-github/pull/5503) +* [@joshuaobrien](https://github.com/joshuaobrien) + * Batch mark/unmark files as viewed [PR #4700](https://github.com/microsoft/vscode-pull-request-github/pull/4700) + * Remove a few unused variables [PR #5510](https://github.com/microsoft/vscode-pull-request-github/pull/5510) +* [@pouyakary (Pouya Kary)](https://github.com/pouyakary): Fixes #5620 [PR #5621](https://github.com/microsoft/vscode-pull-request-github/pull/5621) + +## 0.78.1 + +### Fixes + +- Files changed doesn't properly reflect changes against non base branch. https://github.com/microsoft/vscode-pull-request-github/issues/5545 +- Cannot review PRs with 0.78.0 / VSCode 1.85.0, "GraphQL error: Field 'mergeQueueEntry' doesn't exist. https://github.com/microsoft/vscode-pull-request-github/issues/5544 + +## 0.78.0 + +### Changes + +- Merge queues are now supported in the PR description and create view. + + ![Merge queues in PR description](/documentation/changelog/0.78.0/merge-queue.png) + +- The new setting `"githubPullRequests.allowFetch": false` will prevent `fetch` from being run. +- Projects are now cached for quicker assignment from the PR description. +- Merge commit message uses the message configured in the GitHub repository settings. +- Clicking on the filename of a comment in the PR description will open at the correct line. +- The repository name is shown in the "Changes in PR" view when there are PRs from multiple repositories shown in the view. + + ![Repository name in "Changes in PR" view](/documentation/changelog/0.78.0/repo-name-changes-view.png) + +### Fixes + +- Copy permalink uses wrong repository for submodules. https://github.com/microsoft/vscode-pull-request-github/issues/5181 +- Unable to select a repository when submodules are present. https://github.com/microsoft/vscode-pull-request-github/issues/3950. +- "We couldn't find commit" when submodules exist. https://github.com/microsoft/vscode-pull-request-github/issues/1499 +- Uses PR template from the wrong repo in multi-root workspace. https://github.com/microsoft/vscode-pull-request-github/issues/5489 +- At high contrast mode "Create with option" arrow button is not visible. https://github.com/microsoft/vscode-pull-request-github/issues/5480 +- Remove PR from "Waiting For My Review" list after I review it. https://github.com/microsoft/vscode-pull-request-github/issues/5379 + +**_Thank You_** + +* [@flpcury (Felipe Cury)](https://github.com/flpcury): Fix deprecation messages for createDraft and setAutoMerge [PR #5429](https://github.com/microsoft/vscode-pull-request-github/pull/5429) +* [@gjsjohnmurray (John Murray)](https://github.com/gjsjohnmurray): Treat `githubIssues.useBranchForIssues` setting description as markdown (fix #5506) [PR #5508](https://github.com/microsoft/vscode-pull-request-github/pull/5508) +* [@kurowski (Brandt Kurowski)](https://github.com/kurowski): add setting to never offer ignoring default branch pr [PR #5435](https://github.com/microsoft/vscode-pull-request-github/pull/5435) +* [@ThomsonTan (Tom Tan)](https://github.com/ThomsonTan): Iterate the diffs in each active PR in order [PR #5437](https://github.com/microsoft/vscode-pull-request-github/pull/5437) + +## 0.76.1 + +### Changes + +- Added telemetry for the acceptance rate of the generated PR title and description. + +## 0.76.0 + +### Changes + +- Integration with the GitHub Copilot Chat extension provides PR title and description generation. + + ![GitHub Copilot Chat integration](/documentation/changelog/0.76.0/github-copilot-title-description.gif) + +- "Project" can be set from the PR description webview. + + ![Project shown in PR description](/documentation/changelog/0.76.0/project-in-description.png) + +- Pull requests checked out using the GitHub CLI (`gh pr checkout`) are now recognized. +- The new `"none"` value for the setting `"githubPullRequests.pullRequestDescription"` will cause the title and description of the **Create** view to be empty by default. + +### Fixes + +- Could "Create a Pull Request" make fields within the create-pr view available faster?. https://github.com/microsoft/vscode-pull-request-github/issues/5399 +- Commits view is showing a commit with wrong author. https://github.com/microsoft/vscode-pull-request-github/issues/5352 +- Reviewer dropdown never hits cache. https://github.com/microsoft/vscode-pull-request-github/issues/5316 +- Settings option Pull Branch not honored. https://github.com/microsoft/vscode-pull-request-github/issues/5307 +- Comment locations error messages after deleting PR branch. https://github.com/microsoft/vscode-pull-request-github/issues/5281 + +## 0.74.1 + +### Fixes + +- Unable to Add Comments in PR on fork using GitHub Pull Requests Extension in VSCode. https://github.com/microsoft/vscode-pull-request-github/issues/5317 + +## 0.74.0 + +### Changes + +- Accessibility for reviewing PRs has been improved. See https://github.com/microsoft/vscode-pull-request-github/issues/5225 and https://github.com/microsoft/vscode/issues/192377 for a complete list of improvements. +- Commits are shown in the Create view even when the branch hasn't been published. +- The "Commits" node in the "Changes in Pull Request" tree now shows more than 30 commits. + +### Fixes + +- Using "Create an Issue" a 2nd time does not create a new issue, but a NewIssue.md with tons of numbers. https://github.com/microsoft/vscode-pull-request-github/issues/5253 +- Add +/- to added/deleted lines in PR description. https://github.com/microsoft/vscode-pull-request-github/issues/5224 +- Duplicate @mention suggestions. https://github.com/microsoft/vscode-pull-request-github/issues/5222 +- Don't require commit message for "Rebase and Merge". https://github.com/microsoft/vscode-pull-request-github/issues/5221 +- Focus in list of changes resets when opening file. https://github.com/microsoft/vscode-pull-request-github/issues/5173 + +**_Thank You_** + +* [@hsfzxjy (hsfzxjy)](https://github.com/hsfzxjy): Add a refresh button in the header of comment thread [PR #5229](https://github.com/microsoft/vscode-pull-request-github/pull/5229) + +## 0.72.0 + +### Changes + +- The pull request base in the "Create" view will use the upstream repo as the base if the current branch is a fork. +- There's a refresh button in the Comments view to immediately refresh comments. + +### Fixes + +- PR view comments should have a maximum width, with the code view using a horizontal scrollbar. https://github.com/microsoft/vscode-pull-request-github/issues/5155 +- Code suggestions in PRs are hard to differentiate. https://github.com/microsoft/vscode-pull-request-github/issues/5141 +- No way to remove Milestone. https://github.com/microsoft/vscode-pull-request-github/issues/5102 +- Progress feedback on PR description actions. https://github.com/microsoft/vscode-pull-request-github/issues/4954 + +**_Thank You_** + +* [@tobbbe (Tobbe)](https://github.com/tobbbe): Sanitize slashes from title [PR #5149](https://github.com/microsoft/vscode-pull-request-github/pull/5149) + +## 0.70.0 + +### Changes + +- The "Create" view has been updated to be less noisy and more useful. Aside from the purely visual changes, the following features have been added: + - We try to guess the best possible base branch for your PR instead of always using the default branch. + - You can add reviewers, assignees, labels, and milestones to your PR from the "Create" view. + - By default, your last "create option" will be remembered (ex. draft or auto merge) + - The view is much faster. + - You can view diffs before publishing your branch. + - Once the branch is published, you can also view commits (this is coming soon for unpublished branches). + + ![The new create view](/documentation/changelog/0.70.0/new-create-view.png) + +- If you work on a fork of a repository, but don't ever want to know about or make PRs to the parent, you can prevent the `upstream` remote from being added with the new setting `"githubPullRequests.upstreamRemote": "never"`. + +### Fixes + +- Quote reply missing for some comments. https://github.com/microsoft/vscode-pull-request-github/issues/5012 +- Accessibility of "suggest edits" new workflow and documentation. https://github.com/microsoft/vscode-pull-request-github/issues/4946 + +**_Thank You_** + +* [@mgyucht (Miles Yucht)](https://github.com/mgyucht): Correctly iterate backwards through diffs across files [PR #5036](https://github.com/microsoft/vscode-pull-request-github/pull/5036) + +## 0.68.1 + +### Fixes + +- Github Enterprise Doesn't Show Comments. https://github.com/microsoft/vscode-pull-request-github/issues/4995 +- Buffer is not defined when adding labels. https://github.com/microsoft/vscode-pull-request-github/issues/5009 + +## 0.68.0 + +### Changes + +- Avatars in tree views and comments are circles instead of squares + +![Circle avatar](/documentation/changelog/0.68.0/circle-avatar.png) + +- The old "Suggest Edit" command from the SCM view now directs you to "Suggest a Change" feature introduced in version 0.58.0. +- Up to 1000 (from the previous 100) comment threads can be loaded in a pull request. +- The new VS Code API proposal for a read-only message let's you check out a PR directly from an un-checked-out diff. + +![Read-only PR file message](/documentation/changelog/0.68.0/read-only-file-message.png) + +### Fixes + +- User hover shows null when writing the @username. https://github.com/microsoft/vscode-pull-request-github/issues/4891 +- Reverted PR remains visible in "Local Pull Request Branches" tab of sidebar. https://github.com/microsoft/vscode-pull-request-github/issues/4855 +- Order of workspaces in multi-root workspace is not what I expect. https://github.com/microsoft/vscode-pull-request-github/issues/4837 +- Reassigning same reviewers causes desync with GitHub. https://github.com/microsoft/vscode-pull-request-github/issues/4836 +- Re-request review from one reviewer will remove other reviewers. https://github.com/microsoft/vscode-pull-request-github/issues/4830 +- Don't reload entire DOM when getting data from GitHub. https://github.com/microsoft/vscode-pull-request-github/issues/4371 + +**_Thank You_** + +* [@SKPG-Tech (Salvijus K.)](https://github.com/SKPG-Tech): Fix null when no user name available [PR #4892](https://github.com/microsoft/vscode-pull-request-github/pull/4892) + +## 0.66.2 + +### Fixes + +- Use `supportHtml` for markdown that just cares about coloring spans for showing issue labels. [CVE-2023-36867](https://msrc.microsoft.com/update-guide/vulnerability/CVE-2023-36867) + +## 0.66.1 + +### Fixes + +- TypeError: Cannot read properties of undefined (reading 'number'). https://github.com/microsoft/vscode-pull-request-github/issues/4893 + +## 0.66.0 + +### Changes + +- We show the same welcome view as the git extension when you open a subfolder of a git repository. + +![Git subfolder welcome view](documentation/changelog/0.66.0/git-subfolder-welcome.png) + +- Improved performance of extension activation, particularly for multi-repo workspaces +- There are two new actions for viewing diffs of checked out PRs: **Compare Base With Pull Request Head (readonly)** and **Compare Pull Request Head with Local**. These actions are available from the PR changes context menu. + +![Compare changes with commands location](documentation/changelog/0.66.0/compare-changes-with-commands.png) + +- The new setting `"githubPullRequests.pullPullRequestBranchBeforeCheckout"` can be used to turn off pulling a previously checked out PR branch when checking out that same branch again. + +### Fixes + +- Bad/missing error handling when creating PR can lead to being rate limited. https://github.com/microsoft/vscode-pull-request-github/issues/4848 +- My vscode workspace sometimes shows a PR from vscode-cpptools. https://github.com/microsoft/vscode-pull-request-github/issues/4842 +- Improper `@mentions` in comments. https://github.com/microsoft/vscode-pull-request-github/issues/4810 +- Duplicated issues in tree. https://github.com/microsoft/vscode-pull-request-github/issues/4781 +- Element with id Local Pull Request Brancheshttps... is already registered. https://github.com/microsoft/vscode-pull-request-github/issues/4642 + +**_Thank You_** + +* [@kabel (Kevin Abel)](https://github.com/kabel): Simplify `AuthProvider` enum [PR #4779](https://github.com/microsoft/vscode-pull-request-github/pull/4779) +* [@SKPG-Tech (Salvijus K.)](https://github.com/SKPG-Tech): Add missing index in template [PR #4822](https://github.com/microsoft/vscode-pull-request-github/pull/4822) +* [@unknovvn (Andzej Korovacki)](https://github.com/unknovvn): Use git setting to fetch before checkout in checkoutExistingPullRequestBranch [PR #4759](https://github.com/microsoft/vscode-pull-request-github/pull/4759) + +## 0.64.0 + +### Changes + +- File level comments can be created from PR files. + +![File level comments](documentation/changelog/0.64.0/file-level-comments.gif) + +- We have an internal rate limit which should help prevent us from hitting GitHub's rate limit. +- All of the places where you can "Checkout default branch" respect the git setting `"git.pullBeforeCheckout"`. +- Team reviewers can be added as reviewers to PRs from the PR overview/description. Fetching team reviewers can be slow, so they are only fetched on demand and are then cached until you fetch them on demand again. + +![Show or refresh team reviewers button](documentation/changelog/0.64.0/get-team-reviewers.png) + +### Fixes + +- quickDiff setting is ignored. https://github.com/microsoft/vscode-pull-request-github/issues/4726 +- Overview shows closed instead of merged. https://github.com/microsoft/vscode-pull-request-github/issues/4721 +- 'Commit & Create Pull Request' automatically pushes when working on a PR. https://github.com/microsoft/vscode-pull-request-github/issues/4692 +- PRs for only one repo show in a multi root workspace. https://github.com/microsoft/vscode-pull-request-github/issues/4682 +- Publishing branch reset target branch to main. https://github.com/microsoft/vscode-pull-request-github/issues/4681 +- Old PR editors show error after revisiting. https://github.com/microsoft/vscode-pull-request-github/issues/4661 +- org in issue query causes crash. https://github.com/microsoft/vscode-pull-request-github/issues/4595 + +**_Thank You_** + +* [@Balastrong (Leonardo Montini)](https://github.com/Balastrong) + * Add x button to remove a label from a new PR [PR #4649](https://github.com/microsoft/vscode-pull-request-github/pull/4649) + * Change file mode for execute husky hook on MacOS [PR #4695](https://github.com/microsoft/vscode-pull-request-github/pull/4695) +* [@eastwood (Clinton Ryan)](https://github.com/eastwood): Gracefully handle errors where the SSH configuration file is corrupt or malformed [PR #4644](https://github.com/microsoft/vscode-pull-request-github/pull/4644) +* [@kabel (Kevin Abel)](https://github.com/kabel) + * Fix status checks rendering [PR #4542](https://github.com/microsoft/vscode-pull-request-github/pull/4542) + * Make the display of PR number in tree view configurable [PR #4576](https://github.com/microsoft/vscode-pull-request-github/pull/4576) + * Centralize all configuration strings into `settingKeys.ts` [PR #4577](https://github.com/microsoft/vscode-pull-request-github/pull/4577) + * Move `PullRequest` to a shared location for reviewing of types [PR #4578](https://github.com/microsoft/vscode-pull-request-github/pull/4578) +* [@ypresto (Yuya Tanaka)](https://github.com/ypresto): Fix wrong repo URL for nested repos in workspace (fix copy permalink) [PR #4711](https://github.com/microsoft/vscode-pull-request-github/pull/4711) + +## 0.62.0 + +### Changes + +- Pull requests can be opened on vscode.dev from the Pull Requests view. +- Collapse state is preserved in the Issues view. +- There's a new setting to check the "auto-merge" checkbox in the Create view: `githubPullRequests.setAutoMerge`. + +### Fixes + +- Cannot remove the last label. https://github.com/microsoft/vscode-pull-request-github/issues/4634 +- @type within code block rendering as link to GitHub user. https://github.com/microsoft/vscode-pull-request-github/issues/4611 + +**_Thank You_** + +* [@Balastrong (Leonardo Montini)](https://github.com/Balastrong) + * Allow empty labels array to be pushed to set-labels to remove all of them [PR #4637](https://github.com/microsoft/vscode-pull-request-github/pull/4637) + * Allow empty array to be pushed to remove the last label [PR #4648](https://github.com/microsoft/vscode-pull-request-github/pull/4648) + +## 0.60.0 + +### Changes + +- Permalinks are rendered better in both the comments widget and in the PR description. + +![Permalink in description](documentation/changelog/0.60.0/permalink-description.png) +![Permalink in comment widget](documentation/changelog/0.60.0/permalink-comment-widget.png) + +- The description has a button to re-request a review. + +![Re-request review](documentation/changelog/0.60.0/re-request-review.png) + +- Quick diffs are no longer experimental. You can turn on PR quick diffs with the setting `githubPullRequests.quickDiff`. + +![Pull request quick diff](documentation/changelog/0.60.0/quick-diff.png) + +- Extension logging log level is now controlled by the command "Developer: Set Log Level". The old setting for log level has been deprecated. + +### Fixes + +- Make a suggestion sometimes only works once. https://github.com/microsoft/vscode-pull-request-github/issues/4470 + +**_Thank You_** + +* [@joshuaobrien](https://github.com/joshuaobrien) + * Unify style of re-request review button [PR #4539](https://github.com/microsoft/vscode-pull-request-github/pull/4539) + * Ensure `re-request-review` command is handled in activityBarViewProvider [PR #4540](https://github.com/microsoft/vscode-pull-request-github/pull/4540) + * Prevent timestamp in comments overflowing [PR #4541](https://github.com/microsoft/vscode-pull-request-github/pull/4541) +* [@kabel (Kevin Abel)](https://github.com/kabel): Ignore more files from the vsix [PR #4530](https://github.com/microsoft/vscode-pull-request-github/pull/4530) + +## 0.58.2 + +### Fixes + +- "GitHub Pull Requests and Issues" plugin causing a large number of requests to github enterprise installation. https://github.com/microsoft/vscode-pull-request-github/issues/4523 + +## 0.58.1 + +### Fixes + +- Replacing a label with another appears to work in vscode but doesn't. https://github.com/microsoft/vscode-pull-request-github/issues/4492 + ## 0.58.0 ### Changes diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt deleted file mode 100644 index 17376a61e0..0000000000 --- a/ThirdPartyNotices.txt +++ /dev/null @@ -1,2519 +0,0 @@ -VS Code Extension for Managing GitHub Pull Requests - -NOTICES AND INFORMATION -Do Not Translate or Localize - -This software incorporates material from third parties. -Microsoft makes certain open source code available at https://3rdpartysource.microsoft.com, -or you may send a check or money order for US $5.00, including the product name, -the open source component name, platform, and version number, to: - -Source Code Compliance Team -Microsoft Corporation -One Microsoft Way -Redmond, WA 98052 -USA - -Notwithstanding any other terms, you may reverse engineer this software to the extent -required to debug changes to any libraries licensed under the GNU Lesser General Public License. - ---------------------------------------------------------- - -tslib 1.14.1 - 0BSD -https://www.typescriptlang.org/ - -Copyright (c) Microsoft Corporation - -Copyright (c) Microsoft Corporation. - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. - ---------------------------------------------------------- - ---------------------------------------------------------- - -before-after-hook 2.2.0 - Apache-2.0 -https://github.com/gr2m/before-after-hook#readme - -Copyright 2018 Gregor Martynus and other contributors. - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2018 Gregor Martynus and other contributors. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -webidl-conversions 3.0.1 - BSD-2-Clause -https://github.com/jsdom/webidl-conversions#readme - -Copyright (c) 2014, Domenic Denicola - -# The BSD 2-Clause License - -Copyright (c) 2014, Domenic Denicola -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -deprecation 2.3.1 - ISC -https://github.com/gr2m/deprecation#readme - -Copyright (c) Gregor Martynus and contributors - -The ISC License - -Copyright (c) Gregor Martynus and contributors - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -lru-cache 6.0.0 - ISC -https://github.com/isaacs/node-lru-cache#readme - -Copyright (c) Isaac Z. Schlueter and Contributors - -The ISC License - -Copyright (c) Isaac Z. Schlueter and Contributors - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -once 1.4.0 - ISC -https://github.com/isaacs/once#readme - -Copyright (c) Isaac Z. Schlueter and Contributors - -The ISC License - -Copyright (c) Isaac Z. Schlueter and Contributors - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -universal-user-agent 6.0.0 - ISC -https://github.com/gr2m/universal-user-agent#readme - -Copyright (c) 2018, Gregor Martynus (https://github.com/gr2m) - -# [ISC License](https://spdx.org/licenses/ISC) - -Copyright (c) 2018, Gregor Martynus (https://github.com/gr2m) - -Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -wrappy 1.0.2 - ISC -https://github.com/npm/wrappy - -Copyright (c) Isaac Z. Schlueter and Contributors - -The ISC License - -Copyright (c) Isaac Z. Schlueter and Contributors - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -yallist 4.0.0 - ISC -https://github.com/isaacs/yallist#readme - -Copyright (c) Isaac Z. Schlueter and Contributors - -The ISC License - -Copyright (c) Isaac Z. Schlueter and Contributors - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@microsoft/1ds-core-js 3.2.3 - MIT -https://github.com/microsoft/ApplicationInsights-JS#readme - -copyright Microsoft 2018 -copyright Microsoft 2019 -Copyright (c) Microsoft Corporation -copyright Microsoft 2019 Simplified -Copyright (c) Microsoft and contributors -copyright Microsoft 2018 import AppInsightsCore as InternalCore - -Copyright (c) Microsoft Corporation. - -MIT License - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - ---------------------------------------------------------- - ---------------------------------------------------------- - -@microsoft/1ds-post-js 3.2.3 - MIT -https://github.com/microsoft/ApplicationInsights-JS#readme - -copyright Microsoft 2018 -copyright Microsoft 2020 -copyright Microsoft 2018-2020 -copyright Microsoft 2022 Simple -Copyright (c) Microsoft Corporation -Copyright (c) Microsoft and contributors -copyright Microsoft 2018-2020 import IExtendedAppInsightsCore, SendRequestReason, EventSendType -copyright Microsoft 2018-2020 import IPerfManagerProvider, IValueSanitizer, FieldValueSanitizerType, FieldValueSanitizerFunc, SendRequestReason, EventSendType - -Copyright (c) Microsoft Corporation. - -MIT License - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - ---------------------------------------------------------- - ---------------------------------------------------------- - -@microsoft/applicationinsights-core-js 2.8.4 - MIT -https://github.com/microsoft/ApplicationInsights-JS#readme - -Copyright (c) Microsoft Corporation -Copyright (c) Microsoft and contributors - - MIT License - - Copyright (c) Microsoft Corporation. All rights reserved. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@microsoft/applicationinsights-shims 2.0.1 - MIT -https://github.com/microsoft/ApplicationInsights-JS/tree/master/tools/shims - -Copyright (c) Microsoft Corporation. -Copyright (c) Microsoft and contributors. - - MIT License - - Copyright (c) Microsoft Corporation. All rights reserved. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@microsoft/dynamicproto-js 1.1.6 - MIT -https://github.com/microsoft/DynamicProto-JS#readme - -(c) James Halliday -Copyright (c) Microsoft Corporation -Copyright (c) 2012 Maximilian Antoni -Copyright (c) 2013 Maximilian Antoni -Copyright (c) 2020 Oliver Nightingale -Copyright (c) 2020 Oliver Nightingale A -Copyright (c) 2020 Oliver Nightingale An -Copyright (c) Microsoft and contributors -Copyright (c) 2010-2013 Christian Johansen -Copyright (c) 2010-2014 Christian Johansen -Copyright (c) 2011 Sven Fuchs, Christian Johansen -Copyright Joyent, Inc. and other Node contributors -Copyright jQuery Foundation and other contributors -Copyright 2013 jQuery Foundation, Inc. and other contributors -Copyright (c) 2010-2014, Christian Johansen, christian@cjohansen.no -Copyright 2005, 2014 jQuery Foundation, Inc. and other contributors -Copyright 2006 Google Inc. https://code.google.com/p/google-diff-match-patch - - MIT License - - Copyright (c) Microsoft Corporation. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/auth-token 2.4.5 - MIT -https://github.com/octokit/auth-token.js#readme - -Copyright (c) 2019 Octokit - -The MIT License - -Copyright (c) 2019 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/core 3.3.1 - MIT -https://github.com/octokit/core.js#readme - -Copyright (c) 2019 Octokit - -The MIT License - -Copyright (c) 2019 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/endpoint 6.0.11 - MIT -https://github.com/octokit/endpoint.js#readme - -Copyright (c) 2018 Octokit -Copyright (c) 2012-2014, Bram Stein - -The MIT License - -Copyright (c) 2018 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/graphql 4.6.1 - MIT -https://github.com/octokit/graphql.js#readme - -Copyright (c) 2018 Octokit - -The MIT License - -Copyright (c) 2018 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/openapi-types 12.11.0 - MIT -https://github.com/octokit/openapi-types.ts#readme - -Copyright 2020 Gregor Martynus - -Copyright 2020 Gregor Martynus - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/openapi-types 5.3.2 - MIT -https://github.com/octokit/openapi-types.ts#readme - - -Copyright 2020 Gregor Martynus - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/plugin-paginate-rest 2.13.2 - MIT -https://github.com/octokit/plugin-paginate-rest.js#readme - -Copyright (c) 2019 Octokit - -MIT License Copyright (c) 2019 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/plugin-request-log 1.0.3 - MIT -https://github.com/octokit/plugin-request-log.js#readme - - -MIT License Copyright (c) 2020 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/plugin-rest-endpoint-methods 4.12.2 - MIT -https://github.com/octokit/plugin-rest-endpoint-methods.js#readme - -Copyright (c) 2019 Octokit - -MIT License Copyright (c) 2019 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/request 5.4.14 - MIT -https://github.com/octokit/request.js#readme - -Copyright (c) 2018 Octokit - -The MIT License - -Copyright (c) 2018 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/request-error 2.0.5 - MIT -https://github.com/octokit/request-error.js#readme - -Copyright (c) 2019 Octokit - -The MIT License - -Copyright (c) 2019 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/rest 18.2.1 - MIT -https://github.com/octokit/rest.js#readme - -Copyright (c) 2017-2018 Octokit -Copyright (c) 2012 Cloud9 IDE, Inc. - -The MIT License - -Copyright (c) 2012 Cloud9 IDE, Inc. (Mike de Boer) -Copyright (c) 2017-2018 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/types 6.10.1 - MIT -https://github.com/octokit/types.ts#readme - - -MIT License Copyright (c) 2019 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/types 6.12.2 - MIT -https://github.com/octokit/types.ts#readme - -Copyright (c) 2019 Octokit - -MIT License Copyright (c) 2019 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/types 6.41.0 - MIT -https://github.com/octokit/types.ts#readme - -Copyright (c) 2019 Octokit - -MIT License Copyright (c) 2019 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@types/node 14.14.35 - MIT - - -Copyright (c) Microsoft Corporation. - -MIT License - -Copyright (c) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---------------------------------------------------------- - ---------------------------------------------------------- - -@types/zen-observable 0.8.2 - MIT - - -Copyright (c) Microsoft Corporation. - -MIT License - -Copyright (c) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---------------------------------------------------------- - ---------------------------------------------------------- - -@wry/context 0.4.4 - MIT -https://github.com/benjamn/wryware - -Copyright (c) 2019 Ben Newman - -MIT License - -Copyright (c) 2019 Ben Newman - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@wry/equality 0.1.11 - MIT -https://github.com/benjamn/wryware - -Copyright (c) 2019 Ben Newman - -MIT License - -Copyright (c) 2019 Ben Newman - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -apollo-boost 0.4.9 - MIT -https://github.com/apollographql/apollo-client#readme - -Copyright (c) 2018 Meteor Development Group, Inc. - -The MIT License (MIT) - -Copyright (c) 2018 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - - ---------------------------------------------------------- - ---------------------------------------------------------- - -apollo-cache 1.3.5 - MIT -https://github.com/apollographql/apollo-client#readme - -Copyright (c) 2018 Meteor Development Group, Inc. - -The MIT License (MIT) - -Copyright (c) 2018 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - - ---------------------------------------------------------- - ---------------------------------------------------------- - -apollo-cache-inmemory 1.6.6 - MIT -https://github.com/apollographql/apollo-client#readme - -Copyright (c) 2018 Meteor Development Group, Inc. - -The MIT License (MIT) - -Copyright (c) 2018 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - - ---------------------------------------------------------- - ---------------------------------------------------------- - -apollo-client 2.6.10 - MIT -https://github.com/apollographql/apollo-client#readme - -Copyright (c) 2018 Meteor Development Group, Inc. - -The MIT License (MIT) - -Copyright (c) 2018 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - - ---------------------------------------------------------- - ---------------------------------------------------------- - -apollo-link 1.2.14 - MIT -https://github.com/apollographql/apollo-link#readme - -Copyright (c) 2016 - 2017 Meteor Development Group, Inc. - -The MIT License (MIT) - -Copyright (c) 2016 - 2017 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -apollo-link-context 1.0.20 - MIT -https://github.com/apollographql/apollo-link#readme - -Copyright (c) 2016 - 2017 Meteor Development Group, Inc. - -The MIT License (MIT) - -Copyright (c) 2016 - 2017 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -apollo-link-error 1.1.13 - MIT -https://github.com/apollographql/apollo-link#readme - -Copyright (c) 2016 - 2017 Meteor Development Group, Inc. - -The MIT License (MIT) - -Copyright (c) 2016 - 2017 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -apollo-link-http 1.5.17 - MIT -https://github.com/apollographql/apollo-link#readme - -Copyright (c) 2016 - 2017 Meteor Development Group, Inc. - -The MIT License (MIT) - -Copyright (c) 2016 - 2017 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -apollo-link-http-common 0.2.16 - MIT -https://github.com/apollographql/apollo-link#readme - -Copyright (c) 2016 - 2017 Meteor Development Group, Inc. - -The MIT License (MIT) - -Copyright (c) 2016 - 2017 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -apollo-utilities 1.3.4 - MIT -https://github.com/apollographql/apollo-client#readme - -Copyright (c) 2018 Meteor Development Group, Inc. - -The MIT License (MIT) - -Copyright (c) 2018 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - - ---------------------------------------------------------- - ---------------------------------------------------------- - -axios 0.21.4 - MIT -https://axios-http.com/ - - -Copyright (c) 2014-present Matt Zabriskie - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -cross-fetch 3.1.5 - MIT -https://github.com/lquixada/cross-fetch - -Copyright (c) 2017 Leonardo Quixada -(c) Leonardo Quixada (https://twitter.com/lquixada/) -Copyright (c) 2010 Thomas Fuchs (http://script.aculo.us/thomas) - -The MIT License (MIT) - -Copyright (c) 2017 Leonardo Quixadá - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -dayjs 1.10.4 - MIT -https://day.js.org/ - -Copyright (c) 2018-present - -MIT License - -Copyright (c) 2018-present, iamkun - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -events 3.2.0 - MIT -https://github.com/Gozala/events#readme - -Copyright Joyent, Inc. and other Node contributors. - -MIT - -Copyright Joyent, Inc. and other Node contributors. - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to permit -persons to whom the Software is furnished to do so, subject to the -following conditions: - -The above copyright notice and this permission notice shall be included -in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN -NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, -DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR -OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -fast-deep-equal 3.1.3 - MIT -https://github.com/epoberezkin/fast-deep-equal#readme - -Copyright (c) 2017 Evgeny Poberezkin - -MIT License - -Copyright (c) 2017 Evgeny Poberezkin - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -fast-json-stable-stringify 2.1.0 - MIT -https://github.com/epoberezkin/fast-json-stable-stringify - -Copyright (c) 2013 James Halliday -Copyright (c) 2017 Evgeny Poberezkin - -This software is released under the MIT license: - -Copyright (c) 2017 Evgeny Poberezkin -Copyright (c) 2013 James Halliday - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -follow-redirects 1.14.8 - MIT -https://github.com/follow-redirects/follow-redirects - -Copyright 2014-present Olivier Lalonde , James Talmage , Ruben Verborgh - -Copyright 2014–present Olivier Lalonde , James Talmage , Ruben Verborgh - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -graphql-tag 2.11.0 - MIT -https://github.com/apollographql/graphql-tag#readme - - -The MIT License (MIT) - -Copyright (c) 2020 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -is-plain-object 5.0.0 - MIT -https://github.com/jonschlinkert/is-plain-object - -Copyright (c) 2014-2017, Jon Schlinkert -Copyright (c) 2019, Jon Schlinkert (https://github.com/jonschlinkert) - -The MIT License (MIT) - -Copyright (c) 2014-2017, Jon Schlinkert. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -js-tokens 4.0.0 - MIT -https://github.com/lydell/js-tokens#readme - -Copyright 2014, 2015, 2016, 2017, 2018 Simon Lydell -Copyright (c) 2014, 2015, 2016, 2017, 2018 Simon Lydell - -The MIT License (MIT) - -Copyright (c) 2014, 2015, 2016, 2017, 2018 Simon Lydell - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -loose-envify 1.4.0 - MIT -https://github.com/zertosh/loose-envify - -Copyright (c) 2015 Andres Suarez - -The MIT License (MIT) - -Copyright (c) 2015 Andres Suarez - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -marked 4.0.10 - MIT -https://marked.js.org/ - -Copyright (c) 2011-2013, Christopher Jeffrey -Copyright (c) 2011-2014, Christopher Jeffrey -Copyright (c) 2011-2018, Christopher Jeffrey. -Copyright (c) 2004, John Gruber http://daringfireball.net -Copyright (c) 2018+, MarkedJS (https://github.com/markedjs/) -Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/) - -# License information - -## Contribution License Agreement - -If you contribute code to this project, you are implicitly allowing your code -to be distributed under the MIT license. You are also implicitly verifying that -all code is your original work. `` - -## Marked - -Copyright (c) 2018+, MarkedJS (https://github.com/markedjs/) -Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - -## Markdown - -Copyright © 2004, John Gruber -http://daringfireball.net/ -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. -* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. -* Neither the name “Markdown” nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. - -This software is provided by the copyright holders and contributors “as is” and any express or implied warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose are disclaimed. In no event shall the copyright owner or contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -node-fetch 2.6.7 - MIT -https://github.com/bitinn/node-fetch - -Copyright (c) 2016 David Frank - -The MIT License (MIT) - -Copyright (c) 2016 David Frank - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - - ---------------------------------------------------------- - ---------------------------------------------------------- - -object-assign 4.1.1 - MIT -https://github.com/sindresorhus/object-assign#readme - -(c) Sindre Sorhus -(c) Sindre Sorhus (https://sindresorhus.com) -Copyright (c) Sindre Sorhus (sindresorhus.com) - -The MIT License (MIT) - -Copyright (c) Sindre Sorhus (sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -optimism 0.10.3 - MIT -https://github.com/benjamn/optimism#readme - -Copyright (c) 2016 Ben Newman - -MIT License - -Copyright (c) 2016 Ben Newman - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -prop-types 15.7.2 - MIT -https://facebook.github.io/react/ - -(c) Sindre Sorhus -Copyright (c) 2013-present, Facebook, Inc. -Copyright (c) Facebook, Inc. and its affiliates - -MIT License - -Copyright (c) 2013-present, Facebook, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -react 16.14.0 - MIT -https://reactjs.org/ - -(c) Sindre Sorhus -Copyright (c) 2013-present, Facebook, Inc. -Copyright (c) Facebook, Inc. and its affiliates. - -MIT License - -Copyright (c) Facebook, Inc. and its affiliates. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -react-dom 16.14.0 - MIT -https://reactjs.org/ - -(c) Db (c) -Copyright (c) 2013-present, Facebook, Inc. -Copyright (c) Facebook, Inc. and its affiliates - -MIT License - -Copyright (c) Facebook, Inc. and its affiliates. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -react-is 16.13.1 - MIT -https://reactjs.org/ - -Copyright (c) Facebook, Inc. and its affiliates - -MIT License - -Copyright (c) Facebook, Inc. and its affiliates. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -scheduler 0.19.1 - MIT -https://reactjs.org/ - -Copyright (c) Facebook, Inc. and its affiliates. - -MIT License - -Copyright (c) Facebook, Inc. and its affiliates. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -ssh-config 4.1.1 - MIT -https://github.com/cyjake/ssh-config#readme - -Copyright (c) 2017 Chen Yangjian - -MIT License - -Copyright (c) 2017 Chen Yangjian - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -symbol-observable 1.2.0 - MIT -https://github.com/blesh/symbol-observable#readme - -Copyright (c) Ben Lesh -Copyright (c) Sindre Sorhus (sindresorhus.com) -(c) Sindre Sorhus (https://sindresorhus.com) and Ben Lesh (https://github.com/benlesh) - -The MIT License (MIT) - -Copyright (c) Sindre Sorhus (sindresorhus.com) -Copyright (c) Ben Lesh - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -tas-client 0.1.16 - MIT - - -Copyright (c) Microsoft Corporation. - -MIT License - -Copyright (c) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---------------------------------------------------------- - ---------------------------------------------------------- - -tr46 0.0.3 - MIT -https://github.com/Sebmaster/tr46.js#readme - - -MIT License - -Copyright (c) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---------------------------------------------------------- - ---------------------------------------------------------- - -ts-invariant 0.4.4 - MIT -https://github.com/apollographql/invariant-packages - -Copyright (c) 2019 Apollo GraphQL - -MIT License - -Copyright (c) 2019 Apollo GraphQL - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -tunnel 0.0.6 - MIT -https://github.com/koichik/node-tunnel/ - -Copyright (c) 2012 Koichi Kobayashi - -The MIT License (MIT) - -Copyright (c) 2012 Koichi Kobayashi - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -url-search-params-polyfill 8.1.1 - MIT -https://github.com/jerrybendy/url-search-params-polyfill - -Copyright (c) 2016 Jerry Bendy - -MIT License - -Copyright (c) 2016 Jerry Bendy - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -uuid 8.3.2 - MIT -https://github.com/uuidjs/uuid#readme - -Copyright 2011, Sebastian Tschan https://blueimp.net -Copyright (c) 2010-2020 Robert Kieffer and other contributors -Copyright (c) Paul Johnston 1999 - 2009 Other contributors Greg Holt, Andrew Kepert, Ydnar, Lostinet - -The MIT License (MIT) - -Copyright (c) 2010-2020 Robert Kieffer and other contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -vscode-tas-client 0.1.17 - MIT - - -Copyright (c) Microsoft Corporation. - -MIT License - -Copyright (c) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---------------------------------------------------------- - ---------------------------------------------------------- - -whatwg-url 5.0.0 - MIT -https://github.com/jsdom/whatwg-url#readme - -(c) extraPathPercentEncodeSet.has -Copyright (c) 2015-2016 Sebastian Mayr - -The MIT License (MIT) - -Copyright (c) 2015–2016 Sebastian Mayr - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -zen-observable 0.8.15 - MIT -https://github.com/zenparsing/zen-observable - -Copyright (c) 2018 zenparsing Kevin - -Copyright (c) 2018 zenparsing (Kevin Smith) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -zen-observable-ts 0.8.21 - MIT -https://github.com/zenparsing/zen-observable - -Copyright (c) 2018 -Copyright (c) 2016 - 2018 Meteor Development Group, Inc. - -The MIT License (MIT) - -Copyright (c) 2018 zenparsing (Kevin Smith) -Copyright (c) 2016 - 2018 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -vsls 0.3.1291 -https://aka.ms/vsls - -Copyright (c) Microsoft Corporation. - -MICROSOFT PRE-RELEASE SOFTWARE LICENSE TERMS - -MICROSOFT VISUAL STUDIO LIVE SHARE SOFTWARE - -These license terms are an agreement between Microsoft Corporation (or based on where you live, one of its affiliates) and you. They apply to the pre-release software named above. The terms also apply to any Microsoft services or updates for the software, except to the extent those have additional terms. - -IF YOU COMPLY WITH THESE LICENSE TERMS, YOU HAVE THE RIGHTS BELOW. - -1. INSTALLATION AND USE RIGHTS. You may install and use any number of copies of the software to evaluate it as you develop and test your software applications. You may use the software only with Microsoft Visual Studio or Visual Studio Code. The software works in tandem with an associated preview release service, as described below. - -2. PRE-RELEASE SOFTWARE. The software is a pre-release version. It may not work the way a final version of the software will. Microsoft may change it for the final, commercial version. We also may not release a commercial version. Microsoft is not obligated to provide maintenance, technical support or updates to you for the software. - -3. ASSOCIATED ONLINE SERVICES. - - a. Microsoft Azure Services. Some features of the software provide access to, or rely on, Azure online services, including an associated Azure online service to the software, Visual Studio Live Share (the “corresponding service”). The use of those services (but not the software) is governed by the separate terms and privacy policies in the agreement under which you obtained the Azure services at https://go.microsoft.com/fwLink/p/?LinkID=233178 (and, with respect to the corresponding service, the additional terms below). Please read them. The services may not be available in all regions. - - b. Limited Availability. The corresponding service is currently in “Preview,” and therefore, we may change or discontinue the corresponding service at any time without notice. Any changes or updates to the corresponding service may cause the software to stop working and may result in the deletion of any data stored on the corresponding service. You may not receive notice prior to these updates. - -4. Licenses for other components. The software may include third party components with separate legal notices or governed by other agreements, as described in the ThirdPartyNotices file accompanying the software. Even if such components are governed by other agreements, the disclaimers and the limitations on and exclusions of damages below also apply. - -5. DATA. - - a. Data Collection. The software may collect information about you and your use of the software, and send that to Microsoft. Microsoft may use this information to provide services and improve our products and services. You may opt out of many of these scenarios, but not all, as described in the product documentation. In using the software, you must comply with applicable law. You can learn more about data collection and use in the help documentation and the privacy statement at http://go.microsoft.com/fwlink/?LinkId=398505. Your use of the software operates as your consent to these practices. - - b. Processing of Personal Data. To the extent Microsoft is a processor or subprocessor of personal data in connection with the software, Microsoft makes the commitments in the European Union General Data Protection Regulation Terms of the Online Services Terms to all customers effective May 25, 2018, at http://go.microsoft.com/?linkid=9840733. - -6. FEEDBACK. If you give feedback about the software to Microsoft, you give to Microsoft, without charge, the right to use, share and commercialize your feedback in any way and for any purpose. You will not give feedback that is subject to a license that requires Microsoft to license its software or documentation to third parties because we include your feedback in them. These rights survive this agreement. - -7. SCOPE OF LICENSE. The software is licensed, not sold. This agreement only gives you some rights to use the software. Microsoft reserves all other rights. Unless applicable law gives you more rights despite this limitation, you may use the software only as expressly permitted in this agreement. In doing so, you must comply with any technical limitations in the software that only allow you to use it in certain ways. For example, if Microsoft technically limits or disables extensibility for the software, you may not extend the software by, among other things, loading or injecting into the software any non-Microsoft add-ins, macros, or packages; modifying the software registry settings; or adding features or functionality equivalent to that found in other Visual Studio products. You may not: - - * work around any technical limitations in the software; - - * reverse engineer, decompile or disassemble the software, or attempt to do so, except and only to the extent required by third party licensing terms governing use of certain open source components that may be included with the software; - - * remove, minimize, block or modify any notices of Microsoft or its suppliers in the software; - - * use the software in any way that is against the law; or - - * share, publish, rent or lease the software, or provide the software as a stand-alone offering for others to use. - -8. UPDATES. The software may periodically check for updates and download and install them for you. You may obtain updates only from Microsoft or authorized sources. Microsoft may need to update your system to provide you with updates. You agree to receive these automatic updates without any additional notice. Updates may not include or support all existing software features, services, or peripheral devices. - -9. EXPORT RESTRICTIONS. You must comply with all domestic and international export laws and regulations that apply to the software, which include restrictions on destinations, end users and end use. For further information on export restrictions, visit (aka.ms/exporting). - -10. SUPPORT SERVICES. Because the software is “as is,” we may not provide support services for it. - -11. ENTIRE AGREEMENT. This agreement, and the terms for supplements, updates, Internet-based services and support services that you use, are the entire agreement for the software and support services. - -12. APPLICABLE LAW. If you acquired the software in the United States, Washington State law applies to interpretation of and claims for breach of this agreement, and the laws of the state where you live apply to all other claims. If you acquired the software in any other country, its laws apply. - -13. CONSUMER RIGHTS; REGIONAL VARIATIONS. This agreement describes certain legal rights. You may have other rights, including consumer rights, under the laws of your state or country. Separate and apart from your relationship with Microsoft, you may also have rights with respect to the party from which you acquired the software. This agreement does not change those other rights if the laws of your state or country do not permit it to do so. For example, if you acquired the software in one of the below regions, or mandatory country law applies, then the following provisions apply to you: - - a. Australia. You have statutory guarantees under the Australian Consumer Law and nothing in this agreement is intended to affect those rights. - - b. Canada. If you acquired the software in Canada, you may stop receiving updates by turning off the automatic update feature, disconnecting your device from the Internet (if and when you re-connect to the Internet, however, the software will resume checking for and installing updates), or uninstalling the software. The product documentation, if any, may also specify how to turn off updates for your specific device or software. - - c. Germany and Austria. - - (i) Warranty. The properly licensed software will perform substantially as described in any Microsoft materials that accompany the software. However, Microsoft gives no contractual guarantee in relation to the licensed software. - - (ii) Limitation of Liability. In case of intentional conduct, gross negligence, claims based on the Product Liability Act, as well as, in case of death or personal or physical injury, Microsoft is liable according to the statutory law. - - Subject to the foregoing clause (ii), Microsoft will only be liable for slight negligence if Microsoft is in breach of such material contractual obligations, the fulfillment of which facilitate the due performance of this agreement, the breach of which would endanger the purpose of this agreement and the compliance with which a party may constantly trust in (so-called "cardinal obligations"). In other cases of slight negligence, Microsoft will not be liable for slight negligence. - -14. LEGAL EFFECT. This agreement describes certain legal rights. You may have other rights under the laws of your country. You may also have rights with respect to the party from whom you acquired the software. This agreement does not change your rights under the laws of your country if the laws of your country do not permit it to do so. - -15. DISCLAIMER OF WARRANTY. THE SOFTWARE IS LICENSED “AS-IS.” YOU BEAR THE RISK OF USING IT. MICROSOFT GIVES NO EXPRESS WARRANTIES, GUARANTEES OR CONDITIONS. TO THE EXTENT PERMITTED UNDER YOUR LOCAL LAWS, MICROSOFT EXCLUDES THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. - -16. LIMITATION ON AND EXCLUSION OF DAMAGES. YOU CAN RECOVER FROM MICROSOFT AND ITS SUPPLIERS ONLY DIRECT DAMAGES UP TO U.S. $5.00. YOU CANNOT RECOVER ANY OTHER DAMAGES, INCLUDING CONSEQUENTIAL, LOST PROFITS, SPECIAL, INDIRECT OR INCIDENTAL DAMAGES. - - This limitation applies to (a) anything related to the software, services, content (including code) on third party Internet sites, or third party programs; and (b) claims for breach of contract, breach of warranty, guarantee or condition, strict liability, negligence, or other tort to the extent permitted by applicable law. - - It also applies even if Microsoft knew or should have known about the possibility of the damages. The above limitation or exclusion may not apply to you because your country may not allow the exclusion or limitation of incidental, consequential or other damages. - -Please note: As the software is distributed in Quebec, Canada, some of the clauses in this agreement are provided below in French. - -Remarque : Ce logiciel étant distribué au Québec, Canada, certaines des clauses dans ce contrat sont fournies ci-dessous en français. - -EXONÉRATION DE GARANTIE. Le logiciel visé par une licence est offert « tel quel ». Toute utilisation de ce logiciel est à votre seule risque et péril. Microsoft n’accorde aucune autre garantie expresse. Vous pouvez bénéficier de droits additionnels en vertu du droit local sur la protection des consommateurs, que ce contrat ne peut modifier. La ou elles sont permises par le droit locale, les garanties implicites de qualité marchande, d’adéquation à un usage particulier et d’absence de contrefaçon sont exclues. - -LIMITATION DES DOMMAGES-INTÉRÊTS ET EXCLUSION DE RESPONSABILITÉ POUR LES DOMMAGES. Vous pouvez obtenir de Microsoft et de ses fournisseurs une indemnisation en cas de dommages directs uniquement à hauteur de 5,00 $ US. Vous ne pouvez prétendre à aucune indemnisation pour les autres dommages, y compris les dommages spéciaux, indirects ou accessoires et pertes de bénéfices. - -Cette limitation concerne : - -* tout ce qui est relié au logiciel, aux services ou au contenu (y compris le code) figurant sur des sites Internet tiers ou dans des programmes tiers ; et - -* les réclamations au titre de violation de contrat ou de garantie, ou au titre de responsabilité stricte, de négligence ou d’une autre faute dans la limite autorisée par la loi en vigueur. - -Elle s’applique également, même si Microsoft connaissait ou devrait connaître l’éventualité d’un tel dommage. Si votre pays n’autorise pas l’exclusion ou la limitation de responsabilité pour les dommages indirects, accessoires ou de quelque nature que ce soit, il se peut que la limitation ou l’exclusion ci-dessus ne s’appliquera pas à votre égard. - -EFFET JURIDIQUE. Le présent contrat décrit certains droits juridiques. Vous pourriez avoir d’autres droits prévus par les lois de votre pays. Le présent contrat ne modifie pas les droits que vous confèrent les lois de votre pays si celles-ci ne le permettent pas. - - ---------------------------------------------------------- - diff --git a/azure-pipeline.nightly.yml b/azure-pipeline.nightly.yml index 16d97ccecd..28d5448034 100644 --- a/azure-pipeline.nightly.yml +++ b/azure-pipeline.nightly.yml @@ -3,7 +3,7 @@ trigger: none pr: none schedules: - - cron: '0 9 * * Mon-Thu' + - cron: '0 4 * * Mon-Fri' displayName: Nightly Release Schedule always: true branches: @@ -18,10 +18,21 @@ resources: ref: main endpoint: Monaco +parameters: + - name: publishExtension + displayName: 🚀 Publish Extension + type: boolean + default: false + extends: template: azure-pipelines/extension/pre-release.yml@templates parameters: + ghCreateRelease: false + l10nSourcePaths: ./src + + nodeVersion: "20.x" + buildSteps: - script: yarn install --frozen-lockfile --check-files displayName: Install dependencies @@ -30,6 +41,23 @@ extends: - script: yarn run bundle displayName: Compile + - script: > + node ./scripts/prepare-nightly-build.js + displayName: Generate package.json + + - script: | + mv ./package.json ./package.json.bak + mv ./package.insiders.json ./package.json + displayName: Override package.json + + testSteps: + - script: yarn install --frozen-lockfile --check-files + displayName: Install dependencies + retryCountOnTaskFailure: 3 + + - script: yarn run bundle + displayName: Compile + - bash: | /usr/bin/Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & echo ">>> Started xvfb" @@ -56,12 +84,10 @@ extends: # env: # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-webkit-test-results.xml - - script: > - node ./scripts/prepare-nightly-build.js - -v "$VERSION" - displayName: Generate package.json + tsa: + config: + areaPath: 'Visual Studio Code Web Extensions' + serviceTreeID: '1788a767-5861-45fb-973b-c686b67c5541' + enabled: true - - script: | - mv ./package.json ./package.json.bak - mv ./package.insiders.json ./package.json - displayName: Override package.json + publishExtension: ${{ parameters.publishExtension }} diff --git a/azure-pipeline.pr.yml b/azure-pipeline.pr.yml index 0b2c569a5f..6a4422580d 100644 --- a/azure-pipeline.pr.yml +++ b/azure-pipeline.pr.yml @@ -2,13 +2,22 @@ jobs: - job: test_suite displayName: Test suite pool: - vmImage: 'macos-12' + vmImage: 'macos-15' steps: - template: scripts/ci/common-setup.yml - script: yarn run compile displayName: Compile + - script: npm run hygiene + displayName: Run hygiene checks + + - script: npm run lint + displayName: Run lint + + - script: yarn run check:commands + displayName: Verify command registrations + - script: yarn run test displayName: Run test suite env: diff --git a/azure-pipeline.release.yml b/azure-pipeline.release.yml index 4fc017bed3..1f8bb2c0d6 100644 --- a/azure-pipeline.release.yml +++ b/azure-pipeline.release.yml @@ -4,8 +4,6 @@ trigger: branches: include: - main - tags: - include: ['*'] pr: none resources: @@ -16,10 +14,22 @@ resources: ref: main endpoint: Monaco +parameters: + - name: publishExtension + # allow-any-unicode-next-line + displayName: 🚀 Publish Extension + type: boolean + default: false + extends: template: azure-pipelines/extension/stable.yml@templates parameters: + ghCreateRelease: false + l10nSourcePaths: ./src + + nodeVersion: "20.x" + buildSteps: - script: yarn install --frozen-lockfile --check-files displayName: Install dependencies @@ -28,6 +38,14 @@ extends: - script: yarn run bundle displayName: Compile + testSteps: + - script: yarn install --frozen-lockfile --check-files + displayName: Install dependencies + retryCountOnTaskFailure: 3 + + - script: yarn run bundle + displayName: Compile + - bash: | /usr/bin/Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & echo ">>> Started xvfb" @@ -53,3 +71,11 @@ extends: # displayName: Run test suite (webkit) # env: # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-webkit-test-results.xml + + tsa: + config: + areaPath: 'Visual Studio Code Web Extensions' + serviceTreeID: '1788a767-5861-45fb-973b-c686b67c5541' + enabled: true + + publishExtension: ${{ and(parameters.publishExtension, eq(variables['Build.Repository.Uri'], 'https://github.com/microsoft/vscode-pull-request-github.git')) }} diff --git a/build/eslint-rules/index.js b/build/eslint-rules/index.js new file mode 100644 index 0000000000..85a144f2e8 --- /dev/null +++ b/build/eslint-rules/index.js @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +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-any-except-union-method-signature.js b/build/eslint-rules/no-any-except-union-method-signature.js new file mode 100644 index 0000000000..8edf8e338f --- /dev/null +++ b/build/eslint-rules/no-any-except-union-method-signature.js @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: 'Disallow the use of any except in union types within method signatures', + category: 'Best Practices', + recommended: true, + }, + fixable: null, + schema: [], + messages: { + unexpectedAny: 'Unexpected any. Use a more specific type instead.', + }, + }, + + create(context) { + return { + // Target the 'any' type annotation + TSAnyKeyword(node) { + // Get the parent nodes to determine context + const parent = node.parent; + + if (parent) { + // Check if this type is part of a method signature + let currentNode = parent; + let isMethodSignature = false; + + while (currentNode) { + // Check if we're in a method signature or function type + if ( + currentNode.type === 'TSMethodSignature' || + currentNode.type === 'TSFunctionType' || + currentNode.type === 'FunctionDeclaration' || + currentNode.type === 'FunctionExpression' || + currentNode.type === 'ArrowFunctionExpression' || + currentNode.type === 'MethodDefinition' + ) { + isMethodSignature = true; + break; + } + + currentNode = currentNode.parent; + } + + // If it's part of a method signature, it's allowed + if (isMethodSignature) { + return; + } + } + + // Report any other use of 'any' + context.report({ + node, + messageId: 'unexpectedAny', + }); + } + }; + } +}; 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/no-pr-in-user-strings.js b/build/eslint-rules/no-pr-in-user-strings.js new file mode 100644 index 0000000000..83665ad6fd --- /dev/null +++ b/build/eslint-rules/no-pr-in-user-strings.js @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +/** + * ESLint rule to detect the string "PR" in user-facing strings and suggest using "pull request" instead. + * This rule checks: + * - String literals passed to vscode.l10n.t() calls + * - String literals passed to l10n.t() calls + */ + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Detect "PR" in user-facing strings and suggest using "pull request" instead', + category: 'Best Practices', + recommended: true, + }, + schema: [], + messages: { + noPrInUserString: 'Use "pull request" instead of "PR" in user-facing strings. Found: {{foundText}}', + }, + }, + + create(context) { + /** + * Check if a string contains "PR" as a standalone word + */ + function containsPR(str) { + // Use word boundary regex to match "PR" as a standalone word + const prRegex = /\bPR\b/; + return prRegex.test(str); + } + + /** + * Check if a node is a call to vscode.l10n.t or l10n.t + */ + function isL10nTCall(node) { + if (node.type !== 'CallExpression') { + return false; + } + + const callee = node.callee; + + // Handle l10n.t() calls + if (callee.type === 'MemberExpression' && + callee.property && + callee.property.name === 't') { + + // Check for vscode.l10n.t + if (callee.object.type === 'MemberExpression' && + callee.object.object && + callee.object.object.name === 'vscode' && + callee.object.property && + callee.object.property.name === 'l10n') { + return true; + } + + // Check for l10n.t + if (callee.object.type === 'Identifier' && + callee.object.name === 'l10n') { + return true; + } + } + + return false; + } + + return { + // Check CallExpression nodes for l10n.t calls + CallExpression(node) { + if (isL10nTCall(node)) { + // Check the first argument (string literal) + if (node.arguments && node.arguments.length > 0) { + const firstArg = node.arguments[0]; + if (firstArg.type === 'Literal' && typeof firstArg.value === 'string') { + if (containsPR(firstArg.value)) { + context.report({ + node: firstArg, + messageId: 'noPrInUserString', + data: { + foundText: firstArg.value + } + }); + } + } + } + } + } + }; + } +}; \ 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 new file mode 100644 index 0000000000..8f1b18084e --- /dev/null +++ b/build/eslint-rules/public-methods-well-defined-types.js @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +/** + * ESLint rule to enforce that public methods in exported classes return well-defined types. + * This rule ensures that no inline type (object literal, anonymous type, etc.) is returned + * from any public method. + */ + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Enforce that public methods return well-defined types (no inline types)', + category: 'TypeScript', + recommended: false, + }, + schema: [], + messages: { + inlineReturnType: 'Public method "{{methodName}}" should return a well-defined type, not an inline type. Consider defining an interface or type alias.', + }, + }, + + create(context) { + /** + * Check if a node represents an inline type that should be flagged + */ + function isInlineType(typeNode) { + if (!typeNode) return false; + + switch (typeNode.type) { + // Object type literals: { foo: string, bar: number } + case 'TSTypeLiteral': + return true; + + // Union types with inline object types: string | { foo: bar } + case 'TSUnionType': + return typeNode.types.some(isInlineType); + + // Intersection types with inline object types: Base & { foo: bar } + case 'TSIntersectionType': + return typeNode.types.some(isInlineType); + + // Tuple types: [string, number] + case 'TSTupleType': + return true; + + // Mapped types: { [K in keyof T]: U } + case 'TSMappedType': + return true; + + // Conditional types: T extends U ? X : Y (inline) + case 'TSConditionalType': + return true; + + // Type references with inline type arguments: Promise<{x: string}>, Array<{y: number}> + case 'TSTypeReference': + // 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; + + default: + return false; + } + } + + /** + * Check if a method is public (not private or protected) + */ + function isPublicMethod(node) { + // If no accessibility modifier is specified, it's public by default + if (!node.accessibility) return true; + return node.accessibility === 'public'; + } + + /** + * Check if a class is exported + */ + function isExportedClass(node) { + // Check if the class declaration itself is exported + if (node.parent && node.parent.type === 'ExportNamedDeclaration') { + return true; + } + // Check if it's a default export + if (node.parent && node.parent.type === 'ExportDefaultDeclaration') { + return true; + } + return false; + } + + return { + MethodDefinition(node) { + // Only check methods in exported classes + const classNode = node.parent.parent; // MethodDefinition -> ClassBody -> ClassDeclaration + if (!classNode || classNode.type !== 'ClassDeclaration' || !isExportedClass(classNode)) { + return; + } + + // Only check public methods + if (!isPublicMethod(node)) { + return; + } + + // Check if the method has a return type annotation + const functionNode = node.value; + if (!functionNode.returnType) { + return; // No explicit return type, skip + } + + const returnTypeNode = functionNode.returnType.typeAnnotation; + + // Check if the return type is an inline type + if (isInlineType(returnTypeNode)) { + const methodName = node.key.type === 'Identifier' ? node.key.name : ''; + context.report({ + node: functionNode.returnType, + messageId: 'inlineReturnType', + data: { + methodName: methodName, + }, + }); + } + }, + + // Also check arrow function properties that are public methods + PropertyDefinition(node) { + // Only check properties in exported classes + const classNode = node.parent.parent; // PropertyDefinition -> ClassBody -> ClassDeclaration + if (!classNode || classNode.type !== 'ClassDeclaration' || !isExportedClass(classNode)) { + return; + } + + // Only check public methods + if (!isPublicMethod(node)) { + return; + } + + // Check if the property is an arrow function + if (node.value && node.value.type === 'ArrowFunctionExpression') { + const arrowFunction = node.value; + + // Check if the arrow function has a return type annotation + if (!arrowFunction.returnType) { + return; // No explicit return type, skip + } + + const returnTypeNode = arrowFunction.returnType.typeAnnotation; + + // Check if the return type is an inline type + if (isInlineType(returnTypeNode)) { + const methodName = node.key.type === 'Identifier' ? node.key.name : ''; + context.report({ + node: arrowFunction.returnType, + messageId: 'inlineReturnType', + data: { + methodName: methodName, + }, + }); + } + } + } + }; + }, +}; \ No newline at end of file diff --git a/build/filters.js b/build/filters.js index 358e6ece6c..1425e36c2f 100644 --- a/build/filters.js +++ b/build/filters.js @@ -28,6 +28,9 @@ module.exports.unicodeFilter = [ '!**/ThirdPartyNotices.txt', '!**/LICENSE.{txt,rtf}', '!**/LICENSE', + '!**/CHANGELOG.md', + '!*.yml', + '!resources/emojis.json' ]; module.exports.indentationFilter = [ @@ -40,11 +43,13 @@ module.exports.indentationFilter = [ '!**/LICENSE.{txt,rtf}', '!**/LICENSE', '!**/*.yml', + '!resources/emojis.json', // except multiple specific files '!**/package.json', '!**/yarn.lock', - '!**/yarn-error.log' + '!**/yarn-error.log', + '!**/fixtures/**/*' ]; module.exports.copyrightFilter = [ @@ -54,17 +59,26 @@ module.exports.copyrightFilter = [ '!.vscode/**/*', '!.github/**/*', '!.husky/**/*', + '!tsconfig.base.json', + '!tsconfig.browser.json', + '!tsconfig.json', + '!tsconfig.test.json', + '!tsconfig.webviews.json', + '!tsconfig.scripts.json', '!tsfmt.json', - '!**/queries.gql', + '!**/queries*.gql', '!**/*.yml', '!**/*.md', '!package.nls.json', - '!**/*.svg' + '!**/*.svg', + '!src/integrations/gitlens/gitlens.d.ts', + '!**/fixtures/**' ]; module.exports.tsFormattingFilter = [ 'src/**/*.ts', 'common/**/*.ts', 'webviews/**/*.ts', + '**/fixtures/**/*' ]; diff --git a/build/hygiene.js b/build/hygiene.js index cad1d47754..76df48120e 100644 --- a/build/hygiene.js +++ b/build/hygiene.js @@ -56,13 +56,15 @@ function hygiene(some) { const indentation = es.through(function (file) { const lines = file.__lines; - lines.forEach((line, i) => { + lines?.forEach((line, i) => { if (/^\s*$/.test(line)) { // empty or whitespace lines are OK } else if (/^[\t]*[^\s]/.test(line)) { // good indent } else if (/^[\t]* \*/.test(line)) { // block comment using an extra space + } else if (/^[\s]*- /.test(line)) { + // multiline string using extra space } else { console.error( file.relative + '(' + (i + 1) + ',1): Bad whitespace indentation' @@ -265,7 +267,9 @@ if (require.main === module) { .catch((err) => { console.error(); console.error(err); - process.exit(1); + if (err.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') { + process.exit(1); + } }); } } diff --git a/build/update-codicons.ts b/build/update-codicons.ts new file mode 100644 index 0000000000..9e504fb6fa --- /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/main/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 new file mode 100644 index 0000000000..0ba92071da --- /dev/null +++ b/common/sessionParsing.ts @@ -0,0 +1,329 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface SessionResponseLogChunk { + choices: Array<{ + finish_reason?: 'tool_calls' | 'null' | (string & {}); + delta: { + content?: string; + role: 'assistant' | (string & {}); + tool_calls?: Array<{ + function: { + arguments: string; + name: string; + }; + id: string; + type: string; + index: number; + }>; + }; + }>; + created: number; + id: string; + usage: { + completion_tokens: number; + prompt_tokens: number; + prompt_tokens_details: { + cached_tokens: number; + }; + total_tokens: number; + }; + model: string; + object: string; +} + +export interface ParsedToolCall { + type: 'str_replace_editor' | 'think' | 'bash' | 'report_progress' | 'unknown'; + name: string; + // args: any; + content: string; + command?: string; // For str_replace_editor +} + +export interface ParsedChoice { + type: 'assistant_content' | 'tool_call' | 'pr_title'; + content?: string; + toolCall?: ParsedToolCall; + finishReason?: string; +} + +export interface ParsedToolCallDetails { + toolName: string; + invocationMessage: string; + pastTenseMessage?: string; + originMessage?: string; + toolSpecificData?: StrReplaceEditorToolData | BashToolData; +} + +export interface StrReplaceEditorToolData { + command: 'view' | 'edit' | string; + filePath?: string; + fileLabel?: string; + parsedContent?: { content: string; fileA: string | undefined; fileB: string | undefined; }; + viewRange?: { start: number, end: number } +} + +export namespace StrReplaceEditorToolData { + export function is(value: any): value is StrReplaceEditorToolData { + return value && (typeof value.command === 'string'); + } +} + +export interface BashToolData { + commandLine: { + original: string; + }; + language: 'bash'; +} + +/** + * Parse diff content and extract file information + */ +export function parseDiff(content: string): { content: string; fileA: string | undefined; fileB: string | undefined; } | undefined { + const lines = content.split(/\r?\n/g); + let fileA: string | undefined; + let fileB: string | undefined; + + let startDiffLineIndex = -1; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.startsWith('diff --git')) { + const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/); + if (match) { + fileA = match[1]; + fileB = match[2]; + } + } else if (line.startsWith('@@ ')) { + startDiffLineIndex = i + 1; + break; + } + } + if (startDiffLineIndex < 0) { + return undefined; + } + + return { + content: lines.slice(startDiffLineIndex).join('\n'), + fileA: typeof fileA === 'string' ? '/' + fileA : undefined, + fileB: typeof fileB === 'string' ? '/' + fileB : undefined + }; +} + +export function parseRange(view_range: unknown): { start: number, end: number } | undefined { + if (!view_range) { + return undefined; + } + + if (!Array.isArray(view_range)) { + return undefined; + } + + if (view_range.length !== 2) { + return undefined; + } + + const start = view_range[0]; + const end = view_range[1]; + + if (typeof start !== 'number' || typeof end !== 'number') { + return undefined; + } + + return { + start, + end + }; +} + + + +/** + * Convert absolute file path to relative file label + * File paths are absolute and look like: `/home/runner/work/repo/repo/` + */ +export function toFileLabel(file: string): string { + const parts = file.split('/'); + return parts.slice(6).join('/'); +} + +/** + * Parse tool call arguments and return normalized tool details + */ +export function parseToolCallDetails( + toolCall: { + function: { name: string; arguments: string }; + id: string; + type: string; + index: number; + }, + content: string +): ParsedToolCallDetails { + // Parse arguments once with graceful fallback + let args: { command?: string, path?: string, prDescription?: string, commitMessage?: string, view_range?: unknown } = {}; + try { args = toolCall.function.arguments ? JSON.parse(toolCall.function.arguments) : {}; } catch { /* ignore */ } + + const name = toolCall.function.name; + + // Small focused helpers to remove duplication while preserving behavior + const buildReadDetails = (filePath: string | undefined, parsedRange: { start: number, end: number } | undefined, opts?: { parsedContent?: { content: string; fileA: string | undefined; fileB: string | undefined; } }): ParsedToolCallDetails => { + const fileLabel = filePath && toFileLabel(filePath); + if (fileLabel === undefined || fileLabel === '') { + return { toolName: 'Read repository', invocationMessage: 'Read repository', pastTenseMessage: 'Read repository' }; + } + const rangeSuffix = parsedRange ? `, lines ${parsedRange.start} to ${parsedRange.end}` : ''; + // Default helper returns bracket variant (used for generic view). Plain variant handled separately for str_replace_editor non-diff. + return { + toolName: 'Read', + invocationMessage: `Read [](${fileLabel})${rangeSuffix}`, + pastTenseMessage: `Read [](${fileLabel})${rangeSuffix}`, + toolSpecificData: { + command: 'view', + filePath: filePath, + fileLabel: fileLabel, + parsedContent: opts?.parsedContent, + viewRange: parsedRange + } + }; + }; + + const buildEditDetails = (filePath: string | undefined, command: string, parsedRange: { start: number, end: number } | undefined, opts?: { defaultName?: string }): ParsedToolCallDetails => { + const fileLabel = filePath && toFileLabel(filePath); + const rangeSuffix = parsedRange ? `, lines ${parsedRange.start} to ${parsedRange.end}` : ''; + let invocationMessage: string; + let pastTenseMessage: string; + if (fileLabel) { + invocationMessage = `Edit [](${fileLabel})${rangeSuffix}`; + pastTenseMessage = `Edit [](${fileLabel})${rangeSuffix}`; + } else { + if (opts?.defaultName === 'Create') { + invocationMessage = pastTenseMessage = `Create File ${filePath}`; + } else { + invocationMessage = pastTenseMessage = (opts?.defaultName || 'Edit'); + } + invocationMessage += rangeSuffix; + pastTenseMessage += rangeSuffix; + } + + return { + toolName: opts?.defaultName || 'Edit', + invocationMessage, + pastTenseMessage, + toolSpecificData: fileLabel ? { + command: command || (opts?.defaultName === 'Create' ? 'create' : (command || 'edit')), + filePath: filePath, + fileLabel: fileLabel, + viewRange: parsedRange + } : undefined + }; + }; + + const buildStrReplaceDetails = (filePath: string | undefined): ParsedToolCallDetails => { + const fileLabel = filePath && toFileLabel(filePath); + const message = fileLabel ? `Edit [](${fileLabel})` : `Edit ${filePath}`; + return { + toolName: 'Edit', + invocationMessage: message, + pastTenseMessage: message, + toolSpecificData: fileLabel ? { command: 'str_replace', filePath, fileLabel } : undefined + }; + }; + + const buildCreateDetails = (filePath: string | undefined): ParsedToolCallDetails => { + const fileLabel = filePath && toFileLabel(filePath); + const message = fileLabel ? `Create [](${fileLabel})` : `Create File ${filePath}`; + return { + toolName: 'Create', + invocationMessage: message, + pastTenseMessage: message, + toolSpecificData: fileLabel ? { command: 'create', filePath, fileLabel } : undefined + }; + }; + + const buildBashDetails = (bashArgs: typeof args, contentStr: string): ParsedToolCallDetails => { + const command = bashArgs.command ? `$ ${bashArgs.command}` : undefined; + const bashContent = [command, contentStr].filter(Boolean).join('\n'); + const details: ParsedToolCallDetails = { toolName: 'Run Bash command', invocationMessage: bashContent || 'Run Bash command' }; + if (bashArgs.command) { details.toolSpecificData = { commandLine: { original: bashArgs.command }, language: 'bash' }; } + return details; + }; + + switch (name) { + case 'str_replace_editor': { + if (args.command === 'view') { + const parsedContent = parseDiff(content); + const parsedRange = parseRange(args.view_range); + if (parsedContent) { + const file = parsedContent.fileA ?? parsedContent.fileB; + const fileLabel = file && toFileLabel(file); + if (fileLabel === '') { + return { toolName: 'Read repository', invocationMessage: 'Read repository', pastTenseMessage: 'Read repository' }; + } else if (fileLabel === undefined) { + return { toolName: 'Read', invocationMessage: 'Read repository', pastTenseMessage: 'Read repository' }; + } else { + const rangeSuffix = parsedRange ? `, lines ${parsedRange.start} to ${parsedRange.end}` : ''; + return { + toolName: 'Read', + invocationMessage: `Read [](${fileLabel})${rangeSuffix}`, + pastTenseMessage: `Read [](${fileLabel})${rangeSuffix}`, + toolSpecificData: { command: 'view', filePath: file, fileLabel, parsedContent, viewRange: parsedRange } + }; + } + } + // No diff parsed: use PLAIN (non-bracket) variant for str_replace_editor views + const plainRange = parseRange(args.view_range); + const fp = args.path; const fl = fp && toFileLabel(fp); + if (fl === undefined || fl === '') { + return { toolName: 'Read repository', invocationMessage: 'Read repository', pastTenseMessage: 'Read repository' }; + } + const suffix = plainRange ? `, lines ${plainRange.start} to ${plainRange.end}` : ''; + return { + toolName: 'Read', + invocationMessage: `Read ${fl}${suffix}`, + pastTenseMessage: `Read ${fl}${suffix}`, + toolSpecificData: { command: 'view', filePath: fp, fileLabel: fl, viewRange: plainRange } + }; + } + return buildEditDetails(args.path, args.command || 'edit', parseRange(args.view_range)); + } + case 'str_replace': + return buildStrReplaceDetails(args.path); + case 'create': + return buildCreateDetails(args.path); + case 'view': + return buildReadDetails(args.path, parseRange(args.view_range)); // generic view always bracket variant + case 'think': { + const thought = (args as unknown as { thought?: string }).thought || content || 'Thought'; + return { toolName: 'think', invocationMessage: thought }; + } + case 'report_progress': { + const details: ParsedToolCallDetails = { toolName: 'Progress Update', invocationMessage: `${args.prDescription}` || content || 'Progress Update' }; + if (args.commitMessage) { details.originMessage = `Commit: ${args.commitMessage}`; } + return details; + } + case 'bash': + return buildBashDetails(args, content); + case 'read_bash': + return { toolName: 'read_bash', invocationMessage: 'Read logs from Bash session' }; + case 'stop_bash': + return { toolName: 'stop_bash', invocationMessage: 'Stop Bash session' }; + default: + return { toolName: name || 'unknown', invocationMessage: content || name || 'unknown' }; + } +} + +/** + * Parse raw session logs text into structured log chunks + */ +export function parseSessionLogs(rawText: string): SessionResponseLogChunk[] { + const parts = rawText + .split(/\r?\n/) + .filter(part => part.startsWith('data: ')) + .map(part => { + const trimmed = part.slice('data: '.length).trim(); + return JSON.parse(trimmed); + }); + + return parts as SessionResponseLogChunk[]; +} diff --git a/src/github/milestoneModel.ts b/common/types.ts similarity index 68% rename from src/github/milestoneModel.ts rename to common/types.ts index c0884a1b65..094d680957 100644 --- a/src/github/milestoneModel.ts +++ b/common/types.ts @@ -3,10 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IMilestone } from './interface'; -import { IssueModel } from './issueModel'; - -export interface MilestoneModel { - milestone: IMilestone; - issues: IssueModel[]; +export interface RemoteInfo { + owner: string; + repositoryName: string; } diff --git a/common/views.ts b/common/views.ts index 29161995b7..5682e9b011 100644 --- a/common/views.ts +++ b/common/views.ts @@ -3,12 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ILabel, MergeMethod, MergeMethodsAvailability } from '../src/github/interface'; - -export interface RemoteInfo { - owner: string; - repositoryName: string; -} +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'; export interface CreateParams { availableBaseRemotes: RemoteInfo[]; @@ -29,6 +27,7 @@ export interface CreateParams { baseBranch?: string; compareRemote?: RemoteInfo; compareBranch?: string; + isDraftDefault: boolean; isDraft?: boolean; labels?: ILabel[]; isDarkTheme?: boolean; @@ -37,6 +36,7 @@ export interface CreateParams { showTitleValidationError?: boolean; createError?: string; + autoMergeDefault: boolean; autoMerge?: boolean; autoMergeMethod?: MergeMethod; allowAutoMerge?: boolean; @@ -62,4 +62,122 @@ export interface CreatePullRequest { autoMerge: boolean; autoMergeMethod?: MergeMethod; labels: ILabel[]; -} \ No newline at end of file +} + +export interface CreatePullRequestNew { + title: string; + body: string; + owner: string; + repo: string; + base: string + compareBranch: string; + compareOwner: string; + compareRepo: string; + draft: boolean; + autoMerge: boolean; + autoMergeMethod?: MergeMethod; + labels: ILabel[]; + projects: IProject[]; + assignees: IAccount[]; + reviewers: (IAccount | ITeam)[]; + milestone?: IMilestone; +} + +// #region new create view + +export interface CreateParamsNew { + canModifyBranches: boolean; + actionDetail?: string; + associatedExistingPullRequest?: number; + defaultBaseRemote?: RemoteInfo; + defaultBaseBranch?: string; + defaultCompareRemote?: RemoteInfo; + defaultCompareBranch?: string; + defaultTitle?: string; + defaultDescription?: string; + pendingTitle?: string; + pendingDescription?: string; + baseRemote?: RemoteInfo; + baseBranch?: string; + remoteCount?: number; + compareRemote?: RemoteInfo; + compareBranch?: string; + isDraftDefault: boolean; + isDraft?: boolean; + labels?: DisplayLabel[]; + projects?: IProject[]; + assignees?: IAccount[]; + reviewers?: (IAccount | ITeam)[]; + milestone?: IMilestone; + isDarkTheme?: boolean; + generateTitleAndDescriptionTitle: string | undefined; + initializeWithGeneratedTitleAndDescription: boolean; + preReviewState: PreReviewState; + preReviewer: string | undefined; + + validate?: boolean; + showTitleValidationError?: boolean; + createError?: string; + warning?: string; + + autoMergeDefault: boolean; + autoMerge?: boolean; + autoMergeMethod?: MergeMethod; + allowAutoMerge?: boolean; + defaultMergeMethod?: MergeMethod; + mergeMethodsAvailability?: MergeMethodsAvailability; + baseHasMergeQueue: boolean; + + creating: boolean; + reviewing: boolean; + usingTemplate: boolean; +} + +export interface ChooseRemoteAndBranchArgs { + currentRemote: RemoteInfo | undefined; + currentBranch: string | undefined; +} + +export interface ChooseBaseRemoteAndBranchResult { + baseRemote: RemoteInfo; + baseBranch: string; + defaultBaseBranch: string; + defaultMergeMethod: MergeMethod; + allowAutoMerge: boolean; + mergeMethodsAvailability: MergeMethodsAvailability; + autoMergeDefault: boolean; + baseHasMergeQueue: boolean; + defaultTitle: string; + defaultDescription: string; +} + +export interface ChooseCompareRemoteAndBranchResult { + compareRemote: RemoteInfo; + compareBranch: string; + defaultCompareBranch: string; +} + +export interface TitleAndDescriptionArgs { + useCopilot: boolean; +} + +export interface TitleAndDescriptionResult { + title: string | undefined; + description: string | undefined; +} + +export interface DescriptionResult { + description: string | undefined; +} + +export interface CloseResult { + state: GithubItemStateEnum; + commentEvent?: CommentEvent; + closeEvent: ClosedEvent; +} + +export interface OpenCommitChangesArgs { + commitSha: string; +} + +// #endregion \ No newline at end of file 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.100.0/copilot-issue-search-most-bugs.png b/documentation/changelog/0.100.0/copilot-issue-search-most-bugs.png new file mode 100644 index 0000000000..ca67c41804 Binary files /dev/null and b/documentation/changelog/0.100.0/copilot-issue-search-most-bugs.png differ diff --git a/documentation/changelog/0.100.0/copilot-issue-search.png b/documentation/changelog/0.100.0/copilot-issue-search.png new file mode 100644 index 0000000000..bf6b6a387b Binary files /dev/null and b/documentation/changelog/0.100.0/copilot-issue-search.png differ diff --git a/documentation/changelog/0.102.0/issue-link-in-summary.png b/documentation/changelog/0.102.0/issue-link-in-summary.png new file mode 100644 index 0000000000..61eaef6639 Binary files /dev/null and b/documentation/changelog/0.102.0/issue-link-in-summary.png differ diff --git a/documentation/changelog/0.104.0/pr-icon-tab.png b/documentation/changelog/0.104.0/pr-icon-tab.png new file mode 100644 index 0000000000..1159880498 Binary files /dev/null and b/documentation/changelog/0.104.0/pr-icon-tab.png differ diff --git a/documentation/changelog/0.104.0/toggle-editor-commenting.png b/documentation/changelog/0.104.0/toggle-editor-commenting.png new file mode 100644 index 0000000000..0e5b0ec2c2 Binary files /dev/null and b/documentation/changelog/0.104.0/toggle-editor-commenting.png differ diff --git a/documentation/changelog/0.110.0/already-pr-branch.png b/documentation/changelog/0.110.0/already-pr-branch.png new file mode 100644 index 0000000000..ad27d0d749 Binary files /dev/null and b/documentation/changelog/0.110.0/already-pr-branch.png differ diff --git a/documentation/changelog/0.110.0/copilot-address-comments.png b/documentation/changelog/0.110.0/copilot-address-comments.png new file mode 100644 index 0000000000..a4b30e1ca0 Binary files /dev/null and b/documentation/changelog/0.110.0/copilot-address-comments.png differ diff --git a/documentation/changelog/0.110.0/issue-webview.png b/documentation/changelog/0.110.0/issue-webview.png new file mode 100644 index 0000000000..f561e7f153 Binary files /dev/null and b/documentation/changelog/0.110.0/issue-webview.png differ diff --git a/documentation/changelog/0.112.0/copilot-start-stop.png b/documentation/changelog/0.112.0/copilot-start-stop.png new file mode 100644 index 0000000000..82c71c2a3f Binary files /dev/null and b/documentation/changelog/0.112.0/copilot-start-stop.png differ diff --git a/documentation/changelog/0.114.0/coding-agent-start.png b/documentation/changelog/0.114.0/coding-agent-start.png new file mode 100644 index 0000000000..7f5f188f17 Binary files /dev/null and b/documentation/changelog/0.114.0/coding-agent-start.png differ diff --git a/documentation/changelog/0.114.0/copilot-pr-status.png b/documentation/changelog/0.114.0/copilot-pr-status.png new file mode 100644 index 0000000000..9e82567ab1 Binary files /dev/null and b/documentation/changelog/0.114.0/copilot-pr-status.png differ diff --git a/documentation/changelog/0.114.0/session-log.png b/documentation/changelog/0.114.0/session-log.png new file mode 100644 index 0000000000..17dc724b4f Binary files /dev/null and b/documentation/changelog/0.114.0/session-log.png differ diff --git a/documentation/changelog/0.116.0/coding-agent-status.png b/documentation/changelog/0.116.0/coding-agent-status.png new file mode 100644 index 0000000000..0552153d58 Binary files /dev/null and b/documentation/changelog/0.116.0/coding-agent-status.png differ diff --git a/documentation/changelog/0.116.0/pr-card-in-chat.png b/documentation/changelog/0.116.0/pr-card-in-chat.png new file mode 100644 index 0000000000..7c94a51305 Binary files /dev/null and b/documentation/changelog/0.116.0/pr-card-in-chat.png differ diff --git a/documentation/changelog/0.116.0/pr-header-copy-actions.png b/documentation/changelog/0.116.0/pr-header-copy-actions.png new file mode 100644 index 0000000000..52eb2fa9c4 Binary files /dev/null and b/documentation/changelog/0.116.0/pr-header-copy-actions.png differ diff --git a/documentation/changelog/0.116.0/simplified-pr-header-buttons.png b/documentation/changelog/0.116.0/simplified-pr-header-buttons.png new file mode 100644 index 0000000000..ad3d578f27 Binary files /dev/null and b/documentation/changelog/0.116.0/simplified-pr-header-buttons.png differ diff --git a/documentation/changelog/0.116.0/suggest-configure-queries.png b/documentation/changelog/0.116.0/suggest-configure-queries.png new file mode 100644 index 0000000000..8a4a42ce06 Binary files /dev/null and b/documentation/changelog/0.116.0/suggest-configure-queries.png differ diff --git a/documentation/changelog/0.118.0/collapsed-sidbar-content.png b/documentation/changelog/0.118.0/collapsed-sidbar-content.png new file mode 100644 index 0000000000..dae1775604 Binary files /dev/null and b/documentation/changelog/0.118.0/collapsed-sidbar-content.png differ diff --git a/documentation/changelog/0.118.0/delegate-to-coding-agent-action.png b/documentation/changelog/0.118.0/delegate-to-coding-agent-action.png new file mode 100644 index 0000000000..67a3cd6c04 Binary files /dev/null and b/documentation/changelog/0.118.0/delegate-to-coding-agent-action.png differ 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/documentation/changelog/0.124.0/explicit-chat-context.png b/documentation/changelog/0.124.0/explicit-chat-context.png new file mode 100644 index 0000000000..5a7c4da299 Binary files /dev/null and b/documentation/changelog/0.124.0/explicit-chat-context.png differ diff --git a/documentation/changelog/0.124.0/pull-request-implicit-context.png b/documentation/changelog/0.124.0/pull-request-implicit-context.png new file mode 100644 index 0000000000..476d554ae7 Binary files /dev/null and b/documentation/changelog/0.124.0/pull-request-implicit-context.png differ diff --git a/documentation/changelog/0.124.0/single-button-copilot-pr.png b/documentation/changelog/0.124.0/single-button-copilot-pr.png new file mode 100644 index 0000000000..ee0cd617e1 Binary files /dev/null and b/documentation/changelog/0.124.0/single-button-copilot-pr.png differ diff --git a/documentation/changelog/0.126.0/change-base-branch.png b/documentation/changelog/0.126.0/change-base-branch.png new file mode 100644 index 0000000000..ee2256b4a9 Binary files /dev/null and b/documentation/changelog/0.126.0/change-base-branch.png differ diff --git a/documentation/changelog/0.126.0/change-pr-template.png b/documentation/changelog/0.126.0/change-pr-template.png new file mode 100644 index 0000000000..07564d71da Binary files /dev/null and b/documentation/changelog/0.126.0/change-pr-template.png differ diff --git a/documentation/changelog/0.126.0/convert-to-draft.png b/documentation/changelog/0.126.0/convert-to-draft.png new file mode 100644 index 0000000000..2f2b5ca92b Binary files /dev/null and b/documentation/changelog/0.126.0/convert-to-draft.png differ diff --git a/documentation/changelog/0.126.0/generate-existing-description.png b/documentation/changelog/0.126.0/generate-existing-description.png new file mode 100644 index 0000000000..b948b5e6c7 Binary files /dev/null and b/documentation/changelog/0.126.0/generate-existing-description.png differ diff --git a/documentation/changelog/0.60.0/permalink-comment-widget.png b/documentation/changelog/0.60.0/permalink-comment-widget.png new file mode 100644 index 0000000000..c0c43a4138 Binary files /dev/null and b/documentation/changelog/0.60.0/permalink-comment-widget.png differ diff --git a/documentation/changelog/0.60.0/permalink-description.png b/documentation/changelog/0.60.0/permalink-description.png new file mode 100644 index 0000000000..7e711527ea Binary files /dev/null and b/documentation/changelog/0.60.0/permalink-description.png differ diff --git a/documentation/changelog/0.60.0/quick-diff.png b/documentation/changelog/0.60.0/quick-diff.png new file mode 100644 index 0000000000..7accc8114b Binary files /dev/null and b/documentation/changelog/0.60.0/quick-diff.png differ diff --git a/documentation/changelog/0.60.0/re-request-review.png b/documentation/changelog/0.60.0/re-request-review.png new file mode 100644 index 0000000000..cd7450484a Binary files /dev/null and b/documentation/changelog/0.60.0/re-request-review.png differ diff --git a/documentation/changelog/0.64.0/file-level-comments.gif b/documentation/changelog/0.64.0/file-level-comments.gif new file mode 100644 index 0000000000..6a81889d38 Binary files /dev/null and b/documentation/changelog/0.64.0/file-level-comments.gif differ diff --git a/documentation/changelog/0.64.0/get-team-reviewers.png b/documentation/changelog/0.64.0/get-team-reviewers.png new file mode 100644 index 0000000000..50327c51ec Binary files /dev/null and b/documentation/changelog/0.64.0/get-team-reviewers.png differ diff --git a/documentation/changelog/0.66.0/compare-changes-with-commands.png b/documentation/changelog/0.66.0/compare-changes-with-commands.png new file mode 100644 index 0000000000..d10c126e57 Binary files /dev/null and b/documentation/changelog/0.66.0/compare-changes-with-commands.png differ diff --git a/documentation/changelog/0.66.0/git-subfolder-welcome.png b/documentation/changelog/0.66.0/git-subfolder-welcome.png new file mode 100644 index 0000000000..1cae98e02d Binary files /dev/null and b/documentation/changelog/0.66.0/git-subfolder-welcome.png differ diff --git a/documentation/changelog/0.68.0/circle-avatar.png b/documentation/changelog/0.68.0/circle-avatar.png new file mode 100644 index 0000000000..c21f6be234 Binary files /dev/null and b/documentation/changelog/0.68.0/circle-avatar.png differ diff --git a/documentation/changelog/0.68.0/read-only-file-message.png b/documentation/changelog/0.68.0/read-only-file-message.png new file mode 100644 index 0000000000..cf00ad94fc Binary files /dev/null and b/documentation/changelog/0.68.0/read-only-file-message.png differ diff --git a/documentation/changelog/0.70.0/new-create-view.png b/documentation/changelog/0.70.0/new-create-view.png new file mode 100644 index 0000000000..2ec4a7cb49 Binary files /dev/null and b/documentation/changelog/0.70.0/new-create-view.png differ diff --git a/documentation/changelog/0.76.0/github-copilot-title-description.gif b/documentation/changelog/0.76.0/github-copilot-title-description.gif new file mode 100644 index 0000000000..b1bb41d770 Binary files /dev/null and b/documentation/changelog/0.76.0/github-copilot-title-description.gif differ diff --git a/documentation/changelog/0.76.0/project-in-description.png b/documentation/changelog/0.76.0/project-in-description.png new file mode 100644 index 0000000000..d6bd691d04 Binary files /dev/null and b/documentation/changelog/0.76.0/project-in-description.png differ diff --git a/documentation/changelog/0.78.0/merge-queue.png b/documentation/changelog/0.78.0/merge-queue.png new file mode 100644 index 0000000000..196b6b326a Binary files /dev/null and b/documentation/changelog/0.78.0/merge-queue.png differ diff --git a/documentation/changelog/0.78.0/repo-name-changes-view.png b/documentation/changelog/0.78.0/repo-name-changes-view.png new file mode 100644 index 0000000000..f1982e8466 Binary files /dev/null and b/documentation/changelog/0.78.0/repo-name-changes-view.png differ diff --git a/documentation/changelog/0.80.0/group-by-milestone-repo.png b/documentation/changelog/0.80.0/group-by-milestone-repo.png new file mode 100644 index 0000000000..fa052d4610 Binary files /dev/null and b/documentation/changelog/0.80.0/group-by-milestone-repo.png differ diff --git a/documentation/changelog/0.80.0/merge-base-into-pr.png b/documentation/changelog/0.80.0/merge-base-into-pr.png new file mode 100644 index 0000000000..1bb9fd05f7 Binary files /dev/null and b/documentation/changelog/0.80.0/merge-base-into-pr.png differ diff --git a/documentation/changelog/0.80.0/multi-diff-editor.png b/documentation/changelog/0.80.0/multi-diff-editor.png new file mode 100644 index 0000000000..cae47352fa Binary files /dev/null and b/documentation/changelog/0.80.0/multi-diff-editor.png differ diff --git a/documentation/changelog/0.80.0/open-link-locally.gif b/documentation/changelog/0.80.0/open-link-locally.gif new file mode 100644 index 0000000000..ea67ae878e Binary files /dev/null and b/documentation/changelog/0.80.0/open-link-locally.gif differ diff --git a/documentation/changelog/0.80.0/reaction-hover.png b/documentation/changelog/0.80.0/reaction-hover.png new file mode 100644 index 0000000000..6102706589 Binary files /dev/null and b/documentation/changelog/0.80.0/reaction-hover.png differ diff --git a/documentation/changelog/0.80.0/resolve-merge-conflicts.png b/documentation/changelog/0.80.0/resolve-merge-conflicts.png new file mode 100644 index 0000000000..8a265b1eba Binary files /dev/null and b/documentation/changelog/0.80.0/resolve-merge-conflicts.png differ diff --git a/documentation/changelog/0.82.0/email-for-commit.png b/documentation/changelog/0.82.0/email-for-commit.png new file mode 100644 index 0000000000..4d356d4ddb Binary files /dev/null and b/documentation/changelog/0.82.0/email-for-commit.png differ diff --git a/documentation/changelog/0.86.0/context-menu-comment.png b/documentation/changelog/0.86.0/context-menu-comment.png new file mode 100644 index 0000000000..d61f70ab86 Binary files /dev/null and b/documentation/changelog/0.86.0/context-menu-comment.png differ diff --git a/documentation/changelog/0.86.0/inline-action-comments-view.png b/documentation/changelog/0.86.0/inline-action-comments-view.png new file mode 100644 index 0000000000..ed7a75913a Binary files /dev/null and b/documentation/changelog/0.86.0/inline-action-comments-view.png differ diff --git a/documentation/changelog/0.86.0/outdated-comment.png b/documentation/changelog/0.86.0/outdated-comment.png new file mode 100644 index 0000000000..0962cd0bbf Binary files /dev/null and b/documentation/changelog/0.86.0/outdated-comment.png differ diff --git a/documentation/changelog/0.88.0/accessibility-help.png b/documentation/changelog/0.88.0/accessibility-help.png new file mode 100644 index 0000000000..a3866efebe Binary files /dev/null and b/documentation/changelog/0.88.0/accessibility-help.png differ diff --git a/documentation/changelog/0.88.0/show-all-review-actions.gif b/documentation/changelog/0.88.0/show-all-review-actions.gif new file mode 100644 index 0000000000..3d38b0626d Binary files /dev/null and b/documentation/changelog/0.88.0/show-all-review-actions.gif differ diff --git a/documentation/changelog/0.92.0/date-of-commits.png b/documentation/changelog/0.92.0/date-of-commits.png new file mode 100644 index 0000000000..31c2dd77a5 Binary files /dev/null and b/documentation/changelog/0.92.0/date-of-commits.png differ diff --git a/documentation/changelog/0.94.0/create-revert-pr.gif b/documentation/changelog/0.94.0/create-revert-pr.gif new file mode 100644 index 0000000000..e045240d66 Binary files /dev/null and b/documentation/changelog/0.94.0/create-revert-pr.gif differ diff --git a/documentation/changelog/0.96.0/convert-to-suggestions.gif b/documentation/changelog/0.96.0/convert-to-suggestions.gif new file mode 100644 index 0000000000..7ada139de2 Binary files /dev/null and b/documentation/changelog/0.96.0/convert-to-suggestions.gif differ diff --git a/documentation/releasing.md b/documentation/releasing.md index cc9c0fb606..e76d24aace 100644 --- a/documentation/releasing.md +++ b/documentation/releasing.md @@ -5,29 +5,25 @@ **Until the marketplace supports semantic versioning, the minor version should always be an event number. Odd numbers are reserved for the pre-release version of the extension.** - (If necessary) Update vscode engine version - 2. Update [CHANGELOG.md](https://github.com/Microsoft/vscode-pull-request-github/blob/main/CHANGELOG.md) - In the **Changes** section, link to issues that were fixed or closed in the last sprint. Use a link to the pull request if there is no issue to reference. - In the **Thank You** section, @ mention users who contributed (if there were any). - -3. If there are new dependencies that have been added, update [ThirdPartyNotices.txt](https://github.com/microsoft/vscode-pull-request-github/commits/main/ThirdPartyNotices.txt). - - -4. Create PR with changes to `package.json` and `CHANGELOG.md` (and `ThirdPartyNotices.txt` when necessary) +3. Create PR with changes to `package.json` and `CHANGELOG.md` (`ThirdPartyNotices.txt` changes are not necessary as the pipeline creates the file) - Merge PR once changes are reviewed -5. If the minor version was increased, run the nightly build pipeline to ensure a new pre-release version with the increased version number is released +4. If the minor version was increased, run the nightly build pipeline to ensure a new pre-release version with the increased version number is released -6. Push a tag with the new version number to the appropriate commit (ex. `v0.5.0`). +5. Run the release pipeline with the `publishExtension` variable set to `true`. If needed, set the branch to the appropriate release branch (ex. `release/0.5`). -7. Wait for the release pipeline to finish running. +6. Wait for the release pipeline to finish running. -8. Draft new GitHub release +7. Draft new GitHub release - Go to: https://github.com/Microsoft/vscode-pull-request-github/releases - Tag should be the same as the extension version (ex. `v0.5.0`) - Set release title to the name of the version (ex. `0.5.0`) - Copy over contents from CHANGELOG.md - - Upload .vsix, which can be downloaded from the release pipeline - Preview release - **Publish** release + +8. If the nightly pre-release build was disable, re-enable in in https://github.com/microsoft/vscode-pull-request-github/blob/c6f00d59fb99c7807bfb963f55926505bdb723ef/azure-pipeline.nightly.yml diff --git a/documentation/suggestAChange.md b/documentation/suggestAChange.md new file mode 100644 index 0000000000..6d6e599073 --- /dev/null +++ b/documentation/suggestAChange.md @@ -0,0 +1,31 @@ +# Suggest a Change + +The "Suggest a Change" feature uses GitHub.com's mechanism for suggestion a change (as apposed to the old "Suggest an Edit" feature which used git patches to leave suggestsions). + +## Making a suggestion + +First, select the lines or place your cursor on the line you want to make a suggestion for. Then add a comment, either with the `+` in the editor or with the "Add Comment on Current Selection" command. From the comment, you can use the "Make a Suggestion" button, located below the comment input, to insert the suggestion template into the comment input. The "Make a Suggestion" button can be tabbed to in the comment widget. For example, if you want to leave a comment on this line: + +```ts +console.log('hello world'); +``` + +The following would be inserted into the comment input: + +```` +```suggestion + console.log('hello world'); +``` +```` + +You can then modify the contents of the `suggestion` block such that the code within demonstrates your suggestion. + +## Accepting a suggestion + +If a comment has a `suggestion` block in it as described above, the comment actions will include an "Apply Suggestion" button. This action can be tabbed to when you focus an existing comment. The suggestion is applied by replacing the lines that the comment targets with the contents of the suggestion. When you accept a suggestion, only the file is modified. To have the suggestion pushed to the pull request, you'll need to commit the file change and push the change to the remote branch. + +## Example + +This gif shows an example of how to make a suggestion and then apply it. + +![Example of how to suggest and accept a change in a PR](/documentation/changelog/0.58.0/suggest-a-change.gif) \ No newline at end of file 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 173ddc81e8..4f817a5e3c 100644 --- a/package.json +++ b/package.json @@ -11,23 +11,45 @@ "url": "https://github.com/Microsoft/vscode-pull-request-github/issues" }, "enabledApiProposals": [ - "tokenInformation", - "contribShareMenu", - "treeItemCheckbox", + "activeComment", + "chatContextProvider", + "chatParticipantAdditions", + "chatParticipantPrivate", + "chatSessionsProvider", + "codiconDecoration", + "codeActionRanges", + "commentingRangeHint", + "commentReactor", + "commentReveal", + "commentsDraftState", + "commentThreadApplicability", + "contribAccessibilityHelpContent", + "contribCommentEditorActionsMenu", "contribCommentPeekContext", "contribCommentThreadAdditionalMenu", - "codiconDecoration", + "contribCommentsViewThreadMenus", + "contribEditorContentMenu", + "contribShareMenu", "diffCommand", - "contribCommentEditorActionsMenu", - "quickDiffProvider" + "languageModelToolResultAudience", + "markdownAlertSyntax", + "quickDiffProvider", + "remoteCodingAgents", + "shareProvider", + "tabInputMultiDiff", + "tokenInformation", + "treeItemMarkdownLabel", + "treeViewMarkdownMessage" ], - "version": "0.58.0", + "version": "0.126.0", "publisher": "GitHub", "engines": { - "vscode": "^1.76.0" + "vscode": "^1.107.0" }, "categories": [ - "Other" + "Other", + "AI", + "Chat" ], "extensionDependencies": [ "vscode.github-authentication" @@ -37,7 +59,12 @@ "onFileSystem:newIssue", "onFileSystem:pr", "onFileSystem:githubpr", - "onFileSystem:review" + "onFileSystem:githubcommit", + "onFileSystem:review", + "onWebviewPanel:IssueOverview", + "onWebviewPanel:PullRequestOverview", + "onChatContextProvider:githubpr", + "onChatContextProvider:githubissue" ], "browser": "./dist/browser/extension", "l10n": "./dist/browser/extension", @@ -49,6 +76,28 @@ "virtualWorkspaces": true }, "contributes": { + "chatContext": [ + { + "id": "githubpr", + "icon": "$(github)", + "displayName": "GitHub Pull Requests" + }, + { + "id": "githubissue", + "icon": "$(issues)", + "displayName": "GitHub Issues" + } + ], + "chatParticipants": [ + { + "id": "githubpr", + "name": "githubpr", + "fullName": "GitHub Pull Requests", + "description": "Chat participant for GitHub Pull Requests extension", + "when": "config.githubPullRequests.experimental.chat", + "isSticky": true + } + ], "configuration": { "type": "object", "title": "GitHub Pull Requests", @@ -75,18 +124,42 @@ "type": "string", "enum": [ "template", - "commit" + "commit", + "branchName", + "none", + "Copilot" ], "enumDescriptions": [ "%githubPullRequests.pullRequestDescription.template%", - "%githubPullRequests.pullRequestDescription.commit%" + "%githubPullRequests.pullRequestDescription.commit%", + "%githubPullRequests.pullRequestDescription.branchName%", + "%githubPullRequests.pullRequestDescription.none%", + "%githubPullRequests.pullRequestDescription.copilot%" ], "default": "template", "description": "%githubPullRequests.pullRequestDescription.description%" }, + "githubPullRequests.defaultCreateOption": { + "type": "string", + "enum": [ + "lastUsed", + "create", + "createDraft", + "createAutoMerge" + ], + "markdownEnumDescriptions": [ + "%githubPullRequests.defaultCreateOption.lastUsed%", + "%githubPullRequests.defaultCreateOption.create%", + "%githubPullRequests.defaultCreateOption.createDraft%", + "%githubPullRequests.defaultCreateOption.createAutoMerge%" + ], + "default": "lastUsed", + "description": "%githubPullRequests.defaultCreateOption.description%" + }, "githubPullRequests.createDraft": { "type": "boolean", "default": false, + "deprecationMessage": "Use the setting 'githubPullRequests.defaultCreateOption' instead.", "description": "%githubPullRequests.createDraft%" }, "githubPullRequests.logLevel": { @@ -100,6 +173,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": [ @@ -134,25 +213,46 @@ "type": "string", "description": "%githubPullRequests.queries.query.description%" } + }, + "default": { + "label": "%githubPullRequests.queries.assignedToMe%", + "query": "repo:${owner}/${repository} is:open assignee:${user}" } }, "scope": "resource", "markdownDescription": "%githubPullRequests.queries.markdownDescription%", "default": [ { - "label": "%githubPullRequests.queries.waitingForMyReview%", - "query": "is:open review-requested:${user}" + "label": "%githubPullRequests.queries.copilotOnMyBehalf%", + "query": "repo:${owner}/${repository} is:open author:copilot assignee:${user}" }, { - "label": "%githubPullRequests.queries.assignedToMe%", - "query": "is:open assignee:${user}" + "label": "Local Pull Request Branches", + "query": "default" + }, + { + "label": "%githubPullRequests.queries.waitingForMyReview%", + "query": "repo:${owner}/${repository} is:open review-requested:${user}" }, { "label": "%githubPullRequests.queries.createdByMe%", - "query": "is:open author:${user}" + "query": "repo:${owner}/${repository} is:open author:${user}" + }, + { + "label": "All Open", + "query": "default" } ] }, + "githubPullRequests.labelCreated": { + "type": "array", + "items": { + "type": "string", + "description": "%githubPullRequests.labelCreated.label.description%" + }, + "default": [], + "description": "%githubPullRequests.labelCreated.description%" + }, "githubPullRequests.defaultMergeMethod": { "type": "string", "enum": [ @@ -187,6 +287,16 @@ "default": "tree", "description": "%githubPullRequests.fileListLayout.description%" }, + "githubPullRequests.hideViewedFiles": { + "type": "boolean", + "default": false, + "description": "%githubPullRequests.hideViewedFiles.description%" + }, + "githubPullRequests.fileAutoReveal": { + "type": "boolean", + "default": true, + "description": "%githubPullRequests.fileAutoReveal.description%" + }, "githubPullRequests.defaultDeletionMethod.selectLocalBranch": { "type": "boolean", "default": true, @@ -197,6 +307,11 @@ "default": true, "description": "%githubPullRequests.defaultDeletionMethod.selectRemote.description%" }, + "githubPullRequests.deleteBranchAfterMerge": { + "type": "boolean", + "default": false, + "description": "%githubPullRequests.deleteBranchAfterMerge.description%" + }, "githubPullRequests.terminalLinksHandler": { "type": "string", "enum": [ @@ -216,11 +331,13 @@ "type": "string", "enum": [ "never", - "ask" + "ask", + "always" ], "enumDescriptions": [ "%githubPullRequests.createOnPublishBranch.never%", - "%githubPullRequests.createOnPublishBranch.ask%" + "%githubPullRequests.createOnPublishBranch.ask%", + "%githubPullRequests.createOnPublishBranch.always%" ], "default": "ask", "description": "%githubPullRequests.createOnPublishBranch.description%" @@ -229,39 +346,48 @@ "type": "string", "enum": [ "expandUnresolved", - "collapseAll" + "collapseAll", + "collapsePreexisting" ], "enumDescriptions": [ "%githubPullRequests.commentExpandState.expandUnresolved%", - "%githubPullRequests.commentExpandState.collapseAll%" + "%githubPullRequests.commentExpandState.collapseAll%", + "%githubPullRequests.commentExpandState.collapsePreexisting%" ], "default": "expandUnresolved", "description": "%githubPullRequests.commentExpandState.description%" }, "githubPullRequests.useReviewMode": { - "type": "object", "description": "%githubPullRequests.useReviewMode.description%", - "additionalProperties": false, - "properties": { - "merged": { - "type": "boolean", - "description": "%githubPullRequests.useReviewMode.merged%", - "default": true + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "merged": { + "type": "boolean", + "description": "%githubPullRequests.useReviewMode.merged%", + "default": false + }, + "closed": { + "type": "boolean", + "description": "%githubPullRequests.useReviewMode.closed%", + "default": false + } + }, + "required": [ + "merged", + "closed" + ] }, - "closed": { - "type": "boolean", - "description": "%githubPullRequests.useReviewMode.closed%", - "default": false + { + "type": "string", + "enum": [ + "auto" + ] } - }, - "required": [ - "merged", - "closed" ], - "default": { - "merged": true, - "closed": false - } + "default": "auto" }, "githubPullRequests.assignCreated": { "type": "string", @@ -295,6 +421,11 @@ ], "description": "%githubPullRequests.pullBranch.description%" }, + "githubPullRequests.allowFetch": { + "type": "boolean", + "default": true, + "description": "%githubPullRequests.allowFetch.description%" + }, "githubPullRequests.ignoredPullRequestBranches": { "type": "array", "default": [], @@ -304,6 +435,15 @@ }, "description": "%githubPullRequests.ignoredPullRequestBranches.description%" }, + "githubPullRequests.ignoreSubmodules": { + "type": "boolean", + "default": false, + "description": "%githubPullRequests.ignoreSubmodules.description%" + }, + "githubPullRequests.neverIgnoreDefaultBranch": { + "type": "boolean", + "description": "%githubPullRequests.neverIgnoreDefaultBranch.description%" + }, "githubPullRequests.overrideDefaultBranch": { "type": "string", "description": "%githubPullRequests.overrideDefaultBranch.description%" @@ -313,14 +453,35 @@ "enum": [ "none", "openOverview", - "checkoutDefaultBranch" + "checkoutDefaultBranch", + "checkoutDefaultBranchAndShow", + "checkoutDefaultBranchAndCopy" ], "description": "%githubPullRequests.postCreate.description%", "default": "openOverview", "enumDescriptions": [ "%githubPullRequests.postCreate.none%", "%githubPullRequests.postCreate.openOverview%", - "%githubPullRequests.postCreate.checkoutDefaultBranch%" + "%githubPullRequests.postCreate.checkoutDefaultBranch%", + "%githubPullRequests.postCreate.checkoutDefaultBranchAndShow%", + "%githubPullRequests.postCreate.checkoutDefaultBranchAndCopy%" + ] + }, + "githubPullRequests.postDone": { + "type": "string", + "enum": [ + "checkoutDefaultBranch", + "checkoutDefaultBranchAndPull", + "checkoutPullRequestBaseBranch", + "checkoutPullRequestBaseBranchAndPull" + ], + "description": "%githubPullRequests.postDone.description%", + "default": "checkoutDefaultBranch", + "enumDescriptions": [ + "%githubPullRequests.postDone.checkoutDefaultBranch%", + "%githubPullRequests.postDone.checkoutDefaultBranchAndPull%", + "%githubPullRequests.postDone.checkoutPullRequestBaseBranch%", + "%githubPullRequests.postDone.checkoutPullRequestBaseBranchAndPull%" ] }, "githubPullRequests.defaultCommentType": { @@ -329,16 +490,130 @@ "single", "review" ], - "default": "single", + "default": "review", "description": "%githubPullRequests.defaultCommentType.description%", "enumDescriptions": [ "%githubPullRequests.defaultCommentType.single%", "%githubPullRequests.defaultCommentType.review%" ] }, - "githubPullRequests.experimental.quickDiff": { + "githubPullRequests.quickDiff": { + "type": "boolean", + "description": "Enables quick diff in the editor gutter for checked-out pull requests. Requires a reload to take effect", + "default": false + }, + "githubPullRequests.setAutoMerge": { + "type": "boolean", + "description": "%githubPullRequests.setAutoMerge.description%", + "deprecationMessage": "Use the setting 'githubPullRequests.defaultCreateOption' instead.", + "default": false + }, + "githubPullRequests.pullPullRequestBranchBeforeCheckout": { + "type": "string", + "description": "%githubPullRequests.pullPullRequestBranchBeforeCheckout.description%", + "enum": [ + "never", + "pull", + "pullAndMergeBase", + "pullAndUpdateBase" + ], + "default": "pull", + "enumDescriptions": [ + "%githubPullRequests.pullPullRequestBranchBeforeCheckout.never%", + "%githubPullRequests.pullPullRequestBranchBeforeCheckout.pull%", + "%githubPullRequests.pullPullRequestBranchBeforeCheckout.pullAndMergeBase%", + "%githubPullRequests.pullPullRequestBranchBeforeCheckout.pullAndUpdateBase%" + ] + }, + "githubPullRequests.upstreamRemote": { + "type": "string", + "enum": [ + "add", + "never" + ], + "markdownDescription": "%githubPullRequests.upstreamRemote.description%", + "markdownEnumDescriptions": [ + "%githubPullRequests.upstreamRemote.add%", + "%githubPullRequests.upstreamRemote.never%" + ], + "default": "add" + }, + "githubPullRequests.createDefaultBaseBranch": { + "type": "string", + "enum": [ + "repositoryDefault", + "createdFromBranch", + "auto" + ], + "markdownEnumDescriptions": [ + "%githubPullRequests.createDefaultBaseBranch.repositoryDefault%", + "%githubPullRequests.createDefaultBaseBranch.createdFromBranch%", + "%githubPullRequests.createDefaultBaseBranch.auto%" + ], + "default": "auto", + "markdownDescription": "%githubPullRequests.createDefaultBaseBranch.description%" + }, + "githubPullRequests.experimental.chat": { + "type": "boolean", + "markdownDescription": "%githubPullRequests.experimental.chat.description%", + "default": true + }, + "githubPullRequests.codingAgent.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "%githubPullRequests.codingAgent.description%", + "tags": [ + "experimental" + ] + }, + "githubPullRequests.codingAgent.autoCommitAndPush": { + "type": "boolean", + "default": true, + "markdownDescription": "%githubPullRequests.codingAgent.autoCommitAndPush.description%", + "tags": [ + "experimental" + ] + }, + "githubPullRequests.codingAgent.promptForConfirmation": { + "type": "boolean", + "default": true, + "markdownDescription": "%githubPullRequests.codingAgent.promptForConfirmation.description%", + "tags": [ + "experimental" + ] + }, + "githubPullRequests.codingAgent.uiIntegration": { + "type": "boolean", + "default": true, + "markdownDescription": "%githubPullRequests.codingAgent.uiIntegration.description%", + "tags": [ + "experimental", + "onExP" + ] + }, + "githubPullRequests.experimental.notificationsMarkPullRequests": { + "type": "string", + "markdownDescription": "%githubPullRequests.experimental.notificationsMarkPullRequests.description%", + "enum": [ + "markAsDone", + "markAsRead", + "none" + ], + "default": "none" + }, + "githubPullRequests.experimental.useQuickChat": { + "type": "boolean", + "markdownDescription": "%githubPullRequests.experimental.useQuickChat.description%", + "default": false + }, + "githubPullRequests.webviewRefreshInterval": { + "type": "number", + "markdownDescription": "%githubPullRequests.webviewRefreshInterval.description%", + "default": 60 + }, + "githubPullRequests.devMode": { "type": "boolean", - "description": "Enables experimental quick diff in the editor gutter for checked-out pull requests. Requires a reload to take effect", + "markdownDescription": "%githubPullRequests.devMode.description%", "default": false }, "githubIssues.ignoreMilestones": { @@ -355,7 +630,6 @@ "default": [ "TODO", "todo", - "BUG", "FIXME", "ISSUE", "HACK" @@ -389,6 +663,7 @@ }, "default": [ "coffeescript", + "crystal", "diff", "dockerfile", "dockercompose", @@ -412,9 +687,7 @@ "type": "string", "description": "%githubIssues.ignoreUserCompletionTrigger.items%" }, - "default": [ - "python" - ], + "default": [], "description": "%githubIssues.ignoreUserCompletionTrigger.description%" }, "githubIssues.issueBranchTitle": { @@ -435,11 +708,26 @@ "%githubIssues.useBranchForIssues.prompt%" ], "default": "on", - "description": "%githubIssues.useBranchForIssues.description%" + "markdownDescription": "%githubIssues.useBranchForIssues.markdownDescription%" + }, + "githubIssues.workingBaseBranch": { + "type": "string", + "enum": [ + "currentBranch", + "defaultBranch", + "prompt" + ], + "enumDescriptions": [ + "%githubIssues.workingBaseBranch.currentBranch%", + "%githubIssues.workingBaseBranch.defaultBranch%", + "%githubIssues.workingBaseBranch.prompt%" + ], + "default": "currentBranch", + "markdownDescription": "%githubIssues.workingBaseBranch.markdownDescription%" }, "githubIssues.issueCompletionFormatScm": { "type": "string", - "default": "${issueTitle} ${issueNumberLabel}", + "default": "${issueTitle}\nFixes ${issueNumberLabel}", "markdownDescription": "%githubIssues.issueCompletionFormatScm.markdownDescription%" }, "githubIssues.workingIssueFormatScm": { @@ -460,6 +748,21 @@ "query": { "type": "string", "markdownDescription": "%githubIssues.queries.query%" + }, + "groupBy": { + "type": "array", + "markdownDescription": "%githubIssues.queries.groupBy%", + "items": { + "type": "string", + "enum": [ + "repository", + "milestone" + ], + "enumDescriptions": [ + "%githubIssues.queries.groupBy.milestone%", + "%githubIssues.queries.groupBy.repository%" + ] + } } } }, @@ -468,7 +771,10 @@ "default": [ { "label": "%githubIssues.queries.default.myIssues%", - "query": "default" + "query": "is:open assignee:${user} repo:${owner}/${repository}", + "groupBy": [ + "milestone" + ] }, { "label": "%githubIssues.queries.default.createdIssues%", @@ -485,6 +791,19 @@ "default": true, "description": "%githubIssues.assignWhenWorking.description%" }, + "githubIssues.issueAvatarDisplay": { + "type": "string", + "enum": [ + "author", + "assignee" + ], + "enumDescriptions": [ + "%githubIssues.issueAvatarDisplay.author%", + "%githubIssues.issueAvatarDisplay.assignee%" + ], + "default": "author", + "description": "%githubIssues.issueAvatarDisplay.description%" + }, "githubPullRequests.focusedMode": { "properties": { "oneOf": [ @@ -499,10 +818,27 @@ "enum": [ "firstDiff", "overview", + "multiDiff", false ], - "default": "firstDiff", + "enumDescriptions": [ + "%githubPullRequests.focusedMode.firstDiff%", + "%githubPullRequests.focusedMode.overview%", + "%githubPullRequests.focusedMode.multiDiff%", + "%githubPullRequests.focusedMode.false%" + ], + "default": "multiDiff", "description": "%githubPullRequests.focusedMode.description%" + }, + "githubPullRequests.showPullRequestNumberInTree": { + "type": "boolean", + "default": false, + "description": "%githubPullRequests.showPullRequestNumberInTree.description%" + }, + "githubIssues.alwaysPromptForNewIssueRepo": { + "type": "boolean", + "default": false, + "description": "%githubIssues.alwaysPromptForNewIssueRepo.description%" } } }, @@ -510,7 +846,7 @@ "activitybar": [ { "id": "github-pull-requests", - "title": "GitHub", + "title": "%view.github.pull.requests.name%", "icon": "$(github)" }, { @@ -531,37 +867,63 @@ { "id": "pr:github", "name": "%view.pr.github.name%", - "when": "ReposManagerStateContext != NeedsAuthentication", - "icon": "$(git-pull-request)" + "when": "ReposManagerStateContext != NeedsAuthentication && !github:resolvingConflicts", + "icon": "$(github-inverted)", + "accessibilityHelpContent": "%view.pr.github.accessibilityHelpContent%" }, { "id": "issues:github", "name": "%view.issues.github.name%", - "when": "ReposManagerStateContext != NeedsAuthentication", - "icon": "$(issues)" + "when": "ReposManagerStateContext != NeedsAuthentication && !github:resolvingConflicts", + "icon": "$(issues)", + "accessibilityHelpContent": "%view.pr.github.accessibilityHelpContent%" + }, + { + "id": "notifications:github", + "name": "%view.notifications.github.name%", + "when": "ReposManagerStateContext != NeedsAuthentication && !github:resolvingConflicts && (remoteName != codespaces || !isWeb)", + "icon": "$(bell)", + "accessibilityHelpContent": "%view.pr.github.accessibilityHelpContent%", + "visibility": "collapsed" + }, + { + "id": "github:conflictResolution", + "name": "%view.github.conflictResolution.name%", + "when": "github:resolvingConflicts", + "icon": "$(git-merge)" } ], "github-pull-request": [ { - "id": "github:createPullRequest", + "id": "github:createPullRequestWebview", "type": "webview", "name": "%view.github.create.pull.request.name%", - "when": "github:createPullRequest", + "when": "github:createPullRequest || github:revertPullRequest", + "icon": "$(git-pull-request-create)", "visibility": "visible", "initialSize": 2 }, { - "id": "github:compareChanges", + "id": "github:compareChangesFiles", "name": "%view.github.compare.changes.name%", "when": "github:createPullRequest", + "icon": "$(git-compare)", + "visibility": "visible", + "initialSize": 1 + }, + { + "id": "github:compareChangesCommits", + "name": "%view.github.compare.changesCommits.name%", + "when": "github:createPullRequest", + "icon": "$(git-compare)", "visibility": "visible", "initialSize": 1 }, { "id": "prStatus:github", "name": "%view.pr.status.github.name%", - "when": "github:inReviewMode && !github:createPullRequest", - "icon": "$(git-pull-request)", + "when": "github:inReviewMode && !github:createPullRequest && !github:revertPullRequest", + "icon": "$(diff-multiple)", "visibility": "visible", "initialSize": 3 }, @@ -569,13 +931,15 @@ "id": "github:activePullRequest", "type": "webview", "name": "%view.github.active.pull.request.name%", - "when": "github:inReviewMode && github:focusedReview && !github:createPullRequest && github:activePRCount <= 1", + "when": "github:inReviewMode && github:focusedReview && !github:createPullRequest && !github:revertPullRequest && github:activePRCount <= 1", + "icon": "$(code-review)", "initialSize": 2 }, { "id": "github:activePullRequest:welcome", "name": "%view.github.active.pull.request.welcome.name%", - "when": "!github:stateValidated && github:focusedReview" + "when": "!github:stateValidated && github:focusedReview", + "icon": "$(git-pull-request)" } ] }, @@ -601,8 +965,27 @@ "command": "pr.pick", "title": "%command.pr.pick.title%", "category": "%command.pull.request.category%", + "enablement": "viewItem =~ /hasHeadRef/", "icon": "$(arrow-right)" }, + { + "command": "pr.openChanges", + "title": "%command.pr.openChanges.title%", + "category": "%command.pull.request.category%", + "icon": "$(diff-multiple)" + }, + { + "command": "pr.pickOnVscodeDev", + "title": "%command.pr.pickOnVscodeDev.title%", + "category": "%command.pull.request.category%", + "icon": "$(globe)" + }, + { + "command": "pr.pickOnCodespaces", + "title": "%command.pr.pickOnCodespaces.title%", + "category": "%command.pull.request.category%", + "icon": "$(cloud)" + }, { "command": "pr.exit", "title": "%command.pr.exit.title%", @@ -613,6 +996,12 @@ "title": "%command.pr.dismissNotification.title%", "category": "%command.pull.request.category%" }, + { + "command": "pr.markAllCopilotNotificationsAsRead", + "title": "%command.pr.markAllCopilotNotificationsAsRead.title%", + "category": "%command.pull.request.category%", + "enablement": "viewItem == copilot-query-with-notifications" + }, { "command": "pr.merge", "title": "%command.pr.merge.title%", @@ -624,8 +1013,18 @@ "category": "%command.pull.request.category%" }, { - "command": "pr.close", - "title": "%command.pr.close.title%", + "command": "pr.readyForReviewAndMerge", + "title": "%command.pr.readyForReviewAndMerge.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.readyForReviewDescription", + "title": "%command.pr.readyForReview.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.readyForReviewAndMergeDescription", + "title": "%command.pr.readyForReviewAndMerge.title%", "category": "%command.pull.request.category%" }, { @@ -649,6 +1048,11 @@ "title": "%command.pr.openFileOnGitHub.title%", "category": "%command.pull.request.category%" }, + { + "command": "pr.revealFileInOS", + "title": "%command.pr.revealFileInOS.title%", + "category": "%command.pull.request.category%" + }, { "command": "pr.copyCommitHash", "title": "%command.pr.copyCommitHash.title%", @@ -670,6 +1074,12 @@ "category": "%command.pull.request.category%", "icon": "$(compare-changes)" }, + { + "command": "pr.openDiffViewFromEditor", + "title": "%command.pr.openDiffViewFromEditor.title%", + "category": "%command.pull.request.category%", + "icon": "$(git-pull-request)" + }, { "command": "pr.openDescription", "title": "%command.pr.openDescription.title%", @@ -687,6 +1097,11 @@ "title": "%command.pr.refreshDescription.title%", "category": "%command.pull.request.category%" }, + { + "command": "pr.focusDescriptionInput", + "title": "%command.pr.focusDescriptionInput.title%", + "category": "%command.pull.request.category%" + }, { "command": "pr.showDiffSinceLastReview", "title": "%command.pr.showDiffSinceLastReview.title%", @@ -713,11 +1128,6 @@ "title": "%command.review.openLocalFile.title%", "icon": "$(go-to-file)" }, - { - "command": "review.suggestDiff", - "title": "%command.review.suggestDiff.title%", - "category": "%command.pull.request.category%" - }, { "command": "pr.refreshList", "title": "%command.pr.refreshList.title%", @@ -736,6 +1146,12 @@ "icon": "$(list-flat)", "category": "%command.pull.request.category%" }, + { + "command": "pr.toggleHideViewedFiles", + "title": "%command.pr.toggleHideViewedFiles.title%", + "icon": "$(filter)", + "category": "%command.pull.request.category%" + }, { "command": "pr.refreshChanges", "title": "%command.pr.refreshChanges.title%", @@ -758,6 +1174,11 @@ "title": "%command.pr.signin.title%", "category": "%command.pull.request.category%" }, + { + "command": "pr.signinNoEnterprise", + "title": "%command.pr.signin.title%", + "category": "%command.pull.request.category%" + }, { "command": "pr.signinenterprise", "title": "%command.pr.signinenterprise.title%", @@ -795,7 +1216,8 @@ "command": "pr.editComment", "title": "%command.pr.editComment.title%", "category": "%command.pull.request.category%", - "icon": "$(edit)" + "icon": "$(edit)", + "enablement": "!(comment =~ /temporary/)" }, { "command": "pr.cancelEditComment", @@ -812,18 +1234,32 @@ "command": "pr.deleteComment", "title": "%command.pr.deleteComment.title%", "category": "%command.pull.request.category%", - "icon": "$(trash)" + "icon": "$(trash)", + "enablement": "!(comment =~ /temporary/)" }, { "command": "pr.resolveReviewThread", "title": "%command.pr.resolveReviewThread.title%", - "category": "%command.pull.request.category%" + "category": "%command.pull.request.category%", + "icon": "$(check)" }, { "command": "pr.unresolveReviewThread", "title": "%command.pr.unresolveReviewThread.title%", "category": "%command.pull.request.category%" }, + { + "command": "pr.unresolveReviewThreadFromView", + "title": "%command.pr.unresolveReviewThread.title%", + "category": "%command.pull.request.category%", + "icon": "$(sync)" + }, + { + "command": "pr.diffOutdatedCommentWithHead", + "title": "%command.pr.diffOutdatedCommentWithHead.title%", + "category": "%command.pull.request.category%", + "icon": "$(git-compare)" + }, { "command": "pr.signinAndRefreshList", "title": "%command.pr.signinAndRefreshList.title%", @@ -894,13 +1330,32 @@ "command": "pr.copyCommentLink", "title": "%command.pr.copyCommentLink.title%", "category": "%command.pull.request.category%", - "icon": "$(copy)" + "icon": "$(copy)", + "enablement": "!(comment =~ /temporary/)" }, { "command": "pr.applySuggestion", "title": "%command.pr.applySuggestion.title%", "category": "%command.pull.request.category%", - "icon": "$(gift)" + "icon": "$(replace)" + }, + { + "command": "pr.applySuggestionWithCopilot", + "title": "%command.pr.applySuggestionWithCopilot.title%", + "category": "%command.pull.request.category%", + "icon": "$(sparkle)" + }, + { + "command": "pr.addAssigneesToNewPr", + "title": "%command.pr.addAssigneesToNewPr.title%", + "category": "%command.pull.request.category%", + "icon": "$(account)" + }, + { + "command": "pr.addReviewersToNewPr", + "title": "%command.pr.addReviewersToNewPr.title%", + "category": "%command.pull.request.category%", + "icon": "$(feedback)" }, { "command": "pr.addLabelsToNewPr", @@ -909,45 +1364,301 @@ "icon": "$(tag)" }, { - "command": "issue.createIssueFromSelection", - "title": "%command.issue.createIssueFromSelection.title%", - "category": "%command.issues.category%" + "command": "pr.addMilestoneToNewPr", + "title": "%command.pr.addMilestoneToNewPr.title%", + "category": "%command.pull.request.category%", + "icon": "$(milestone)" }, { - "command": "issue.createIssueFromClipboard", - "title": "%command.issue.createIssueFromClipboard.title%", - "category": "%command.issues.category%" + "command": "pr.addProjectsToNewPr", + "title": "%command.pr.addProjectsToNewPr.title%", + "category": "%command.pull.request.category%", + "icon": "$(github-project)" }, { - "command": "pr.copyVscodeDevPrLink", - "title": "%command.pr.copyVscodeDevPrLink.title%", - "category": "%command.issues.category%" + "command": "pr.preReview", + "title": "%command.pr.preReview.title%", + "category": "%command.pull.request.category%", + "enablement": "!pr:preReviewing && !pr:creating", + "icon": "$(comment)" }, { - "command": "issue.copyGithubPermalink", - "title": "%command.issue.copyGithubPermalink.title%", - "category": "%command.issues.category%" + "command": "pr.addFileComment", + "title": "%command.pr.addFileComment.title%", + "category": "%command.pull.request.category%", + "icon": "$(comment)" }, { - "command": "issue.copyGithubHeadLink", - "title": "%command.issue.copyGithubHeadLink.title%", - "category": "%command.issues.category%" + "command": "pr.checkoutFromReadonlyFile", + "title": "%command.pr.pick.title%", + "category": "%command.pull.request.category%" }, { - "command": "issue.copyMarkdownGithubPermalink", - "title": "%command.issue.copyMarkdownGithubPermalink.title%", - "category": "%command.issues.category%" + "command": "pr.resolveConflict", + "title": "%command.pr.resolveConflict.title%", + "category": "%command.pull.request.category%" }, { - "command": "issue.openGithubPermalink", - "title": "%command.issue.openGithubPermalink.title%", - "category": "%command.issues.category%" + "command": "pr.acceptMerge", + "title": "%command.pr.acceptMerge.title%", + "category": "%command.pull.request.category%" }, { - "command": "issue.openIssue", - "title": "%command.issue.openIssue.title%", - "category": "%command.issues.category%", - "icon": "$(globe)" + "command": "pr.closeRelatedEditors", + "title": "%command.pr.closeRelatedEditors.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.toggleEditorCommentingOn", + "title": "%command.pr.toggleEditorCommentingOn.title%", + "category": "%command.pull.request.category%", + "icon": "$(eye-closed)" + }, + { + "command": "pr.toggleEditorCommentingOff", + "title": "%command.pr.toggleEditorCommentingOff.title%", + "category": "%command.pull.request.category%", + "icon": "$(eye)" + }, + { + "command": "pr.checkoutFromDescription", + "title": "%command.pr.checkoutFromDescription.title%", + "category": "%command.pull.request.category%", + "icon": "$(git-compare)" + }, + { + "command": "pr.applyChangesFromDescription", + "title": "%command.pr.applyChangesFromDescription.title%", + "category": "%command.pull.request.category%", + "icon": "$(git-stash-apply)" + }, + { + "command": "pr.checkoutOnVscodeDevFromDescription", + "title": "%command.pr.checkoutOnVscodeDevFromDescription.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.checkoutOnCodespacesFromDescription", + "title": "%command.pr.checkoutOnCodespacesFromDescription.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.openSessionLogFromDescription", + "title": "%command.pr.openSessionLogFromDescription.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.diffWithPrHead", + "title": "%command.review.diffWithPrHead.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.diffLocalWithPrHead", + "title": "%command.review.diffLocalWithPrHead.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.approve", + "title": "%command.review.approve.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.comment", + "title": "%command.review.comment.title%", + "category": "%command.pull.request.category%", + "enablement": "github:reviewCommentCommentEnabled" + }, + { + "command": "review.requestChanges", + "title": "%command.review.requestChanges.title%", + "category": "%command.pull.request.category%", + "enablement": "github:reviewRequestChangesEnabled" + }, + { + "command": "review.approveOnDotCom", + "title": "%command.review.approveOnDotCom.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.requestChangesOnDotCom", + "title": "%command.review.requestChangesOnDotCom.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.approveDescription", + "title": "%command.review.approve.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.commentDescription", + "title": "%command.review.comment.title%", + "category": "%command.pull.request.category%", + "enablement": "github:reviewCommentCommentEnabled" + }, + { + "command": "review.requestChangesDescription", + "title": "%command.review.requestChanges.title%", + "category": "%command.pull.request.category%", + "enablement": "github:reviewRequestChangesEnabled" + }, + { + "command": "review.approveOnDotComDescription", + "title": "%command.review.approveOnDotCom.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.requestChangesOnDotComDescription", + "title": "%command.review.requestChangesOnDotCom.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.createSuggestionsFromChanges", + "title": "%command.review.createSuggestionsFromChanges.title%", + "icon": "$(comment)", + "category": "%command.pull.request.category%" + }, + { + "command": "review.createSuggestionFromChange", + "title": "%command.review.createSuggestionFromChange.title%", + "icon": "$(comment)", + "category": "%command.pull.request.category%" + }, + { + "command": "review.copyPrLink", + "title": "%command.review.copyPrLink.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuCreate", + "title": "%command.pr.createPrMenuCreate.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuDraft", + "title": "%command.pr.createPrMenuDraft.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuMergeWhenReady", + "title": "%command.pr.createPrMenuMergeWhenReady.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuMerge", + "title": "%command.pr.createPrMenuMerge.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuSquash", + "title": "%command.pr.createPrMenuSquash.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuRebase", + "title": "%command.pr.createPrMenuRebase.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "issue.openDescription", + "title": "%command.issue.openDescription.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.openIssueOnGitHub", + "title": "%command.issue.openIssueOnGitHub.title%", + "category": "%command.issues.category%", + "icon": "$(globe)" + }, + { + "command": "issue.createIssueFromSelection", + "title": "%command.issue.createIssueFromSelection.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.createIssueFromClipboard", + "title": "%command.issue.createIssueFromClipboard.title%", + "category": "%command.issues.category%" + }, + { + "command": "pr.copyVscodeDevPrLink", + "title": "%command.pr.copyVscodeDevPrLink.title%", + "category": "%command.issues.category%" + }, + { + "command": "pr.copyPrLink", + "title": "%command.pr.copyPrLink.title%", + "category": "%command.issues.category%" + }, + { + "command": "pr.refreshComments", + "title": "%command.pr.refreshComments.title%", + "category": "%command.pull.request.category%", + "icon": "$(refresh)" + }, + { + "command": "issue.copyGithubDevLinkWithoutRange", + "title": "%command.issue.copyGithubDevLink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyGithubDevLinkFile", + "title": "%command.issue.copyGithubDevLink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyGithubDevLink", + "title": "%command.issue.copyGithubDevLink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyGithubPermalink", + "title": "%command.issue.copyGithubPermalink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyGithubHeadLink", + "title": "%command.issue.copyGithubHeadLink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyGithubPermalinkWithoutRange", + "title": "%command.issue.copyGithubPermalink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyGithubHeadLinkWithoutRange", + "title": "%command.issue.copyGithubHeadLink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyMarkdownGithubPermalink", + "title": "%command.issue.copyMarkdownGithubPermalink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyMarkdownGithubPermalinkWithoutRange", + "title": "%command.issue.copyMarkdownGithubPermalink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.openGithubPermalink", + "title": "%command.issue.openGithubPermalink.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.openIssue", + "title": "%command.issue.openIssue.title%", + "category": "%command.issues.category%", + "icon": "$(globe)" }, { "command": "issue.copyIssueNumber", @@ -1054,25 +1765,128 @@ { "command": "issues.openIssuesWebsite", "title": "%command.issues.openIssuesWebsite.title%", - "category": "%command.pull.request.category%", + "category": "%command.issues.category%", "icon": "$(globe)" + }, + { + "command": "issue.chatSummarizeIssue", + "title": "%command.issue.chatSummarizeIssue.title%", + "category": "%command.issues.category%", + "icon": "$(copilot)" + }, + { + "command": "issue.chatSuggestFix", + "title": "%command.issue.chatSuggestFix.title%", + "category": "%command.issues.category%", + "icon": "$(sparkle)" + }, + { + "command": "issue.assignToCodingAgent", + "title": "%command.issue.assignToCodingAgent.title%", + "category": "%command.issues.category%", + "icon": "$(send-to-remote-agent)", + "enablement": "config.githubPullRequests.codingAgent.enabled" + }, + { + "command": "issues.configureIssuesViewlet", + "title": "%command.issues.configureIssuesViewlet.title%", + "category": "%command.issues.category%", + "icon": "$(gear)" + }, + { + "command": "notifications.refresh", + "title": "%command.notifications.refresh.title%", + "category": "%command.notifications.category%", + "icon": "$(refresh)" + }, + { + "command": "notifications.loadMore", + "title": "%command.notifications.loadMore.title%", + "category": "%command.notifications.category%" + }, + { + "command": "notifications.sortByTimestamp", + "title": "%command.notifications.sortByTimestamp.title%", + "category": "%command.notifications.category%" + }, + { + "command": "notifications.sortByPriority", + "title": "%command.notifications.sortByPriority.title%", + "category": "%command.notifications.category%" + }, + { + "command": "notification.openOnGitHub", + "title": "%command.notifications.openOnGitHub.title%", + "category": "%command.notifications.category%", + "icon": "$(globe)" + }, + { + "command": "notification.chatSummarizeNotification", + "title": "%command.notification.chatSummarizeNotification.title%", + "category": "%command.notifications.category%", + "icon": "$(copilot)" + }, + { + "command": "notification.markAsRead", + "title": "%command.notifications.markAsRead.title%", + "category": "%command.notifications.category%", + "icon": "$(mail-read)" + }, + { + "command": "notification.markAsDone", + "title": "%command.notifications.markAsDone.title%", + "category": "%command.notifications.category%", + "icon": "$(check-all)" + }, + { + "command": "notifications.markPullRequestsAsRead", + "title": "%command.notifications.markPullRequestsAsRead.title%", + "category": "%command.notifications.category%", + "icon": "$(git-pull-request-done)" + }, + { + "command": "notifications.markPullRequestsAsDone", + "title": "%command.notifications.markPullRequestsAsDone.title%", + "category": "%command.notifications.category%", + "icon": "$(git-pull-request-done)" + }, + { + "command": "notifications.configureNotificationsViewlet", + "title": "%command.notifications.configureNotificationsViewlet.title%", + "category": "%command.notifications.category%", + "icon": "$(gear)" + }, + { + "command": "pr.checkoutChatSessionPullRequest", + "title": "%command.pr.checkoutChatSessionPullRequest.title%", + "category": "%command.pull.request.category%" } ], "viewsWelcome": [ { "view": "github:login", - "when": "ReposManagerStateContext == NeedsAuthentication", + "when": "ReposManagerStateContext == NeedsAuthentication && github:hasGitHubRemotes", "contents": "%welcome.github.login.contents%" }, + { + "view": "pr:github", + "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", - "contents": "%welcome.github.loginWithEnterprise.contents%" + "contents": "%welcome.github.loginNoEnterprise.contents%" }, { - "view": "github:compareChanges", - "when": "github:noUpstream", - "contents": "%welcome.github.compareChanges.contents%" + "view": "github:login", + "when": "(ReposManagerStateContext == NeedsAuthentication) && ((!github:hasGitHubRemotes && gitOpenRepositoryCount) || config.github-enterprise.uri)", + "contents": "%welcome.github.loginWithEnterprise.contents%" }, { "view": "pr:github", @@ -1091,9 +1905,19 @@ }, { "view": "pr:github", - "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0", + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount == 0", "contents": "%welcome.pr.github.noRepo.contents%" }, + { + "view": "pr:github", + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount == 1", + "contents": "%welcome.pr.github.parentRepo.contents%" + }, + { + "view": "pr:github", + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount > 1", + "contents": "%welcome.pr.github.parentRepo.contents%" + }, { "view": "issues:github", "when": "git.state != initialized && !github:initialized && workspaceFolderCount > 0", @@ -1111,13 +1935,38 @@ }, { "view": "issues:github", - "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0", + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount == 0", "contents": "%welcome.issues.github.noRepo.contents%" }, + { + "view": "issues:github", + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount == 1", + "contents": "%welcome.pr.github.parentRepo.contents%" + }, + { + "view": "issues:github", + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount > 1", + "contents": "%welcome.pr.github.parentRepo.contents%" + }, { "view": "github:activePullRequest:welcome", "when": "!github:stateValidated", "contents": "%welcome.github.activePullRequest.contents%" + }, + { + "view": "notifications:github", + "when": "!github:notificationCount && workspaceFolderCount > 0", + "contents": "%welcome.github.notificationsLoading.contents%" + }, + { + "view": "notifications:github", + "when": "workspaceFolderCount == 0", + "contents": "%welcome.issues.github.noFolder.contents%" + }, + { + "view": "notifications:github", + "when": "ReposManagerStateContext == RepositoriesLoaded && github:notificationCount == -1", + "contents": "%welcome.github.notifications.contents%" } ], "keybindings": [ @@ -1131,6 +1980,30 @@ "mac": "cmd+s", "command": "issue.createIssueFromFile", "when": "resourceScheme == newIssue && config.files.autoSave != off" + }, + { + "key": "ctrl+enter", + "mac": "cmd+enter", + "command": "issue.createIssueFromFile", + "when": "resourceScheme == newIssue" + }, + { + "key": "ctrl+k m", + "mac": "cmd+k m", + "command": "pr.makeSuggestion", + "when": "commentEditorFocused" + }, + { + "key": "ctrl+r", + "mac": "cmd+r", + "command": "pr.refreshDescription", + "when": "activeWebviewPanelId == 'PullRequestOverview'" + }, + { + "key": "shift+alt+r", + "mac": "shift+alt+cmd+r", + "command": "pr.revealFileInOS", + "when": "focusedView =~ /(pr|prStatus):github/ && listFocus" } ], "menus": { @@ -1151,6 +2024,22 @@ "command": "pr.pick", "when": "false" }, + { + "command": "pr.checkoutFromReadonlyFile", + "when": "false" + }, + { + "command": "pr.openChanges", + "when": "false" + }, + { + "command": "pr.pickOnVscodeDev", + "when": "false" + }, + { + "command": "pr.pickOnCodespaces", + "when": "false" + }, { "command": "pr.exit", "when": "github:inReviewMode" @@ -1159,6 +2048,10 @@ "command": "pr.dismissNotification", "when": "false" }, + { + "command": "pr.markAllCopilotNotificationsAsRead", + "when": "false" + }, { "command": "pr.resetViewedFiles", "when": "github:inReviewMode" @@ -1171,10 +2064,6 @@ "command": "review.openLocalFile", "when": "false" }, - { - "command": "pr.close", - "when": "gitHubOpenRepositoryCount != 0 && github:inReviewMode" - }, { "command": "pr.create", "when": "gitHubOpenRepositoryCount != 0 && github:authenticated" @@ -1189,7 +2078,19 @@ }, { "command": "pr.readyForReview", - "when": "gitHubOpenRepositoryCount != 0 && github:inReviewMode" + "when": "false" + }, + { + "command": "pr.readyForReviewDescription", + "when": "false" + }, + { + "command": "pr.readyForReviewAndMergeDescription", + "when": "false" + }, + { + "command": "pr.readyForReviewAndMerge", + "when": "false" }, { "command": "pr.openPullRequestOnGitHub", @@ -1207,6 +2108,10 @@ "command": "pr.openFileOnGitHub", "when": "false" }, + { + "command": "pr.revealFileInOS", + "when": "false" + }, { "command": "pr.openOriginalFile", "when": "false" @@ -1227,6 +2132,10 @@ "command": "pr.openDiffView", "when": "false" }, + { + "command": "pr.openDiffViewFromEditor", + "when": "false" + }, { "command": "pr.openDescriptionToTheSide", "when": "false" @@ -1235,6 +2144,10 @@ "command": "pr.openDescription", "when": "gitHubOpenRepositoryCount != 0 && github:inReviewMode" }, + { + "command": "pr.focusDescriptionInput", + "when": "github:pullRequestDescriptionVisible" + }, { "command": "pr.showDiffSinceLastReview", "when": "false" @@ -1243,6 +2156,86 @@ "command": "pr.showDiffAll", "when": "false" }, + { + "command": "pr.closeRelatedEditors", + "when": "gitHubOpenRepositoryCount != 0" + }, + { + "command": "pr.toggleEditorCommentingOn", + "when": "false" + }, + { + "command": "pr.toggleEditorCommentingOff", + "when": "false" + }, + { + "command": "pr.checkoutFromDescription", + "when": "false" + }, + { + "command": "pr.applyChangesFromDescription", + "when": "false" + }, + { + "command": "pr.checkoutChatSessionPullRequest", + "when": "false" + }, + { + "command": "pr.checkoutOnVscodeDevFromDescription", + "when": "false" + }, + { + "command": "pr.openSessionLogFromDescription", + "when": "false" + }, + { + "command": "review.approve", + "when": "false" + }, + { + "command": "review.comment", + "when": "false" + }, + { + "command": "review.requestChanges", + "when": "false" + }, + { + "command": "review.approveOnDotCom", + "when": "false" + }, + { + "command": "review.requestChangesOnDotCom", + "when": "false" + }, + { + "command": "review.approveDescription", + "when": "false" + }, + { + "command": "review.commentDescription", + "when": "false" + }, + { + "command": "review.requestChangesDescription", + "when": "false" + }, + { + "command": "review.approveOnDotComDescription", + "when": "false" + }, + { + "command": "review.requestChangesOnDotComDescription", + "when": "false" + }, + { + "command": "review.createSuggestionsFromChanges", + "when": "false" + }, + { + "command": "review.createSuggestionFromChange", + "when": "activeEditor == workbench.editors.textDiffEditor && (resourcePath in github:unviewedFiles || resourcePath in github:viewedFiles)" + }, { "command": "pr.refreshList", "when": "gitHubOpenRepositoryCount != 0 && github:authenticated && github:hasGitHubRemotes" @@ -1255,6 +2248,10 @@ "command": "pr.setFileListLayoutAsFlat", "when": "false" }, + { + "command": "pr.toggleHideViewedFiles", + "when": "false" + }, { "command": "pr.refreshChanges", "when": "false" @@ -1263,6 +2260,10 @@ "command": "pr.signin", "when": "gitHubOpenRepositoryCount != 0 && github:hasGitHubRemotes" }, + { + "command": "pr.signinNoEnterprise", + "when": "false" + }, { "command": "pr.signinenterprise", "when": "gitHubOpenRepositoryCount != 0 && github:hasGitHubRemotes" @@ -1307,6 +2308,18 @@ "command": "pr.deleteComment", "when": "false" }, + { + "command": "pr.unresolveReviewThread", + "when": "false" + }, + { + "command": "pr.unresolveReviewThreadFromView", + "when": "false" + }, + { + "command": "pr.resolveReviewThread", + "when": "false" + }, { "command": "pr.openReview", "when": "false" @@ -1333,7 +2346,11 @@ }, { "command": "pr.copyVscodeDevPrLink", - "when": "github:inReviewMode" + "when": "github:inReviewMode && remoteName != codespaces && embedderIdentifier != github.dev" + }, + { + "command": "pr.copyPrLink", + "when": "false" }, { "command": "pr.goToNextDiffInPr", @@ -1356,7 +2373,79 @@ "when": "false" }, { - "command": "pr.addLabelsToNewPr", + "command": "pr.addAssigneesToNewPr", + "when": "false" + }, + { + "command": "pr.addReviewersToNewPr", + "when": "false" + }, + { + "command": "pr.addLabelsToNewPr", + "when": "false" + }, + { + "command": "pr.addMilestoneToNewPr", + "when": "false" + }, + { + "command": "pr.addProjectsToNewPr", + "when": "false" + }, + { + "command": "pr.preReview", + "when": "false" + }, + { + "command": "pr.addFileComment", + "when": "false" + }, + { + "command": "review.diffWithPrHead", + "when": "false" + }, + { + "command": "review.diffLocalWithPrHead", + "when": "false" + }, + { + "command": "pr.createPrMenuCreate", + "when": "false" + }, + { + "command": "pr.createPrMenuDraft", + "when": "false" + }, + { + "command": "pr.createPrMenuMergeWhenReady", + "when": "false" + }, + { + "command": "pr.createPrMenuMerge", + "when": "false" + }, + { + "command": "pr.createPrMenuSquash", + "when": "false" + }, + { + "command": "pr.createPrMenuRebase", + "when": "false" + }, + { + "command": "pr.refreshComments", + "when": "gitHubOpenRepositoryCount != 0" + }, + { + "command": "pr.resolveConflict", + "when": "false" + }, + { + "command": "pr.acceptMerge", + "when": "isMergeResultEditor && mergeEditorBaseUri =~ /^(githubpr|gitpr):/" + }, + { + "command": "issue.openDescription", "when": "false" }, { @@ -1379,6 +2468,10 @@ "command": "issue.openIssue", "when": "false" }, + { + "command": "issue.assignToCodingAgent", + "when": "false" + }, { "command": "issue.copyIssueNumber", "when": "false" @@ -1455,10 +2548,46 @@ "command": "issue.goToLinkedCode", "when": "false" }, + { + "command": "issue.copyGithubDevLinkWithoutRange", + "when": "false" + }, + { + "command": "issue.copyGithubDevLinkFile", + "when": "false" + }, + { + "command": "issue.copyGithubDevLink", + "when": "false" + }, + { + "command": "issue.copyGithubPermalinkWithoutRange", + "when": "false" + }, + { + "command": "issue.copyMarkdownGithubPermalinkWithoutRange", + "when": "false" + }, + { + "command": "issue.copyGithubHeadLinkWithoutRange", + "when": "false" + }, + { + "command": "issues.configureIssuesViewlet", + "when": "false" + }, { "command": "pr.refreshActivePullRequest", "when": "false" }, + { + "command": "pr.applySuggestion", + "when": "false" + }, + { + "command": "pr.applySuggestionWithCopilot", + "when": "false" + }, { "command": "pr.openPullsWebsite", "when": "github:hasGitHubRemotes" @@ -1466,6 +2595,62 @@ { "command": "issues.openIssuesWebsite", "when": "github:hasGitHubRemotes" + }, + { + "command": "issue.chatSummarizeIssue", + "when": "false" + }, + { + "command": "issue.chatSuggestFix", + "when": "false" + }, + { + "command": "notifications.sortByTimestamp", + "when": "false" + }, + { + "command": "notifications.sortByPriority", + "when": "false" + }, + { + "command": "notifications.loadMore", + "when": "false" + }, + { + "command": "notifications.refresh", + "when": "false" + }, + { + "command": "notification.openOnGitHub", + "when": "false" + }, + { + "command": "notification.markAsRead", + "when": "false" + }, + { + "command": "notification.markAsDone", + "when": "false" + }, + { + "command": "notification.chatSummarizeNotification", + "when": "false" + }, + { + "command": "notifications.markPullRequestsAsRead", + "when": "false" + }, + { + "command": "notifications.markPullRequestsAsDone", + "when": "false" + }, + { + "command": "notifications.configureNotificationsViewlet", + "when": "false" + }, + { + "command": "review.copyPrLink", + "when": "github:inReviewMode" } ], "view/title": [ @@ -1502,12 +2687,27 @@ { "command": "pr.setFileListLayoutAsTree", "when": "view == prStatus:github && fileListLayout:flat", - "group": "navigation" + "group": "navigation1" }, { "command": "pr.setFileListLayoutAsFlat", "when": "view == prStatus:github && !fileListLayout:flat", - "group": "navigation" + "group": "navigation1" + }, + { + "command": "pr.toggleHideViewedFiles", + "when": "view == prStatus:github", + "group": "navigation1" + }, + { + "command": "pr.toggleEditorCommentingOn", + "when": "view == prStatus:github && !commentingEnabled", + "group": "navigation@0" + }, + { + "command": "pr.toggleEditorCommentingOff", + "when": "view == prStatus:github && commentingEnabled", + "group": "navigation@0" }, { "command": "issue.createIssue", @@ -1525,7 +2725,7 @@ "group": "overflow@1" }, { - "command": "pr.configurePRViewlet", + "command": "issues.configureIssuesViewlet", "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == issues:github", "group": "overflow@2" }, @@ -1544,88 +2744,226 @@ "when": "view == github:activePullRequest && github:hasGitHubRemotes", "group": "navigation@3" }, + { + "command": "pr.addAssigneesToNewPr", + "when": "view == github:createPullRequestWebview && github:createPrPermissions != READ && github:createPrPermissions", + "group": "navigation@1" + }, + { + "command": "pr.addReviewersToNewPr", + "when": "view == github:createPullRequestWebview && github:createPrPermissions != READ && github:createPrPermissions", + "group": "navigation@2" + }, { "command": "pr.addLabelsToNewPr", - "when": "view == github:createPullRequest && github:createPrPermissions != READ", + "when": "view == github:createPullRequestWebview && github:createPrPermissions != READ && github:createPrPermissions", + "group": "navigation@3" + }, + { + "command": "pr.addMilestoneToNewPr", + "when": "view == github:createPullRequestWebview && github:createPrPermissions != READ && github:createPrPermissions", + "group": "navigation@4" + }, + { + "command": "pr.addProjectsToNewPr", + "when": "view == github:createPullRequestWebview && github:createPrPermissions != READ && github:createPrPermissions", + "group": "navigation@5" + }, + { + "command": "pr.refreshComments", + "when": "view == workbench.panel.comments", + "group": "navigation" + }, + { + "command": "notifications.sortByTimestamp", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == notifications:github", + "group": "sortNotifications@1" + }, + { + "command": "notifications.sortByPriority", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == notifications:github", + "group": "sortNotifications@2" + }, + { + "command": "notifications.configureNotificationsViewlet", + "when": "view == notifications:github", + "group": "sortNotifications@3" + }, + { + "command": "notifications.markPullRequestsAsRead", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == notifications:github && config.githubPullRequests.experimental.notificationsMarkPullRequests == markAsRead", + "group": "navigation@0" + }, + { + "command": "notifications.markPullRequestsAsDone", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == notifications:github && config.githubPullRequests.experimental.notificationsMarkPullRequests == markAsDone", + "group": "navigation@0" + }, + { + "command": "notifications.refresh", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == notifications:github", "group": "navigation@1" } ], "view/item/context": [ { "command": "pr.pick", - "when": "view == pr:github && viewItem =~ /pullrequest(:local)?:nonactive|description/", - "group": "pullrequest@1" + "when": "view == pr:github && viewItem =~ /(pullrequest(:local)?:nonactive)/", + "group": "1_pullrequest@1" }, { - "command": "pr.pick", - "when": "view == pr:github && viewItem =~ /description/", - "group": "inline" + "command": "pr.exit", + "when": "view == pr:github && viewItem =~ /pullrequest(:local)?:active/", + "group": "1_pullrequest@1" }, { - "command": "pr.exit", - "when": "view == pr:github && viewItem =~ /pullrequest(:local)?:active|description/", - "group": "pullrequest@1" + "command": "pr.pickOnVscodeDev", + "when": "view == pr:github && viewItem =~ /pullrequest(:local)?:nonactive/ && (!isWeb || remoteName != codespaces && virtualWorkspace != vscode-vfs)", + "group": "1_pullrequest@2" }, { - "command": "pr.refreshPullRequest", - "when": "view == pr:github && viewItem =~ /pullrequest|description/", - "group": "pullrequest@2" + "command": "pr.pickOnCodespaces", + "when": "view == pr:github && viewItem =~ /pullrequest(:local)?:nonactive/ && (!isWeb || remoteName != codespaces && virtualWorkspace != vscode-vfs)", + "group": "1_pullrequest@3" + }, + { + "command": "pr.openChanges", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /(pullrequest|description)/", + "group": "2_pullrequest@1" + }, + { + "command": "pr.openDescriptionToTheSide", + "group": "2_pullrequest@2", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /(pullrequest|description)/" }, { "command": "pr.openPullRequestOnGitHub", - "when": "view == pr:github && viewItem =~ /pullrequest|description/", - "group": "pullrequest@3" + "when": "view == pr:github && viewItem =~ /pullrequest/", + "group": "2_pullrequest@3" + }, + { + "command": "pr.refreshPullRequest", + "when": "view == pr:github && viewItem =~ /pullrequest/", + "group": "3_pullrequest@1" }, { "command": "pr.deleteLocalBranch", "when": "view == pr:github && viewItem =~ /pullrequest:local:nonactive/", - "group": "pullrequest@4" + "group": "4_pullrequest@4" }, { "command": "pr.dismissNotification", "when": "view == pr:github && viewItem =~ /pullrequest(.*):notification/", - "group": "pullrequest@5" + "group": "4_pullrequest@5" }, { - "command": "pr.openFileOnGitHub", - "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /filechange/" + "command": "pr.markAllCopilotNotificationsAsRead", + "when": "view == pr:github && viewItem =~ /copilot-query/", + "group": "0_category@1" }, { - "command": "pr.copyCommitHash", - "when": "view == prStatus:github && viewItem =~ /commit/" + "command": "issue.chatSummarizeIssue", + "when": "view == pr:github && viewItem =~ /pullrequest/ && github.copilot-chat.activated && config.githubPullRequests.experimental.chat && !config.chat.disableAIFeatures", + "group": "5_pullrequest@2" }, { - "command": "pr.openDescriptionToTheSide", - "group": "inline", - "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /description/" + "command": "pr.pick", + "when": "view == pr:github && viewItem =~ /(pullrequest(:local)?:nonactive)/", + "group": "inline@1" + }, + { + "command": "pr.openChanges", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /(pullrequest|description)/", + "group": "inline@0" }, { "command": "pr.showDiffSinceLastReview", - "group": "inline", - "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /description:changesSinceReview:inactive/" + "group": "inline@1", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /(pullrequest|description):(active|nonactive):hasChangesSinceReview:showingAllChanges/" }, { "command": "pr.showDiffAll", - "group": "inline", - "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /description:changesSinceReview:active/" + "group": "inline@1", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /(pullrequest|description):(active|nonactive):hasChangesSinceReview:showingChangesSinceReview/" + }, + { + "command": "notification.chatSummarizeNotification", + "group": "issues_0@0", + "when": "view == notifications:github && (viewItem == 'Issue' || viewItem == 'PullRequest') && config.githubPullRequests.experimental.notificationsView && config.githubPullRequests.experimental.chat && !config.chat.disableAIFeatures" + }, + { + "command": "notification.openOnGitHub", + "group": "issues_0@1", + "when": "view == notifications:github && (viewItem == 'Issue' || viewItem == 'PullRequest') && config.githubPullRequests.experimental.notificationsView" + }, + { + "command": "notification.markAsRead", + "group": "inline@3", + "when": "view == notifications:github && (viewItem == 'Issue' || viewItem == 'PullRequest') && config.githubPullRequests.experimental.notificationsView" + }, + { + "command": "notification.markAsRead", + "group": "issues_0@2", + "when": "view == notifications:github && (viewItem == 'Issue' || viewItem == 'PullRequest') && config.githubPullRequests.experimental.notificationsView" + }, + { + "command": "notification.markAsDone", + "group": "inline@4", + "when": "view == notifications:github && (viewItem == 'Issue' || viewItem == 'PullRequest') && config.githubPullRequests.experimental.notificationsView" + }, + { + "command": "notification.markAsDone", + "group": "issues_0@3", + "when": "view == notifications:github && (viewItem == 'Issue' || viewItem == 'PullRequest') && config.githubPullRequests.experimental.notificationsView" + }, + { + "command": "pr.openPullRequestOnGitHub", + "group": "inline@3", + "when": "view == prStatus:github && viewItem =~ /description/ && github:activePRCount >= 2" + }, + { + "command": "pr.copyCommitHash", + "when": "view == prStatus:github && viewItem =~ /commit/" }, { "command": "review.openFile", "group": "inline@0", - "when": "openDiffOnClick && view == prStatus:github && viewItem =~ /filechange(?!:DELETE)/" + "when": "openDiffOnClick && showInlineOpenFileAction && view == prStatus:github && viewItem =~ /filechange(?!:DELETE)/" }, { "command": "pr.openDiffView", "group": "inline@0", - "when": "!openDiffOnClick && view == prStatus:github && viewItem =~ /filechange(?!:DELETE)/" + "when": "!openDiffOnClick && showInlineOpenFileAction && view == prStatus:github && viewItem =~ /filechange(?!:DELETE)/" + }, + { + "command": "pr.openFileOnGitHub", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /filechange/", + "group": "0_open@0" + }, + { + "command": "pr.revealFileInOS", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /filechange(?!:ADD)/", + "group": "0_open@1" }, { "command": "pr.openOriginalFile", - "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /filechange:MODIFY/" + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /filechange:MODIFY/", + "group": "0_open@2" }, { "command": "pr.openModifiedFile", - "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /filechange:MODIFY/" + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /filechange:MODIFY/", + "group": "0_open@3" + }, + { + "command": "review.diffWithPrHead", + "group": "1_diff@0", + "when": "openDiffOnClick && view == prStatus:github && viewItem =~ /filechange(?!:DELETE)/" + }, + { + "command": "review.diffLocalWithPrHead", + "group": "1_diff@1", + "when": "openDiffOnClick && view == prStatus:github && viewItem =~ /filechange(?!:DELETE)/" }, { "command": "pr.editQuery", @@ -1636,11 +2974,6 @@ "command": "pr.editQuery", "when": "view == pr:github && viewItem == query" }, - { - "command": "issue.openIssue", - "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/", - "group": "inline@2" - }, { "command": "issue.openIssue", "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/", @@ -1654,22 +2987,22 @@ { "command": "issue.startWorking", "when": "view == issues:github && viewItem =~ /^(link)?issue/ && config.githubIssues.useBranchForIssues != on", - "group": "inline@1" + "group": "inline@2" }, { "command": "issue.startWorkingBranchDescriptiveTitle", "when": "view == issues:github && viewItem =~ /^(link)?issue/ && config.githubIssues.useBranchForIssues == on", - "group": "inline@1" + "group": "inline@2" }, { "command": "issue.startWorking", "when": "view == issues:github && viewItem =~ /^(link)?continueissue/ && config.githubIssues.useBranchForIssues != on", - "group": "inline@1" + "group": "inline@2" }, { "command": "issue.startWorkingBranchDescriptiveTitle", "when": "view == issues:github && viewItem =~ /^(link)?continueissue/ && config.githubIssues.useBranchForIssues == on", - "group": "inline@1" + "group": "inline@2" }, { "command": "issue.startWorking", @@ -1702,15 +3035,30 @@ "when": "view == issues:github && viewItem =~ /^(link)?currentissue/ && config.githubIssues.useBranchForIssues == on", "group": "inline@1" }, + { + "command": "issue.chatSummarizeIssue", + "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/ && github.copilot-chat.activated && config.githubPullRequests.experimental.chat && !config.chat.disableAIFeatures", + "group": "issues_1@0" + }, + { + "command": "issue.chatSuggestFix", + "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/ && github.copilot-chat.activated && config.githubPullRequests.experimental.chat && !config.chat.disableAIFeatures", + "group": "issues_1@1" + }, + { + "command": "issue.assignToCodingAgent", + "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/ && config.githubPullRequests.codingAgent.enabled && !config.chat.disableAIFeatures", + "group": "issues_1@2" + }, { "command": "issue.copyIssueNumber", "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/", - "group": "issues_1@1" + "group": "issues_2@1" }, { "command": "issue.copyIssueUrl", "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/", - "group": "issues_1@2" + "group": "issues_2@2" }, { "command": "issue.editQuery", @@ -1722,6 +3070,43 @@ "when": "view == issues:github && viewItem == query" } ], + "commentsView/commentThread/context": [ + { + "command": "pr.diffOutdatedCommentWithHead", + "group": "inline@0", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /outdated/" + }, + { + "command": "pr.resolveReviewThread", + "group": "inline@1", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canResolve/" + }, + { + "command": "pr.unresolveReviewThreadFromView", + "group": "inline@1", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canUnresolve/" + }, + { + "command": "pr.diffOutdatedCommentWithHead", + "group": "context@0", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /outdated/" + }, + { + "command": "pr.resolveReviewThread", + "group": "context@1", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canResolve/" + }, + { + "command": "pr.unresolveReviewThreadFromView", + "group": "context@1", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canUnresolve/" + }, + { + "command": "pr.applySuggestionWithCopilot", + "group": "context@2", + "when": "commentController =~ /^github-review/ && !config.chat.disableAIFeatures" + } + ], "editor/title": [ { "command": "review.openFile", @@ -1747,151 +3132,174 @@ "command": "pr.unmarkFileAsViewed", "group": "navigation", "when": "resourceScheme != pr && resourceScheme != review && resourceScheme != filechange && resourcePath in github:viewedFiles" + }, + { + "command": "pr.openDiffViewFromEditor", + "group": "navigation", + "when": "!isInDiffEditor && resourceScheme != pr && resourceScheme != review && resourceScheme != filechange && resourcePath in github:unviewedFiles" + }, + { + "command": "pr.openDiffViewFromEditor", + "group": "navigation", + "when": "!isInDiffEditor && resourceScheme != pr && resourceScheme != review && resourceScheme != filechange && resourcePath in github:viewedFiles" + }, + { + "command": "pr.addFileComment", + "group": "navigation", + "when": "(resourceScheme == pr) || (resourcePath in github:viewedFiles) || (resourcePath in github:unviewedFiles)" } ], - "scm/title": [ + "editor/content": [ { - "command": "review.suggestDiff", - "when": "scmProvider =~ /^git|^remoteHub:github/ && scmProviderRootUri in github:reposInReviewMode", - "group": "inline" - }, + "command": "pr.acceptMerge", + "when": "isMergeResultEditor && mergeEditorBaseUri =~ /^(githubpr|gitpr):/" + } + ], + "scm/title": [ { "command": "pr.create", "when": "scmProvider =~ /^git|^remoteHub:github/ && scmProviderRootUri in github:reposNotInReviewMode", "group": "navigation" } ], - "comments/commentThread/context": [ + "scm/resourceGroup/context": [ { - "command": "pr.createComment", - "group": "inline@1", - "when": "commentController =~ /^github-browse/ && prInDraft" - }, + "command": "review.createSuggestionsFromChanges", + "when": "scmProviderRootUri in github:reposInReviewMode && scmProvider =~ /^git|^remoteHub:github/ && scmResourceGroup == workingTree", + "group": "inline@-2" + } + ], + "scm/resourceState/context": [ { - "command": "pr.createComment", - "group": "inline@1", - "when": "commentController =~ /^github-review/ && reviewInDraftMode" - }, + "command": "review.createSuggestionsFromChanges", + "when": "scmProviderRootUri in github:reposInReviewMode && scmProvider =~ /^git|^remoteHub:github/ && scmResourceGroup == workingTree", + "group": "1_modification@5" + } + ], + "scm/repository": [ { - "command": "pr.createSingleComment", - "group": "inline@1", - "when": "commentController =~ /^github-browse/ && !prInDraft && config.githubPullRequests.defaultCommentType != review" - }, + "command": "pr.create", + "when": "scmProvider =~ /^git|^remoteHub:github/ && scmProviderRootUri in github:reposNotInReviewMode", + "group": "inline" + } + ], + "comments/commentThread/context": [ { - "command": "pr.startReview", + "command": "pr.createComment", "group": "inline@1", - "when": "commentController =~ /^github-browse/ && !prInDraft && config.githubPullRequests.defaultCommentType == review" + "when": "(commentController =~ /^github-browse/ && prInDraft) || (commentController =~ /^github-review/ && reviewInDraftMode)" }, { "command": "pr.createSingleComment", - "group": "inline@1", - "when": "commentController =~ /^github-review/ && !reviewInDraftMode && config.githubPullRequests.defaultCommentType != review" - }, - { - "command": "pr.startReview", - "group": "inline@1", - "when": "commentController =~ /^github-review/ && !reviewInDraftMode && config.githubPullRequests.defaultCommentType == review" - }, - { - "command": "pr.startReview", - "group": "inline@2", - "when": "commentController =~ /^github-browse/ && !prInDraft && config.githubPullRequests.defaultCommentType != review" + "group": "inline@1", + "when": "config.githubPullRequests.defaultCommentType != review && ((commentController =~ /^github-browse/ && !prInDraft) || (commentController =~ /^github-review/ && !reviewInDraftMode))" }, { - "command": "pr.createSingleComment", - "group": "inline@2", - "when": "commentController =~ /^github-browse/ && !prInDraft && config.githubPullRequests.defaultCommentType == review" + "command": "pr.startReview", + "group": "inline@1", + "when": "config.githubPullRequests.defaultCommentType == review && ((commentController =~ /^github-browse/ && !prInDraft) || (commentController =~ /^github-review/ && !reviewInDraftMode))" }, { "command": "pr.startReview", "group": "inline@2", - "when": "commentController =~ /^github-review/ && !reviewInDraftMode && config.githubPullRequests.defaultCommentType != review" + "when": "config.githubPullRequests.defaultCommentType != review && ((commentController =~ /^github-browse/ && !prInDraft) || (commentController =~ /^github-review/ && !reviewInDraftMode))" }, { "command": "pr.createSingleComment", "group": "inline@2", - "when": "commentController =~ /^github-review/ && !reviewInDraftMode && config.githubPullRequests.defaultCommentType == review" + "when": "config.githubPullRequests.defaultCommentType == review && ((commentController =~ /^github-browse/ && !prInDraft) || commentController =~ /^github-review/ && !reviewInDraftMode)" } ], "comments/comment/editorActions": [ { "command": "pr.makeSuggestion", "group": "inline@3", - "when": "commentController =~ /^github-(browse|review)/" + "when": "commentController =~ /^github-(browse|review)/ && !github:activeCommentHasSuggestion" } ], "comments/commentThread/additionalActions": [ { "command": "pr.resolveReviewThread", "group": "inline@1", - "when": "commentController =~ /^github-(browse|review)/ && commentThread == canResolve" + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canResolve/" }, { "command": "pr.unresolveReviewThread", "group": "inline@1", - "when": "commentController =~ /^github-(browse|review)/ && commentThread == canUnresolve" - }, - { - "command": "pr.openReview", - "group": "inline@2", - "when": "commentController =~ /^github-browse/ && prInDraft" + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canUnresolve/" }, { "command": "pr.openReview", "group": "inline@2", - "when": "commentController =~ /^github-review/ && reviewInDraftMode" + "when": "(commentController =~ /^github-browse/ && prInDraft) || (commentController =~ /^github-review/ && reviewInDraftMode)" } ], "comments/commentThread/title/context": [ { "command": "pr.resolveReviewThread", "group": "inline@3", - "when": "commentController =~ /^github-(browse|review)/ && commentThread == canResolve" + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canResolve/" }, { "command": "pr.unresolveReviewThread", "group": "inline@3", - "when": "commentController =~ /^github-(browse|review)/ && commentThread == canUnresolve" + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canUnresolve/" } ], "comments/commentThread/comment/context": [ { "command": "pr.resolveReviewThread", - "group": "inline@3", - "when": "commentController =~ /^github-(browse|review)/ && commentThread == canResolve" + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canResolve/" }, { "command": "pr.unresolveReviewThread", - "group": "inline@3", - "when": "commentController =~ /^github-(browse|review)/ && commentThread == canUnresolve" + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canUnresolve/" + }, + { + "command": "pr.applySuggestion", + "when": "commentController =~ /^github-review/ && comment =~ /hasSuggestion/" + }, + { + "command": "pr.applySuggestionWithCopilot", + "when": "commentController =~ /^github-review/ && !config.chat.disableAIFeatures" } ], "comments/comment/title": [ - { - "command": "pr.copyCommentLink", - "group": "inline@1", - "when": "commentController =~ /^github-(browse|review)/ && comment =~ /canEdit/" - }, { "command": "pr.applySuggestion", "group": "inline@0", "when": "commentController =~ /^github-review/ && comment =~ /hasSuggestion/" }, + { + "command": "pr.applySuggestionWithCopilot", + "group": "overflow@0", + "when": "commentController =~ /^github-review/ && !config.chat.disableAIFeatures" + }, { "command": "pr.editComment", - "group": "inline@2", + "group": "overflow@1", "when": "commentController =~ /^github-(browse|review)/ && comment =~ /canEdit/" }, { "command": "pr.deleteComment", - "group": "inline@3", + "group": "overflow@2", "when": "commentController =~ /^github-(browse|review)/ && comment =~ /canDelete/" + }, + { + "command": "pr.copyCommentLink", + "group": "overflow@3", + "when": "commentController =~ /^github-(browse|review)/ && comment =~ /canEdit/" } ], "comments/commentThread/title": [ + { + "command": "pr.refreshComments", + "group": "0_refresh@0", + "when": "commentController =~ /^github-(browse|review)/" + }, { "command": "pr.collapseAllComments", - "group": "collapse@0", + "group": "1_collapse@0", "when": "commentController =~ /^github-(browse|review)/" } ], @@ -1939,6 +3347,18 @@ "command": "issue.copyGithubHeadLink", "when": "github:hasGitHubRemotes", "group": "1_githubPullRequests@2" + }, + { + "command": "issue.copyGithubDevLink", + "when": "github:hasGitHubRemotes && remoteName == codespaces && isWeb || github:hasGitHubRemotes && embedderIdentifier == github.dev", + "group": "0_vscode@0" + } + ], + "editor/context": [ + { + "command": "review.createSuggestionFromChange", + "when": "activeEditor == workbench.editors.textDiffEditor && (resourcePath in github:unviewedFiles || resourcePath in github:viewedFiles)", + "group": "2_git@6" } ], "file/share": [ @@ -1949,7 +3369,7 @@ }, { "command": "pr.copyVscodeDevPrLink", - "when": "github:hasGitHubRemotes && github:inReviewMode", + "when": "github:hasGitHubRemotes && github:inReviewMode && remoteName != codespaces && embedderIdentifier != github.dev", "group": "1_githubPullRequests@1" }, { @@ -1961,30 +3381,84 @@ "command": "issue.copyGithubHeadLink", "when": "github:hasGitHubRemotes", "group": "1_githubPullRequests@3" + }, + { + "command": "issue.copyGithubDevLinkFile", + "when": "github:hasGitHubRemotes && remoteName == codespaces && isWeb || github:hasGitHubRemotes && embedderIdentifier == github.dev", + "group": "0_vscode@0" } ], - "editor/title/context": [ + "editor/lineNumber/context": [ { "command": "issue.copyGithubPermalink", - "when": "github:hasGitHubRemotes", - "group": "1_cutcopypaste@10" + "when": "github:hasGitHubRemotes && activeEditor == workbench.editors.files.textFileEditor && config.editor.lineNumbers == on", + "group": "1_cutcopypaste@3" + }, + { + "command": "issue.copyMarkdownGithubPermalink", + "when": "github:hasGitHubRemotes && activeEditor == workbench.editors.files.textFileEditor && config.editor.lineNumbers == on", + "group": "1_cutcopypaste@4" }, { "command": "issue.copyGithubHeadLink", + "when": "github:hasGitHubRemotes && activeEditor == workbench.editors.files.textFileEditor && config.editor.lineNumbers == on", + "group": "1_cutcopypaste@5" + }, + { + "command": "issue.copyGithubDevLink", + "when": "github:hasGitHubRemotes && remoteName == codespaces && isWeb || github:hasGitHubRemotes && embedderIdentifier == github.dev", + "group": "1_cutcopypaste@0" + } + ], + "editor/title/context": [ + { + "command": "pr.closeRelatedEditors", + "when": "resourceScheme == 'pr' || resourceScheme == 'review' || resourcePath in github:unviewedFiles || resourcePath in github:viewedFiles", + "group": "1_close@60" + } + ], + "editor/title/context/share": [ + { + "command": "issue.copyGithubPermalinkWithoutRange", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@10" + }, + { + "command": "issue.copyMarkdownGithubPermalinkWithoutRange", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@11" + }, + { + "command": "issue.copyGithubHeadLinkWithoutRange", "when": "github:hasGitHubRemotes", - "group": "1_cutcopypaste@11" + "group": "1_githubPullRequests@12" + }, + { + "command": "issue.copyGithubDevLinkWithoutRange", + "when": "github:hasGitHubRemotes && remoteName == codespaces && isWeb || github:hasGitHubRemotes && embedderIdentifier == github.dev", + "group": "0_vscode@0" } ], - "explorer/context": [ + "explorer/context/share": [ { - "command": "issue.copyGithubPermalink", + "command": "issue.copyGithubPermalinkWithoutRange", "when": "github:hasGitHubRemotes", - "group": "5_cutcopypaste@10" + "group": "5_githubPulLRequests@10" }, { - "command": "issue.copyGithubHeadLink", + "command": "issue.copyMarkdownGithubPermalinkWithoutRange", + "when": "github:hasGitHubRemotes", + "group": "5_githubPulLRequests@11" + }, + { + "command": "issue.copyGithubHeadLinkWithoutRange", "when": "github:hasGitHubRemotes", - "group": "5_cutcopypaste@11" + "group": "5_githubPulLRequests@12" + }, + { + "command": "issue.copyGithubDevLinkWithoutRange", + "when": "github:hasGitHubRemotes && remoteName == codespaces && isWeb || github:hasGitHubRemotes && embedderIdentifier == github.dev", + "group": "0_vscode@0" } ], "menuBar/edit/copy": [ @@ -2003,6 +3477,151 @@ "when": "scmProvider =~ /^remoteHub:github/", "group": "1_modification@0" } + ], + "webview/context": [ + { + "command": "pr.createPrMenuCreate", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu", + "group": "0_create@0" + }, + { + "command": "pr.createPrMenuDraft", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu && github:createPrMenuDraft", + "group": "0_create@1" + }, + { + "command": "pr.createPrMenuMergeWhenReady", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu && github:createPrMenuMergeWhenReady", + "group": "1_create@0" + }, + { + "command": "pr.createPrMenuMerge", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu && github:createPrMenuMerge", + "group": "1_create@0" + }, + { + "command": "pr.createPrMenuSquash", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu && github:createPrMenuSquash", + "group": "1_create@1" + }, + { + "command": "pr.createPrMenuRebase", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu && github:createPrMenuRebase", + "group": "1_create@2" + }, + { + "command": "review.comment", + "when": "webviewId == 'github:activePullRequest' && github:reviewCommentMenu && github:reviewCommentComment", + "group": "1_review@1" + }, + { + "command": "review.approve", + "when": "webviewId == 'github:activePullRequest' && github:reviewCommentMenu && github:reviewCommentApprove", + "group": "1_review@2" + }, + { + "command": "review.requestChanges", + "when": "webviewId == 'github:activePullRequest' && github:reviewCommentMenu && github:reviewCommentRequestChanges", + "group": "1_review@3" + }, + { + "command": "review.approveOnDotCom", + "when": "webviewId == 'github:activePullRequest' && github:reviewCommentMenu && github:reviewCommentApproveOnDotCom" + }, + { + "command": "review.requestChangesOnDotCom", + "when": "webviewId == 'github:activePullRequest' && github:reviewCommentMenu && github:reviewCommentRequestChangesOnDotCom" + }, + { + "command": "review.commentDescription", + "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentComment", + "group": "1_review@1" + }, + { + "command": "review.approveDescription", + "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentApprove", + "group": "1_review@2" + }, + { + "command": "review.requestChangesDescription", + "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentRequestChanges", + "group": "1_review@3" + }, + { + "command": "review.approveOnDotComDescription", + "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentApproveOnDotCom" + }, + { + "command": "review.requestChangesOnDotComDescription", + "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentRequestChangesOnDotCom" + }, + { + "command": "pr.copyPrLink", + "when": "(webviewId == PullRequestOverview || webviewId == IssueOverview) && github:copyMenu" + }, + { + "command": "pr.copyVscodeDevPrLink", + "when": "webviewId == PullRequestOverview && github:copyMenu" + }, + { + "command": "pr.readyForReviewDescription", + "when": "(webviewId == PullRequestOverview) && github:readyForReviewMenu" + }, + { + "command": "pr.readyForReviewAndMergeDescription", + "when": "(webviewId == PullRequestOverview) && github:readyForReviewMenu && github:readyForReviewMenuWithMerge" + }, + { + "command": "pr.readyForReview", + "when": "(webviewId == 'github:activePullRequest') && github:readyForReviewMenu" + }, + { + "command": "pr.readyForReviewAndMerge", + "when": "(webviewId == 'github:activePullRequest') && github:readyForReviewMenu && github:readyForReviewMenuWithMerge" + }, + { + "command": "pr.openChanges", + "group": "checkout@0", + "when": "webviewId == PullRequestOverview && github:checkoutMenu" + }, + { + "command": "pr.checkoutOnVscodeDevFromDescription", + "group": "checkout@1", + "when": "webviewId == PullRequestOverview && github:checkoutMenu" + }, + { + "command": "pr.checkoutOnCodespacesFromDescription", + "group": "checkout@2", + "when": "webviewId == PullRequestOverview && github:checkoutMenu" + }, + { + "command": "pr.openSessionLogFromDescription", + "when": "webviewId == PullRequestOverview && github:codingAgentMenu" + } + ], + "chat/chatSessions": [ + { + "command": "pr.openChanges", + "when": "chatSessionType == copilot-cloud-agent && !config.chat.disableAIFeatures", + "group": "inline" + }, + { + "command": "pr.checkoutChatSessionPullRequest", + "when": "chatSessionType == copilot-cloud-agent && !config.chat.disableAIFeatures", + "group": "context" + } + ], + "chat/input/editing/sessionToolbar": [ + { + "command": "pr.checkoutFromDescription", + "group": "navigation@0", + "when": "chatSessionType == copilot-cloud-agent && workspaceFolderCount > 0 && github.vscode-pull-request-github.activated && !config.chat.disableAIFeatures" + }, + { + "command": "pr.applyChangesFromDescription", + "group": "navigation@1", + "when": "chatSessionType == copilot-cloud-agent && workspaceFolderCount > 0 && github.vscode-pull-request-github.activated && !config.chat.disableAIFeatures" + } ] }, "colors": [ @@ -2029,13 +3648,23 @@ { "id": "issues.closed", "defaults": { - "dark": "#cb2431", - "light": "#cb2431", + "dark": "pullRequests.merged", + "light": "pullRequests.merged", "highContrast": "editor.foreground", "highContrastLight": "editor.foreground" }, "description": "The color used for indicating that an issue is closed." }, + { + "id": "github.issues.closed", + "defaults": { + "dark": "pullRequests.merged", + "light": "pullRequests.merged", + "highContrast": "editor.foreground", + "highContrastLight": "editor.foreground" + }, + "description": "The color used for indicating that an issue is closed. Duplicates issues.closed because there's another extension that uses this identifier." + }, { "id": "pullRequests.merged", "defaults": { @@ -2069,8 +3698,8 @@ { "id": "pullRequests.closed", "defaults": { - "dark": "issues.closed", - "light": "issues.closed", + "dark": "#cb2431", + "light": "#cb2431", "highContrast": "editor.background", "highContrastLight": "editor.background" }, @@ -2097,6 +3726,559 @@ "stripPathStartingSeparator": true } } + ], + "languageModelTools": [ + { + "name": "github-pull-request_issue_fetch", + "tags": [ + "github", + "issues", + "prs" + ], + "toolReferenceName": "issue_fetch", + "displayName": "%languageModelTools.github-pull-request_issue_fetch.displayName%", + "modelDescription": "Get a GitHub issue/PR's details as a JSON object.", + "icon": "$(info)", + "canBeReferencedInPrompt": true, + "inputSchema": { + "type": "object", + "properties": { + "repo": { + "type": "object", + "description": "The repository to get the issue/PR from.", + "properties": { + "owner": { + "type": "string", + "description": "The owner of the repository to get the issue/PR from." + }, + "name": { + "type": "string", + "description": "The name of the repository to get the issue/PR from." + } + }, + "required": [ + "owner", + "name" + ] + }, + "issueNumber": { + "type": "number", + "description": "The number of the issue/PR to get." + } + }, + "required": [ + "issueNumber" + ] + }, + "when": "config.githubPullRequests.experimental.chat" + }, + { + "name": "github-pull-request_notification_fetch", + "tags": [ + "github", + "notification" + ], + "toolReferenceName": "notification_fetch", + "displayName": "%languageModelTools.github-pull-request_notification_fetch.displayName%", + "modelDescription": "Get a GitHub notification's details as a JSON object.", + "icon": "$(info)", + "canBeReferencedInPrompt": false, + "inputSchema": { + "type": "object", + "properties": { + "thread_id": { + "type": "string", + "description": "The notification thread id." + } + }, + "required": [ + "thread_id" + ] + }, + "when": "config.githubPullRequests.experimental.chat" + }, + { + "name": "github-pull-request_issue_summarize", + "tags": [ + "github", + "issues", + "prs" + ], + "toolReferenceName": "issue_summarize", + "displayName": "%languageModelTools.github-pull-request_issue_summarize.displayName%", + "modelDescription": "Summarizes a GitHub issue or pull request. A summary is a great way to describe an issue or pull request.", + "icon": "$(info)", + "canBeReferencedInPrompt": false, + "inputSchema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "The title of the issue/PR" + }, + "body": { + "type": "string", + "description": "The body of the issue/PR" + }, + "owner": { + "type": "string", + "description": "The owner of the repo in which the issue/PR is located" + }, + "repo": { + "type": "string", + "description": "The repo in which the issue/PR is located" + }, + "comments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "body": { + "type": "string", + "description": "The comment body" + }, + "author": { + "type": "string", + "description": "The author of the comment" + } + } + }, + "description": "The array of associated string comments" + }, + "fileChanges": { + "type": "array", + "items": { + "type": "object", + "properties": { + "fileName": { + "type": "string", + "description": "The name of the file of the change" + }, + "patch": { + "type": "string", + "description": "The patch of the change" + } + } + }, + "description": "For a PR, the array of associated file changes" + } + }, + "required": [ + "title", + "body", + "comments", + "owner", + "repo" + ] + }, + "when": "config.githubPullRequests.experimental.chat" + }, + { + "name": "github-pull-request_notification_summarize", + "tags": [ + "github", + "notification" + ], + "toolReferenceName": "notification_summarize", + "displayName": "%languageModelTools.github-pull-request_notification_summarize.displayName%", + "modelDescription": "Summarizes a GitHub notification. A summary is a great way to describe a notification.", + "icon": "$(info)", + "canBeReferencedInPrompt": false, + "inputSchema": { + "type": "object", + "properties": { + "lastReadAt": { + "type": "string", + "description": "The last read time of the notification." + }, + "lastUpdatedAt": { + "type": "string", + "description": "The last updated time of the notification." + }, + "unread": { + "type": "boolean", + "description": "Whether the notification is unread." + }, + "title": { + "type": "string", + "description": "The title of the notification issue/PR" + }, + "body": { + "type": "string", + "description": "The body of the notification issue/PR" + }, + "owner": { + "type": "string", + "description": "The owner of the repo in which the issue/PR is located" + }, + "repo": { + "type": "string", + "description": "The repo in which the issue/PR is located" + }, + "comments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "body": { + "type": "string", + "description": "The comment body" + }, + "author": { + "type": "string", + "description": "The author of the comment" + } + } + }, + "description": "The array of unread comments under the issue/PR of the notification" + }, + "threadId": { + "type": "number", + "description": "The thread id of the notification" + }, + "notificationKey": { + "type": "string", + "description": "The key of the notification" + }, + "itemNumber": { + "type": "string", + "description": "The number of the issue/PR in the notification" + }, + "itemType": { + "type": "string", + "description": "The type of the item in the notification - whether it is an issue or a PR" + }, + "fileChanges": { + "type": "array", + "items": { + "type": "object", + "properties": { + "fileName": { + "type": "string", + "description": "The name of the file of the change" + }, + "patch": { + "type": "string", + "description": "The patch of the change" + } + }, + "required": [ + "fileName", + "patch" + ] + }, + "description": "For a notification about a PR, the array of associated file changes" + } + }, + "required": [ + "title", + "comments", + "lastUpdatedAt", + "unread", + "threadId", + "notificationKey", + "owner", + "repo", + "itemNumber", + "itemType" + ] + }, + "when": "config.githubPullRequests.experimental.chat" + }, + { + "name": "github-pull-request_suggest-fix", + "tags": [ + "github", + "issues" + ], + "toolReferenceName": "suggest-fix", + "displayName": "%languageModelTools.github-pull-request_suggest-fix.displayName%", + "modelDescription": "Summarize and suggest a fix for a GitHub issue.", + "icon": "$(info)", + "canBeReferencedInPrompt": true, + "inputSchema": { + "type": "object", + "properties": { + "repo": { + "type": "object", + "description": "The repository to get the issue from.", + "properties": { + "owner": { + "type": "string", + "description": "The owner of the repository to get the issue from." + }, + "name": { + "type": "string", + "description": "The name of the repository to get the issue from." + } + }, + "required": [ + "owner", + "name" + ] + }, + "issueNumber": { + "type": "number", + "description": "The number of the issue to get." + } + }, + "required": [ + "issueNumber", + "repo" + ] + }, + "when": "config.githubPullRequests.experimental.chat" + }, + { + "name": "github-pull-request_formSearchQuery", + "tags": [ + "github", + "issues", + "search", + "query", + "natural language" + ], + "toolReferenceName": "searchSyntax", + "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": true, + "inputSchema": { + "type": "object", + "properties": { + "repo": { + "type": "object", + "description": "The repository to get the issue from.", + "properties": { + "owner": { + "type": "string", + "description": "The owner of the repository to get the issue from." + }, + "name": { + "type": "string", + "description": "The name of the repository to get the issue from." + } + }, + "required": [ + "owner", + "name" + ] + }, + "naturalLanguageString": { + "type": "string", + "description": "A plain text description of what the search should be." + } + }, + "required": [ + "naturalLanguageString" + ] + }, + "when": "config.githubPullRequests.experimental.chat" + }, + { + "name": "github-pull-request_doSearch", + "tags": [ + "github", + "issues", + "search" + ], + "toolReferenceName": "doSearch", + "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": true, + "inputSchema": { + "type": "object", + "properties": { + "repo": { + "type": "object", + "description": "The repository to get the issue from.", + "properties": { + "owner": { + "type": "string", + "description": "The owner of the repository to get the issue from." + }, + "name": { + "type": "string", + "description": "The name of the repository to get the issue from." + } + }, + "required": [ + "owner", + "name" + ] + }, + "query": { + "type": "string", + "description": "A well formed GitHub search query using proper GitHub search syntax." + } + }, + "required": [ + "query", + "repo" + ] + }, + "when": "config.githubPullRequests.experimental.chat" + }, + { + "name": "github-pull-request_renderIssues", + "tags": [ + "github", + "issues", + "render", + "display" + ], + "toolReferenceName": "renderIssues", + "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": true, + "inputSchema": { + "type": "object", + "properties": { + "arrayOfIssues": { + "type": "array", + "description": "An array of GitHub Issues.", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "The title of the issue." + }, + "number": { + "type": "number", + "description": "The number of the issue." + }, + "url": { + "type": "string", + "description": "The URL of the issue." + }, + "state": { + "type": "string", + "description": "The state of the issue (open/closed)." + }, + "createdAt": { + "type": "string", + "description": "The creation date of the issue." + }, + "updatedAt": { + "type": "string", + "description": "The last update date of the issue." + }, + "closedAt": { + "type": "string", + "description": "The closing date of the issue." + }, + "author": { + "type": "object", + "description": "The author of the issue.", + "properties": { + "login": { + "type": "string", + "description": "The login of the author." + }, + "url": { + "type": "string", + "description": "The URL of the author's profile." + } + } + }, + "labels": { + "type": "array", + "description": "The labels associated with the issue.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the label." + }, + "color": { + "type": "string", + "description": "The color of the label." + } + } + } + }, + "assignees": { + "type": "array", + "description": "The assignees of the issue.", + "items": { + "type": "object", + "properties": { + "login": { + "type": "string", + "description": "The login of the assignee." + }, + "url": { + "type": "string", + "description": "The URL of the assignee's profile." + } + } + } + }, + "commentCount": { + "type": "number", + "description": "The number of comments on the issue." + }, + "reactionCount": { + "type": "number", + "description": "The number of reactions on the issue." + } + }, + "required": [ + "title", + "number", + "url", + "state", + "createdAt", + "author", + "commentCount", + "reactionCount" + ] + } + }, + "totalIssues": { + "type": "number", + "description": "The total number of issues in the search." + } + }, + "required": [ + "arrayOfIssues", + "totalIssues" + ] + }, + "when": "config.githubPullRequests.experimental.chat" + }, + { + "name": "github-pull-request_activePullRequest", + "tags": [ + "github", + "pull request" + ], + "toolReferenceName": "activePullRequest", + "displayName": "%languageModelTools.github-pull-request_activePullRequest.displayName%", + "modelDescription": "Get comprehensive information about the active GitHub pull request (PR). The active PR is the one that is currently checked out. This includes the PR title, full description, list of changed files, review comments, PR state, and status checks/CI results. For PRs created by Copilot, it also includes the session logs which indicate the development process and decisions made by the coding agent. When asked about the active or current pull request, do this first! Use this tool for any request related to \"current changes,\" \"pull request details,\" \"what changed,\" \"PR status,\" or similar queries even if the user does not explicitly mention \"pull request.\" When asked to use this tool, ALWAYS use it.", + "icon": "$(git-pull-request)", + "canBeReferencedInPrompt": true, + "userDescription": "%languageModelTools.github-pull-request_activePullRequest.description%", + "when": "config.githubPullRequests.experimental.chat" + }, + { + "name": "github-pull-request_openPullRequest", + "tags": [ + "github", + "pull request" + ], + "toolReferenceName": "openPullRequest", + "displayName": "%languageModelTools.github-pull-request_openPullRequest.displayName%", + "modelDescription": "Get comprehensive information about the GitHub pull request (PR) which is currently visible, but not necessarily checked out. This is the pull request that the user is currently viewing. This includes the PR title, full description, list of changed files, review comments, PR state, and status checks/CI results. For PRs created by Copilot, it also includes the session logs which indicate the development process and decisions made by the coding agent. When asked about the currently open pull request, do this first! Use this tool for any request related to \"pull request details,\" \"what changed,\" \"PR status,\" or similar queries even if the user does not explicitly mention \"pull request.\" When asked to use this tool, ALWAYS use it.", + "icon": "$(git-pull-request)", + "canBeReferencedInPrompt": true, + "userDescription": "%languageModelTools.github-pull-request_openPullRequest.description%", + "when": "config.githubPullRequests.experimental.chat" + } ] }, "scripts": { @@ -2107,54 +4289,62 @@ "clean": "rm -r dist/", "compile": "webpack --mode development --env esbuild", "compile:test": "tsc -p tsconfig.test.json", + "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.json --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", - "pretty": "prettier --config .prettierrc --loglevel warn --write .", "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", + "test:preprocess": "yarn run compile:test && yarn run test:preprocess-gql && yarn run test:preprocess-svg && yarn run test:preprocess-fixtures", "browsertest:preprocess": "tsc ./src/test/browser/runTests.ts --outDir ./dist/browser/test --rootDir ./src/test/browser --target es6 --module commonjs", "browsertest": "yarn run browsertest:preprocess && node ./dist/browser/test/runTests.js", - "test:preprocess-gql": "node scripts/preprocess-gql --in src/github/queries.gql --out out/src/github/queries.gql", + "test:preprocess-gql": "node scripts/preprocess-gql --in src/github/queries.gql --out out/src/github/queries.gql && node scripts/preprocess-gql --in src/github/queriesExtra.gql --out out/src/github/queriesExtra.gql && node scripts/preprocess-gql --in src/github/queriesShared.gql --out out/src/github/queriesShared.gql && node scripts/preprocess-gql --in src/github/queriesLimited.gql --out out/src/github/queriesLimited.gql", "test:preprocess-svg": "node scripts/preprocess-svg --in ../resources/ --out out/resources", - "update-dts": "cd \"src/@types\" && npx vscode-dts main && npx vscode-dts dev", + "test:preprocess-fixtures": "node scripts/preprocess-fixtures --in src --out out", + "update-dts": "cd \"src/@types\" && node ../../node_modules/@vscode/dts/index.js main && node ../../node_modules/@vscode/dts/index.js dev", "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" + "check:commands": "node scripts/check-commands.js", + "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", + "@types/js-yaml": "^4.0.9", "@types/lru-cache": "^5.1.0", "@types/marked": "^0.7.2", "@types/mocha": "^8.2.2", - "@types/node": "12.12.70", + "@types/node": "22", "@types/react": "^16.8.4", "@types/react-dom": "^16.8.2", "@types/sinon": "7.0.11", "@types/temp": "0.8.34", - "@types/vscode": "1.69.0", + "@types/vscode": "1.103.0", "@types/webpack-env": "^1.16.0", - "@typescript-eslint/eslint-plugin": "4.18.0", - "@typescript-eslint/parser": "4.18.0", - "@vscode/test-electron": "^2.1.5", - "@vscode/test-web": "^0.0.29", + "@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", + "@vscode/test-web": "^0.0.71", "assert": "^2.0.0", "buffer": "^6.0.3", "constants-browserify": "^1.0.0", "crypto-browserify": "3.12.0", - "css-loader": "5.1.3", - "esbuild-loader": "2.10.0", - "eslint": "7.22.0", - "eslint-cli": "1.1.1", - "eslint-config-prettier": "8.1.0", - "eslint-plugin-import": "2.22.1", + "css-loader": "7.1.2", + "esbuild-loader": "4.2.2", + "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": "6.1.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", @@ -2171,46 +4361,59 @@ "os-browserify": "^0.3.0", "p-all": "^1.0.0", "path-browserify": "1.0.1", - "prettier": "2.2.1", "process": "^0.11.10", - "raw-loader": "4.0.2", "react-testing-library": "7.0.1", "sinon": "9.0.0", "source-map-support": "0.5.19", "stream-browserify": "^3.0.0", - "style-loader": "2.0.0", + "style-loader": "4.0.0", "svg-inline-loader": "^0.8.2", "temp": "0.9.4", "terser-webpack-plugin": "5.1.1", "timers-browserify": "^2.0.12", - "ts-loader": "8.0.18", + "ts-loader": "9.5.2", + "ts-node": "^10.9.2", "tty": "1.0.1", - "typescript": "4.2.3", + "typescript": "^5.9.2", + "typescript-eslint": "^8.44.0", "typescript-formatter": "^7.2.2", "vinyl-fs": "^3.0.3", - "webpack": "5.68.0", + "webpack": "5.94.0", "webpack-cli": "4.2.0" }, "dependencies": { - "@octokit/rest": "18.2.1", - "@octokit/types": "6.10.1", - "@vscode/extension-telemetry": "0.6.2", + "@joaomoreno/unique-names-generator": "^5.2.0", + "@octokit/rest": "22.0.0", + "@octokit/types": "14.1.0", + "@vscode/codicons": "^0.0.36", + "@vscode/extension-telemetry": "0.7.5", + "@vscode/prompt-tsx": "^0.3.0-alpha.12", "apollo-boost": "^0.4.9", "apollo-link-context": "1.0.20", + "cockatiel": "^3.1.1", "cross-fetch": "3.1.5", "dayjs": "1.10.4", + "debounce": "^1.2.1", "events": "3.2.0", "fast-deep-equal": "^3.1.3", + "js-yaml": "^4.1.1", + "jsonc-parser": "^3.3.1", "lru-cache": "6.0.0", + "markdown-it": "^14.1.0", "marked": "^4.0.10", "react": "^16.12.0", "react-dom": "^16.12.0", "ssh-config": "4.1.1", + "stream-http": "^3.2.0", + "temporal-polyfill": "^0.3.0", "tunnel": "0.0.6", "url-search-params-polyfill": "^8.1.1", "uuid": "8.3.2", - "vscode-tas-client": "^0.1.17", + "vscode-tas-client": "^0.1.84", "vsls": "^0.3.967" }, + "resolutions": { + "string_decoder": "^1.3.0" + }, "license": "MIT" } diff --git a/package.nls.json b/package.nls.json index 26e58baeb3..4e28e66278 100644 --- a/package.nls.json +++ b/package.nls.json @@ -1,246 +1,429 @@ -{ - "displayName": "GitHub Pull Requests and Issues", - "description": "Pull Request and Issue Provider for GitHub", - "githubPullRequests.pullRequestDescription.description": "The description used when creating pull requests.", - "githubPullRequests.pullRequestDescription.template": "Use a pull request template and commit description, or just use the commit description if no templates were found", - "githubPullRequests.pullRequestDescription.commit": "Use the latest commit message only", - "githubPullRequests.createDraft": "Whether the \"Draft\" checkbox will be checked by default when creating a pull request.", - "githubPullRequests.logLevel.description": "Logging for GitHub Pull Request extension. The log is emitted to the output channel named as GitHub Pull Request.", - "githubPullRequests.logLevel.markdownDeprecationMessage":{ - "message": "Log level is now controlled by the [Developer: Set Log Level...](command:workbench.action.setLogLevel) command. You can set the log level for the current session and also the default log level from there.", - "comment" : [ - "Do not translate what's inside of (...). It is link syntax.", - "{Locked='](command:workbench.action.setLogLevel)'}" - ] - }, - "githubPullRequests.remotes.markdownDescription": "List of remotes, by name, to fetch pull requests from.", - "githubPullRequests.queries.markdownDescription": "Specifies what queries should be used in the GitHub Pull Requests tree. All queries are made against **the currently opened repos**. Each query object has a `label` that will be shown in the tree and a search `query` using [GitHub search syntax](https://help.github.com/en/articles/understanding-the-search-syntax). The variable `${user}` can be used to specify the logged in user within a search. By default these queries define the categories \"Waiting For My Review\", \"Assigned To Me\" and \"Created By Me\". If you want to preserve these, make sure they are still in the array when you modify the setting.", - "githubPullRequests.queries.label.description": "The label to display for the query in the Pull Requests tree", - "githubPullRequests.queries.query.description": "The query used for searching pull requests.", - "githubPullRequests.queries.waitingForMyReview": "Waiting For My Review", - "githubPullRequests.queries.assignedToMe": "Assigned To Me", - "githubPullRequests.queries.createdByMe": "Created By Me", - "githubPullRequests.defaultMergeMethod.description": "The method to use when merging pull requests.", - "githubPullRequests.notifications.description": "If GitHub notifications should be shown to the user.", - "githubPullRequests.fileListLayout.description": "The layout to use when displaying changed files list.", - "githubPullRequests.defaultDeletionMethod.selectLocalBranch.description": "When true, the option to delete the local branch will be selected by default when deleting a branch from a pull request.", - "githubPullRequests.defaultDeletionMethod.selectRemote.description": "When true, the option to delete the remote will be selected by default when deleting a branch from a pull request.", - "githubPullRequests.terminalLinksHandler.description": "Default handler for terminal links.", - "githubPullRequests.terminalLinksHandler.github": "Create the pull request on GitHub", - "githubPullRequests.terminalLinksHandler.vscode": "Create the pull request in VS Code", - "githubPullRequests.terminalLinksHandler.ask": "Ask which method to use", - "githubPullRequests.createOnPublishBranch.description": "Create a pull request when a branch is published.", - "githubPullRequests.createOnPublishBranch.never": "Never create a pull request when a branch is published.", - "githubPullRequests.createOnPublishBranch.ask": "Ask if you want to create a pull request when a branch is published.", - "githubPullRequests.commentExpandState.description": "Controls whether comments are expanded when a document with comments is opened.", - "githubPullRequests.commentExpandState.expandUnresolved": "All unresolved comments will be expanded.", - "githubPullRequests.commentExpandState.collapseAll": "All comments will be collapsed", - "githubPullRequests.useReviewMode.description": "Choose which pull request states will use review mode. \"Open\" pull requests will always use review mode.", - "githubPullRequests.useReviewMode.merged": "Use review mode for merged pull requests.", - "githubPullRequests.useReviewMode.closed": "Use review mode for closed pull requests. Merged pull requests are not considered \"closed\".", - "githubPullRequests.assignCreated.description": { - "message": "All pull requests created with this extension will be assigned to this user. To assign to yourself, use the '${user}' variable.", - "comment": [ - "{Locked='${user}'}", - "Do not translate what's inside of the '${..}'. It is an internal syntax for the extension" - ] - }, - "githubPullRequests.pushBranch.description": "Push the \"from\" branch when creating a PR and the \"from\" branch is not available on the remote.", - "githubPullRequests.pushBranch.prompt": "Prompt to push the branch when creating a PR and the \"from\" branch is not available on the remote.", - "githubPullRequests.pushBranch.always": "Always push the branch when creating a PR and the \"from\" branch is not available on the remote.", - "githubPullRequests.pullBranch.description": "Pull changes from the remote when a PR branch is checked out locally. Changes are detected when the PR is manually refreshed and during periodic background updates.", - "githubPullRequests.pullBranch.prompt": "Prompt to pull a PR branch when changes are detected in the PR.", - "githubPullRequests.pullBranch.never": "Never pull a PR branch when changes are detected in the PR.", - "githubPullRequests.pullBranch.always": "Always pull a PR branch when changes are detected in the PR. When `\"git.autoStash\": true` this will instead `prompt` to prevent unexpected file changes.", - "githubPullRequests.ignoredPullRequestBranches.description": "Prevents branches that are associated with a pull request from being automatically detected. This will prevent review mode from being entered on these branches.", - "githubPullRequests.ignoredPullRequestBranches.items": "Branch name", - "githubPullRequests.overrideDefaultBranch.description": "The default branch for a repository is set on github.com. With this setting, you can override that default with another branch.", - "githubPullRequests.postCreate.description": "The action to take after creating a pull request.", - "githubPullRequests.postCreate.none": "No action", - "githubPullRequests.postCreate.openOverview": "Open the overview page of the pull request", - "githubPullRequests.postCreate.checkoutDefaultBranch": "Checkout the default branch of the repository", - "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", - "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.", - "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.", - "githubIssues.ignoreCompletionTrigger.description": "Languages that the '#' character should not be used to trigger issue completion suggestions.", - "githubIssues.ignoreCompletionTrigger.items": "Language that issue completions should not trigger on '#'.", - "githubIssues.ignoreUserCompletionTrigger.description": "Languages that the '@' character should not be used to trigger user completion suggestions.", - "githubIssues.ignoreUserCompletionTrigger.items": "Language that user completions should not trigger on '@'.", - "githubIssues.issueBranchTitle.markdownDescription": { - "message": "Advanced settings for the name of the branch that is created when you start working on an issue. \n- `${user}` will be replace with the currently logged in username \n- `${issueNumber}` will be replaced with the current issue number \n- `${sanitizedIssueTitle}` will be replaced with the issue title, with all spaces and unsupported characters (https://git-scm.com/docs/git-check-ref-format) removed", - "comment": [ - "{Locked='${...}'}", - "Do not translate what's inside of the '${..}'. It is an internal syntax for the extension" - ] - }, - "githubIssues.useBranchForIssues.description": { - "message": "Determines whether a branch should be checked out when working on an issue. To configure the name of the branch, set `githubIssues.issueBranchTitle`.", - "comment": [ - "{Locked='`githubIssues.issueBranchTitle`'}", - "Do not translate what's inside of the `...`. It is a setting id." - ] - }, - "githubIssues.useBranchForIssues.on": "A branch will always be checked out when you start working on an issue. If the branch doesn't exist, it will be created.", - "githubIssues.useBranchForIssues.off": "A branch will not be created when you start working on an issue. If you have worked on an issue before and a branch was created for it, that same branch will be checked out.", - "githubIssues.useBranchForIssues.prompt": "A prompt will show for setting the name of the branch that will be created and checked out.", - "githubIssues.issueCompletionFormatScm.markdownDescription": { - "message": "Sets the format of issue completions in the SCM inputbox. \n- `${user}` will be replace with the currently logged in username \n- `${issueNumber}` will be replaced with the current issue number \n- `${issueNumberLabel}` will be replaced with a label formatted as #number or owner/repository#number, depending on whether the issue is in the current repository", - "comment": [ - "Do not translate what's inside of the ${...}. It is an internal syntax for the extension." - ] - }, - "githubIssues.workingIssueFormatScm.markdownDescription": { - "message": "Sets the format of the commit message that is set in the SCM inputbox when you **Start Working on an Issue**. Defaults to `${issueTitle} \nFixes #${issueNumber}`", - "comment": [ - "Do not translate what's inside of the ${...}. It is an internal syntax for the extension." - ] - }, - "githubIssues.queries.markdownDescription": "Specifies what queries should be used in the GitHub issues tree using [GitHub search syntax](https://help.github.com/en/articles/understanding-the-search-syntax) with variables. The first query listed will be expanded in the Issues view. The \"default\" query includes issues assigned to you by Milestone. If you want to preserve these, make sure they are still in the array when you modify the setting.", - "githubIssues.queries.label": "The label to display for the query in the Issues tree.", - "githubIssues.queries.query": "The search query using [GitHub search syntax](https://help.github.com/en/articles/understanding-the-search-syntax) with variables. The variable `${user}` can be used to specify the logged in user within a search. `${owner}` and `${repository}` can be used to specify the repository by using `repo:${owner}/${repository}`.", - "githubIssues.queries.default.myIssues": "My Issues", - "githubIssues.queries.default.createdIssues": "Created Issues", - "githubIssues.queries.default.recentIssues": "Recent Issues", - "githubIssues.assignWhenWorking.description": "Assigns the issue you're working on to you. Only applies when the issue you're working on is in a repo you currently have open.", - "githubPullRequests.focusedMode.description": "The layout to use when a pull request is checked out. Set to false to prevent layout changes.", - "view.github.pull.request.name": "GitHub Pull Request", - "view.github.login.name": "Login", - "view.pr.github.name": "Pull Requests", - "view.issues.github.name": "Issues", - "view.github.create.pull.request.name": "Create Pull Request", - "view.github.compare.changes.name": "Compare Changes", - "view.pr.status.github.name": "Changes In Pull Request", - "view.github.active.pull.request.name": "Active Pull Request", - "view.github.active.pull.request.welcome.name": "Active Pull Request", - "command.pull.request.category": "GitHub Pull Requests", - "command.pr.create.title": "Create Pull Request", - "command.pr.pick.title": "Checkout Pull Request", - "command.pr.exit.title": "Checkout Default Branch", - "command.pr.dismissNotification.title": "Dismiss Notification", - "command.pr.merge.title": "Merge Pull Request", - "command.pr.readyForReview.title": "Mark Pull Request Ready For Review", - "command.pr.close.title": "Close Pull Request", - "command.pr.openPullRequestOnGitHub.title": "Open Pull Request on GitHub", - "command.pr.openAllDiffs.title": "Open All Diffs", - "command.pr.refreshPullRequest.title": "Refresh Pull Request", - "command.pr.openFileOnGitHub.title": "Open File on GitHub", - "command.pr.copyCommitHash.title": "Copy Commit Hash", - "command.pr.openOriginalFile.title": "Open Original File", - "command.pr.openModifiedFile.title": "Open Modified File", - "command.pr.openDiffView.title": "Open Diff View", - "command.pr.openDescription.title": "View Pull Request Description", - "command.pr.openDescriptionToTheSide.title": "Open Pull Request Description to the Side", - "command.pr.refreshDescription.title": "Refresh Pull Request Description", - "command.pr.showDiffSinceLastReview.title": "Show Changes Since Last Review", - "command.pr.showDiffAll.title": "Show All Changes", - "command.pr.checkoutByNumber.title": "Checkout Pull Request by Number", - "command.review.openFile.title": "Open File", - "command.review.openLocalFile.title": "Open File", - "command.review.suggestDiff.title": "Suggest Edit", - "command.pr.refreshList.title": "Refresh Pull Requests List", - "command.pr.setFileListLayoutAsTree.title": "Toggle View Mode", - "command.pr.setFileListLayoutAsFlat.title": "Toggle View Mode", - "command.pr.refreshChanges.title": "Refresh", - "command.pr.configurePRViewlet.title": "Configure...", - "command.pr.deleteLocalBranch.title": "Delete Local Branch", - "command.pr.signin.title": "Sign in to GitHub", - "command.pr.signinenterprise.title": "Sign in to GitHub Enterprise", - "command.pr.deleteLocalBranchesNRemotes.title": "Delete local branches and remotes", - "command.pr.createComment.title": "Add Review Comment", - "command.pr.createSingleComment.title": "Add Comment", - "command.pr.makeSuggestion.title": "Make a Suggestion", - "command.pr.startReview.title": "Start Review", - "command.pr.editComment.title": "Edit Comment", - "command.pr.cancelEditComment.title": "Cancel", - "command.pr.saveComment.title": "Save", - "command.pr.deleteComment.title": "Delete Comment", - "command.pr.resolveReviewThread.title": "Resolve Conversation", - "command.pr.unresolveReviewThread.title": "Unresolve Conversation", - "command.pr.signinAndRefreshList.title": "Sign in and Refresh", - "command.pr.configureRemotes.title": "Configure Remotes...", - "command.pr.refreshActivePullRequest.title": "Refresh", - "command.pr.markFileAsViewed.title": "Mark File As Viewed", - "command.pr.unmarkFileAsViewed.title": "Mark File As Not Viewed", - "command.pr.openReview.title": "Go to Review", - "command.pr.collapseAllComments.title": "Collapse All Comments", - "command.comments.category": "Comments", - "command.pr.editQuery.title": "Edit Query", - "command.pr.openPullsWebsite.title": "Open on GitHub", - "command.pr.resetViewedFiles.title": "Reset Viewed Files", - "command.pr.goToNextDiffInPr.title": "Go to Next Diff in Pull Request", - "command.pr.goToPreviousDiffInPr.title": "Go to Previous Diff in Pull Request", - "command.pr.copyCommentLink.title": "Copy Comment Link", - "command.pr.applySuggestion.title": "Apply Suggestion", - "command.pr.addLabelsToNewPr.title": "Add Labels", - "command.issues.category": "GitHub Issues", - "command.issue.createIssueFromSelection.title": "Create Issue From Selection", - "command.issue.createIssueFromClipboard.title": "Create Issue From Clipboard", - "command.pr.copyVscodeDevPrLink.title": "Copy vscode.dev Pull Request Link", - "command.issue.copyGithubPermalink.title": "Copy GitHub Permalink", - "command.issue.copyGithubHeadLink.title": "Copy GitHub Head Link", - "command.issue.copyMarkdownGithubPermalink.title": "Copy GitHub Permalink as Markdown", - "command.issue.openGithubPermalink.title": "Open Permalink on GitHub", - "command.issue.openIssue.title": "Open Issue on GitHub", - "command.issue.copyIssueNumber.title": "Copy Issue Number", - "command.issue.copyIssueUrl.title": "Copy Issue Link", - "command.issue.refresh.title": "Refresh", - "command.issue.suggestRefresh.title": "Refresh Suggestions", - "command.issue.startWorking.title": "Start Working on Issue", - "command.issue.startWorkingBranchDescriptiveTitle.title": "Start Working on Issue and Checkout Topic Branch", - "command.issue.continueWorking.title": "Continue Working on Issue", - "command.issue.startWorkingBranchPrompt.title": "Start Working and Set Branch...", - "command.issue.stopWorking.title": "Stop Working on Issue", - "command.issue.stopWorkingBranchDescriptiveTitle.title": "Stop Working on Issue and Leave Topic Branch", - "command.issue.statusBar.title": "Current Issue Options", - "command.issue.getCurrent.title": "Get current issue", - "command.issue.editQuery.title": "Edit Query", - "command.issue.createIssue.title": "Create an Issue", - "command.issue.createIssueFromFile.title": "Create Issue", - "command.issue.issueCompletion.title": "Issue Completion Chosen", - "command.issue.userCompletion.title": "User Completion Chosen", - "command.issue.signinAndRefreshList.title": "Sign in and Refresh", - "command.issue.goToLinkedCode.title": "Go to Linked Code", - "command.issues.openIssuesWebsite.title": "Open on GitHub", - "welcome.github.login.contents": { - "message": "You have not yet signed in with GitHub\n[Sign in](command:pr.signin)", - "comment" : [ - "Do not translate what's inside of (...). It is link syntax.", - "{Locked='](command:pr.signin)'}" - ] - }, - "welcome.github.loginWithEnterprise.contents": { - "message": "[Sign in with GitHub Enterprise](command:pr.signinenterprise)", - "comment": [ - "Do not translate what's inside of (...). It is link syntax.", - "{Locked='](command:pr.signinenterprise)'}" - ] - }, - "welcome.github.compareChanges.contents": { - "message": "The comparing branch has no upstream remote\n[Publish branch](command:git.publish)", - "comment" : [ - "Do not translate what's inside of (...). It is link syntax.", - "{Locked='](command:git.publish)'}" - ] - }, - "welcome.pr.github.uninitialized.contents": "Loading...", - "welcome.pr.github.noFolder.contents": { - "message": "You have not yet opened a folder.\n[Open Folder](command:workbench.action.files.openFolder)", - "comment" : [ - "Do not translate what's inside of (...). It is link syntax.", - "{Locked='](command:workbench.action.files.openFolder)'}" - ] - }, - "welcome.pr.github.noRepo.contents": "No git repositories found", - "welcome.issues.github.uninitialized.contents": "Loading...", - "welcome.issues.github.noFolder.contents": "You have not yet opened a folder.", - "welcome.issues.github.noRepo.contents": "No git repositories found", - "welcome.github.activePullRequest.contents": "Loading...", - "submenu.github.pullRequests.overflow.label": "More actions...", - "submenu.github.issues.overflow.label": "More actions..." +{ + "displayName": "GitHub Pull Requests", + "description": "Pull Request and Issue Provider for GitHub", + "githubPullRequests.pullRequestDescription.description": "The description used when creating pull requests.", + "githubPullRequests.pullRequestDescription.template": "Use a pull request template and commit description, or just use the commit description if no templates were found.", + "githubPullRequests.pullRequestDescription.commit": "Use the latest commit message only.", + "githubPullRequests.pullRequestDescription.branchName": "Use the branch name as the pull request title", + "githubPullRequests.pullRequestDescription.none": "Do not have a default description.", + "githubPullRequests.pullRequestDescription.copilot": "Generate a pull request title and description from GitHub Copilot. Requires that the GitHub Copilot extension is installed and authenticated. Will fall back to `commit` if Copilot is not set up.", + "githubPullRequests.defaultCreateOption.description": "The create option that the \"Create\" button will default to when creating a pull request.", + "githubPullRequests.defaultCreateOption.lastUsed": "The most recently used create option.", + "githubPullRequests.defaultCreateOption.create": "The pull request will be created.", + "githubPullRequests.defaultCreateOption.createDraft": "The pull request will be created as a draft.", + "githubPullRequests.defaultCreateOption.createAutoMerge": "The pull request will be created with auto-merge enabled. The merge method selected will be the default for the repo or the value of `githubPullRequests.defaultMergeMethod` if set.", + "githubPullRequests.createDraft": "Whether the \"Draft\" checkbox will be checked by default when creating a pull request.", + "githubPullRequests.logLevel.description": "Logging for GitHub Pull Request extension. The log is emitted to the output channel named GitHub Pull Request.", + "githubPullRequests.logLevel.markdownDeprecationMessage": { + "message": "Log level is now controlled by the [Developer: Set Log Level...](command:workbench.action.setLogLevel) command. You can set the log level for the current session and also the default log level from there.", + "comment": [ + "Do not translate what's inside of (...). It is link syntax.", + "{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.", + "githubPullRequests.codingAgent.promptForConfirmation.description": "Prompt for confirmation before initiating a coding agent session from the UI integration.", + "githubPullRequests.remotes.markdownDescription": "List of remotes, by name, to fetch pull requests from.", + "githubPullRequests.queries.markdownDescription": "Specifies what queries should be used in the GitHub Pull Requests tree. All queries are made against **the currently opened repos**. Each query object has a `label` that will be shown in the tree and a search `query` using [GitHub search syntax](https://help.github.com/en/articles/understanding-the-search-syntax). By default these queries define the categories \"Copilot on My Behalf\", \"Local Pull Request Branches\", \"Waiting For My Review\", \"Assigned To Me\" and \"Created By Me\". If you want to preserve these, make sure they are still in the array when you modify the setting. \n\n**Variables available:**\n - `${user}` - currently logged in user \n - `${owner}` - repository owner, ex. `microsoft` in `microsoft/vscode` \n - `${repository}` - repository name, ex. `vscode` in `microsoft/vscode` \n - `${today-Nd}` - date N days ago, ex. `${today-7d}` becomes `2025-01-04`\n\n**Example custom queries:**\n```json\n\"githubPullRequests.queries\": [\n {\n \"label\": \"Waiting For My Review\",\n \"query\": \"is:open review-requested:${user}\"\n },\n {\n \"label\": \"Mentioned Me\",\n \"query\": \"is:open mentions:${user}\"\n },\n {\n \"label\": \"Recent Activity\",\n \"query\": \"is:open updated:>${today-7d}\"\n }\n]\n```", + "githubPullRequests.queries.label.description": "The label to display for the query in the Pull Requests tree.", + "githubPullRequests.queries.query.description": "The GitHub search query for finding pull requests. Use GitHub search syntax with variables like ${user}, ${owner}, ${repository}. Example: 'is:open author:${user}' finds your open pull requests.", + "githubPullRequests.queries.copilotOnMyBehalf": "Copilot on My Behalf", + "githubPullRequests.queries.waitingForMyReview": "Waiting For My Review", + "githubPullRequests.queries.assignedToMe": "Assigned To Me", + "githubPullRequests.queries.createdByMe": "Created By Me", + "githubPullRequests.defaultMergeMethod.description": "The method to use when merging pull requests.", + "githubPullRequests.notifications.description": "If GitHub notifications should be shown to the user.", + "githubPullRequests.fileListLayout.description": "The layout to use when displaying changed files list.", + "githubPullRequests.hideViewedFiles.description": "Hide files that have been marked as viewed in the pull request changes tree.", + "githubPullRequests.fileAutoReveal.description": "Automatically reveal open files in the pull request changes tree.", + "githubPullRequests.defaultDeletionMethod.selectLocalBranch.description": "When true, the option to delete the local branch will be selected by default when deleting a branch from a pull request.", + "githubPullRequests.defaultDeletionMethod.selectRemote.description": "When true, the option to delete the remote will be selected by default when deleting a branch from a pull request.", + "githubPullRequests.deleteBranchAfterMerge.description": "Automatically delete the branch after merging a pull request. This setting only applies when the pull request is merged through this extension. When using merge queues, this will only delete the local branch.", + "githubPullRequests.terminalLinksHandler.description": "Default handler for terminal links.", + "githubPullRequests.terminalLinksHandler.github": "Create the pull request on GitHub.", + "githubPullRequests.terminalLinksHandler.vscode": "Create the pull request in VS Code.", + "githubPullRequests.terminalLinksHandler.ask": "Ask which method to use.", + "githubPullRequests.createOnPublishBranch.description": "Create a pull request when a branch is published.", + "githubPullRequests.createOnPublishBranch.never": "Never create a pull request when a branch is published.", + "githubPullRequests.createOnPublishBranch.ask": "Ask if you want to create a pull request when a branch is published.", + "githubPullRequests.createOnPublishBranch.always": "Always create a pull request when a branch is published.", + "githubPullRequests.commentExpandState.description": "Controls whether comments are expanded when a document with comments is opened. Requires a reload to take effect for comments that have already been added.", + "githubPullRequests.commentExpandState.expandUnresolved": "All unresolved comments will be expanded.", + "githubPullRequests.commentExpandState.collapseAll": "All comments will be collapsed.", + "githubPullRequests.commentExpandState.collapsePreexisting": "Only pre-existing comments will be collapsed. Newly added comments will remain expanded.", + "githubPullRequests.useReviewMode.description": "Choose which pull request states will use review mode. \"Open\" pull requests will always use review mode. Setting to \"auto\" will use review mode for open, closed, and merged pull requests in web, but only open pull requests on desktop.", + "githubPullRequests.useReviewMode.merged": "Use review mode for merged pull requests.", + "githubPullRequests.useReviewMode.closed": "Use review mode for closed pull requests. Merged pull requests are not considered \"closed\".", + "githubPullRequests.assignCreated.description": { + "message": "All pull requests created with this extension will be assigned to this user. To assign to yourself, use the '${user}' variable.", + "comment": [ + "{Locked='${user}'}", + "Do not translate what's inside of the '${..}'. It is an internal syntax for the extension" + ] + }, + "githubPullRequests.pushBranch.description": "Push the \"from\" branch when creating a pull request and the \"from\" branch is not available on the remote.", + "githubPullRequests.pushBranch.prompt": "Prompt to push the branch when creating a pull request and the \"from\" branch is not available on the remote.", + "githubPullRequests.pushBranch.always": "Always push the branch when creating a pull request and the \"from\" branch is not available on the remote.", + "githubPullRequests.pullBranch.description": "Pull changes from the remote when a pull request branch is checked out locally. Changes are detected when the pull request is manually refreshed and during periodic background updates.", + "githubPullRequests.pullBranch.prompt": "Prompt to pull a pull request branch when changes are detected in the pull request.", + "githubPullRequests.pullBranch.never": "Never pull a pull request branch when changes are detected in the pull request.", + "githubPullRequests.pullBranch.always": "Always pull a pull request branch when changes are detected in the pull request. When `\"git.autoStash\": true` this will instead `prompt` to prevent unexpected file changes.", + "githubPullRequests.allowFetch.description": "Allows `git fetch` to be run for checked-out pull request branches when checking for updates to the pull request.", + "githubPullRequests.ignoredPullRequestBranches.description": "Prevents branches that are associated with a pull request from being automatically detected. This will prevent review mode from being entered on these branches.", + "githubPullRequests.ignoredPullRequestBranches.items": "Branch name", + "githubPullRequests.ignoreSubmodules.description": "Prevents repositories that are submodules from being managed by the GitHub Pull Requests extension. A window reload is required for changes to this setting to take effect.", + "githubPullRequests.neverIgnoreDefaultBranch.description": "Never offer to ignore a pull request associated with the default branch of a repository.", + "githubPullRequests.overrideDefaultBranch.description": "The default branch for a repository is set on github.com. With this setting, you can override that default with another branch.", + "githubPullRequests.postCreate.description": "The action to take after creating a pull request.", + "githubPullRequests.postCreate.none": "No action", + "githubPullRequests.postCreate.openOverview": "Open the overview page of the pull request.", + "githubPullRequests.postCreate.checkoutDefaultBranch": "Checkout the default branch of the repository.", + "githubPullRequests.postCreate.checkoutDefaultBranchAndShow": "Checkout the default branch of the repository and show the pull request in the Pull Requests view.", + "githubPullRequests.postCreate.checkoutDefaultBranchAndCopy": "Checkout the default branch of the repository and copy a link to the pull request to the clipboard.", + "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.postDone.checkoutPullRequestBaseBranch": "Checkout the pull request's base branch", + "githubPullRequests.postDone.checkoutPullRequestBaseBranchAndPull": "Checkout the pull request's base branch and pull the latest changes", + "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.", + "githubPullRequests.setAutoMerge.description": "Checks the \"Auto-merge\" checkbox in the \"Create Pull Request\" view.", + "githubPullRequests.pullPullRequestBranchBeforeCheckout.description": "Controls whether the pull request branch is pulled before checkout. It can also be set to additionally merge updates from the base branch.", + "githubPullRequests.pullPullRequestBranchBeforeCheckout.never": "Never pull the pull request branch before checkout.", + "githubPullRequests.pullPullRequestBranchBeforeCheckout.pull": "Pull the pull request branch before checkout.", + "githubPullRequests.pullPullRequestBranchBeforeCheckout.pullAndMergeBase": "Pull the pull request branch before checkout, fetch the base branch, and merge the base branch into the pull request branch.", + "githubPullRequests.pullPullRequestBranchBeforeCheckout.pullAndUpdateBase": "Pull the pull request branch before checkout, fetch the base branch, merge the base branch into the pull request branch, and finally push the pull request branch to the remote.", + "githubPullRequests.upstreamRemote.description": "Controls whether an `upstream` remote is automatically added for forks.", + "githubPullRequests.upstreamRemote.add": "An `upstream` remote will be automatically added for forks.", + "githubPullRequests.upstreamRemote.never": "An `upstream` remote will never be automatically added for forks.", + "githubPullRequests.createDefaultBaseBranch.description": "Controls what the base branch picker defaults to when creating a pull request.", + "githubPullRequests.createDefaultBaseBranch.repositoryDefault": "The default branch of the repository.", + "githubPullRequests.createDefaultBaseBranch.createdFromBranch": "The branch that the current branch was created from, if known.", + "githubPullRequests.createDefaultBaseBranch.auto": "When the current repository is a fork, this will work like \"repositoryDefault\". Otherwise, it will work like \"createdFromBranch\".", + "githubPullRequests.experimental.chat.description": "Enables the `@githubpr` Copilot chat participant in the chat view. `@githubpr` can help search for issues and pull requests, suggest fixes for issues, and summarize issues, pull requests, and notifications.", + "githubPullRequests.experimental.notificationsView.description": "Enables the notifications view, which shows a list of your GitHub notifications. When combined with `#githubPullRequests.experimental.chat#`, you can have Copilot sort and summarize your notifications. View will not show in a Codespace accessed from the browser.", + "githubPullRequests.experimental.notificationsMarkPullRequests.description": "Adds an action in the Notifications view to mark pull requests with no non-empty reviews, comments, or commits since you last viewed the pull request as read.", + "githubPullRequests.experimental.useQuickChat.description": "Controls whether the Copilot \"Summarize\" commands in the Pull Requests, Issues, and Notifications views will use quick chat. Only has an effect if `#githubPullRequests.experimental.chat#` is enabled.", + "githubPullRequests.webviewRefreshInterval.description": "The interval, in seconds, at which the pull request and issues webviews are refreshed when the webview is the active tab.", + "githubPullRequests.devMode.description": "When enabled, limits expensive API calls to prevent hitting rate limits during extension development. Disables automatic Copilot PR status polling, collapses all PR query results, and disables issue fetching.", + "githubIssues.ignoreMilestones.description": "An array of milestone 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.", + "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.", + "githubIssues.ignoreCompletionTrigger.description": "Languages that the '#' character should not be used to trigger issue completion suggestions.", + "githubIssues.ignoreCompletionTrigger.items": "Language that issue completions should not trigger on '#'.", + "githubIssues.ignoreUserCompletionTrigger.description": "Languages that the '@' character should not be used to trigger user completion suggestions.", + "githubIssues.ignoreUserCompletionTrigger.items": "Language that user completions should not trigger on '@'.", + "githubIssues.issueBranchTitle.markdownDescription": { + "message": "Advanced settings for the name of the branch that is created when you start working on an issue. \n- `${user}` will be replaced with the currently logged in username \n- `${issueNumber}` will be replaced with the current issue number \n- `${sanitizedIssueTitle}` will be replaced with the issue title, with all spaces and unsupported characters (https://git-scm.com/docs/git-check-ref-format) removed. For lowercase, use `${sanitizedLowercaseIssueTitle}` ", + "comment": [ + "{Locked='${...}'}", + "Do not translate what's inside of the '${..}'. It is an internal syntax for the extension" + ] + }, + "githubIssues.useBranchForIssues.markdownDescription": { + "message": "Determines whether a branch should be checked out when working on an issue. To configure the name of the branch, set `#githubIssues.issueBranchTitle#`.", + "comment": [ + "{Locked='`#githubIssues.issueBranchTitle#`'}", + "Do not translate what's inside of the `...`. It is a setting id." + ] + }, + "githubIssues.useBranchForIssues.on": "A branch will always be checked out when you start working on an issue. If the branch doesn't exist, it will be created.", + "githubIssues.useBranchForIssues.off": "A branch will not be created when you start working on an issue. If you have worked on an issue before and a branch was created for it, that same branch will be checked out.", + "githubIssues.useBranchForIssues.prompt": "A prompt will show for setting the name of the branch that will be created and checked out.", + "githubIssues.workingBaseBranch.markdownDescription": { + "message": "Determines which branch to use as the base when creating a new branch for an issue. This setting controls what branch the new issue branch is created from.", + "comment": [ + "Describes the base branch selection for issue branches" + ] + }, + "githubIssues.workingBaseBranch.currentBranch": "Create the issue branch from the current branch without switching to the default branch first.", + "githubIssues.workingBaseBranch.defaultBranch": "Always switch to the default branch before creating the issue branch.", + "githubIssues.workingBaseBranch.prompt": "Prompt which branch to use as the base when creating an issue branch.", + "githubIssues.issueCompletionFormatScm.markdownDescription": { + "message": "Sets the format of issue completions in the SCM inputbox. \n- `${user}` will be replace with the currently logged in username \n- `${issueNumber}` will be replaced with the current issue number \n- `${issueNumberLabel}` will be replaced with a label formatted as #number or owner/repository#number, depending on whether the issue is in the current repository", + "comment": [ + "Do not translate what's inside of the ${...}. It is an internal syntax for the extension." + ] + }, + "githubIssues.workingIssueFormatScm.markdownDescription": { + "message": "Sets the format of the commit message that is set in the SCM inputbox when you **Start Working on an Issue**. Defaults to `${issueTitle} \nFixes ${issueNumberLabel}`", + "comment": [ + "Do not translate what's inside of the ${...}. It is an internal syntax for the extension." + ] + }, + "githubIssues.queries.markdownDescription": "Specifies what queries should be used in the GitHub issues tree using [GitHub search syntax](https://help.github.com/en/articles/understanding-the-search-syntax) with variables. The first query listed will be expanded in the Issues view. The \"default\" query includes issues assigned to you by Milestone. If you want to preserve these, make sure they are still in the array when you modify the setting.", + "githubIssues.queries.label": "The label to display for the query in the Issues tree.", + "githubIssues.queries.query": "The search query using [GitHub search syntax](https://help.github.com/en/articles/understanding-the-search-syntax) with variables. The variable `${user}` can be used to specify the logged in user within a search. `${owner}` and `${repository}` can be used to specify the repository by using `repo:${owner}/${repository}`.", + "githubIssues.queries.groupBy": "The categories to group issues by when displaying them, in the order in which they should be grouped.", + "githubIssues.queries.groupBy.repository": "Group issues by their repository.", + "githubIssues.queries.groupBy.milestone": "Group issues by their milestone.", + "githubIssues.queries.default.myIssues": "My Issues", + "githubIssues.queries.default.createdIssues": "Created Issues", + "githubIssues.queries.default.recentIssues": "Recent Issues", + "githubIssues.assignWhenWorking.description": "Assigns the issue you're working on to you. Only applies when the issue you're working on is in a repo you currently have open.", + "githubIssues.issueAvatarDisplay.description": "Controls which avatar to display in the issue list.", + "githubIssues.issueAvatarDisplay.author": "Show the avatar of the issue creator.", + "githubIssues.issueAvatarDisplay.assignee": "Show the avatar of the first assignee (show GitHub icon if no assignees).", + "githubPullRequests.focusedMode.description": "The layout to use when a pull request is checked out. Set to false to prevent layout changes.", + "githubPullRequests.focusedMode.firstDiff": "Show the first diff in the pull request. If there are no changes, show the overview.", + "githubPullRequests.focusedMode.overview": "Show the overview of the pull request.", + "githubPullRequests.focusedMode.multiDiff": "Show all diffs in the pull request. If there are no changes, show the overview.", + "githubPullRequests.focusedMode.false": "Do not change the layout.", + "githubPullRequests.showPullRequestNumberInTree.description": "Shows the pull request number in the tree view.", + "githubPullRequests.labelCreated.description": "Group of labels that you want to add to the pull request automatically. Labels that don't exist in the repository won't be added.", + "githubPullRequests.labelCreated.label.description": "Each string element is the value of label that you want to add.", + "githubIssues.alwaysPromptForNewIssueRepo.description": "Enabling will always prompt which repository to create an issue in instead of basing off the current open file.", + "view.github.pull.requests.name": "GitHub", + "view.github.pull.request.name": "GitHub Pull Request", + "view.github.login.name": "Login", + "view.pr.github.name": "Pull Requests", + "view.pr.github.accessibilityHelpContent": { + "message": "Helpful commands include:\n-GitHub Pull Requests: Refresh Pull Requests List\n-GitHub Pull Requests: Focus on Issues View \n-GitHub Pull Requests: Focus on Pull Requests View\n-GitHub Issues: Copy GitHub Permalink\n-GitHub Issues: Create an Issue\n-GitHub Pull Requests: Create Pull Request", + "comment": [ + "Do not translate the contents of (...) or <...> in the message. They are commands that will be replaced with the actual command name and keybinding." + ] + }, + "view.issues.github.name": "Issues", + "view.notifications.github.name": "Notifications", + "view.github.conflictResolution.name": "Conflict Resolution", + "view.github.create.pull.request.name": "Create", + "view.github.compare.changes.name": "Files Changed", + "view.github.compare.changesCommits.name": "Commits", + "view.pr.status.github.name": "Changes In Pull Request", + "view.github.active.pull.request.name": "Review Pull Request", + "view.github.active.pull.request.welcome.name": "Active Pull Request", + "command.pull.request.category": "GitHub Pull Requests", + "command.pr.create.title": "Create Pull Request", + "command.pr.pick.title": "Checkout Pull Request", + "command.pr.openChanges.title": "Open Changes", + "command.pr.pickOnVscodeDev.title": "Checkout Pull Request on vscode.dev", + "command.pr.pickOnCodespaces.title": "Checkout Pull Request on Codespaces", + "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": "Ready for Review", + "command.pr.readyForReviewAndMerge.title": "Ready, Approve, and Auto-Merge", + "command.pr.openPullRequestOnGitHub.title": "Open Pull Request on GitHub", + "command.pr.openAllDiffs.title": "Open All Diffs", + "command.pr.refreshPullRequest.title": "Refresh Pull Request", + "command.pr.openFileOnGitHub.title": "Open File on GitHub", + "command.pr.revealFileInOS.title": "Reveal in File Explorer", + "command.pr.copyCommitHash.title": "Copy Commit Hash", + "command.pr.openOriginalFile.title": "Open Original File", + "command.pr.openModifiedFile.title": "Open Modified File", + "command.pr.openDiffView.title": "Open Diff View", + "command.pr.openDiffViewFromEditor.title": "Open Pull Request Diff View", + "command.pr.openDescription.title": "View Pull Request Description", + "command.pr.openDescriptionToTheSide.title": "Open Pull Request Description to the Side", + "command.pr.refreshDescription.title": "Refresh Pull Request Description", + "command.pr.focusDescriptionInput.title": "Focus Pull Request Description Review Input", + "command.pr.showDiffSinceLastReview.title": "Show Changes Since Last Review", + "command.pr.showDiffAll.title": "Show All Changes", + "command.pr.checkoutByNumber.title": "Checkout Pull Request by Number", + "command.review.openFile.title": "Open File", + "command.review.openLocalFile.title": "Open File", + "command.review.approve.title": "Approve", + "command.review.comment.title": "Comment", + "command.review.requestChanges.title": "Request Changes", + "command.review.approveOnDotCom.title": "Approve on github.com", + "command.review.requestChangesOnDotCom.title": "Request changes on github.com", + "command.review.createSuggestionsFromChanges.title": "Create Pull Request Suggestions", + "command.review.createSuggestionFromChange.title": "Convert to Pull Request Suggestion", + "command.review.copyPrLink.title": "Copy Pull Request Link", + "command.pr.refreshList.title": "Refresh Pull Requests List", + "command.pr.setFileListLayoutAsTree.title": "View as Tree", + "command.pr.setFileListLayoutAsFlat.title": "View as List", + "command.pr.toggleHideViewedFiles.title": "Toggle Hide Viewed Files", + "command.pr.refreshChanges.title": "Refresh", + "command.pr.configurePRViewlet.title": "Configure...", + "command.pr.deleteLocalBranch.title": "Delete Local Branch", + "command.pr.signin.title": "Sign in to GitHub", + "command.pr.signinenterprise.title": "Sign in to GitHub Enterprise", + "command.pr.deleteLocalBranchesNRemotes.title": "Delete local branches and remotes", + "command.pr.createComment.title": "Add Review Comment", + "command.pr.createSingleComment.title": "Add Comment", + "command.pr.makeSuggestion.title": "Make Code Suggestion", + "command.pr.startReview.title": "Start Review", + "command.pr.editComment.title": "Edit Comment", + "command.pr.cancelEditComment.title": "Cancel", + "command.pr.saveComment.title": "Save", + "command.pr.deleteComment.title": "Delete Comment", + "command.pr.resolveReviewThread.title": "Resolve Conversation", + "command.pr.unresolveReviewThread.title": "Unresolve Conversation", + "command.pr.diffOutdatedCommentWithHead.title": "Diff Comment with HEAD", + "command.pr.signinAndRefreshList.title": "Sign in and Refresh", + "command.pr.configureRemotes.title": "Configure Remotes...", + "command.pr.refreshActivePullRequest.title": "Refresh", + "command.pr.markFileAsViewed.title": "Mark File As Viewed", + "command.pr.unmarkFileAsViewed.title": "Mark File As Not Viewed", + "command.pr.openReview.title": "Go to Review", + "command.pr.collapseAllComments.title": "Collapse All Comments", + "command.comments.category": "Comments", + "command.pr.editQuery.title": "Edit Query", + "command.pr.openPullsWebsite.title": "Open on GitHub", + "command.pr.resetViewedFiles.title": "Reset Viewed Files", + "command.pr.goToNextDiffInPr.title": "Go to Next Diff in Pull Request", + "command.pr.goToPreviousDiffInPr.title": "Go to Previous Diff in Pull Request", + "command.pr.copyCommentLink.title": "Copy Comment Link", + "command.pr.applySuggestion.title": "Apply Suggestion", + "command.pr.applySuggestionWithCopilot.title": "Apply Suggestion Using AI", + "command.pr.addAssigneesToNewPr.title": "Add Assignees", + "command.pr.addReviewersToNewPr.title": "Add Reviewers", + "command.pr.addLabelsToNewPr.title": "Apply Labels", + "command.pr.addMilestoneToNewPr.title": "Set Milestone", + "command.pr.addProjectsToNewPr.title": "Set Projects", + "command.pr.preReview.title": "Pre-review Changes", + "command.pr.addFileComment.title": "Add File Comment", + "command.review.diffWithPrHead.title": "Compare Base With Pull Request Head (readonly)", + "command.review.diffLocalWithPrHead.title": "Compare Pull Request Head with Local", + "command.issues.category": "GitHub Issues", + "command.issue.createIssueFromSelection.title": "Create Issue From Selection", + "command.issue.createIssueFromClipboard.title": "Create Issue From Clipboard", + "command.pr.copyVscodeDevPrLink.title": "Copy vscode.dev Link", + "command.pr.copyPrLink.title": "Copy Link", + "command.pr.createPrMenuCreate.title": "Create", + "command.pr.createPrMenuDraft.title": "Create Draft", + "command.pr.createPrMenuSquash.title": "Create + Auto-Squash", + "command.pr.createPrMenuMergeWhenReady.title": "Create + Merge When Ready", + "command.pr.createPrMenuMerge.title": "Create + Auto-Merge", + "command.pr.createPrMenuRebase.title": "Create + Auto-Rebase", + "command.pr.refreshComments.title": "Refresh Pull Request Comments", + "command.pr.resolveConflict.title": "Resolve Conflict", + "command.pr.acceptMerge.title": "Accept Merge", + "command.pr.closeRelatedEditors.title": "Close All Pull Request Editors", + "command.pr.toggleEditorCommentingOn.title": "Toggle Editor Commenting On", + "command.pr.toggleEditorCommentingOff.title": "Toggle Editor Commenting Off", + "command.pr.checkoutFromDescription.title": "Checkout", + "command.pr.applyChangesFromDescription.title": "Apply", + "command.pr.checkoutOnVscodeDevFromDescription.title": "Checkout on vscode.dev", + "command.pr.checkoutOnCodespacesFromDescription.title": "Checkout on Codespaces", + "command.pr.openSessionLogFromDescription.title": "View Session", + "command.issue.openDescription.title": "View Issue Description", + "command.issue.openIssueOnGitHub.title": "Open Issue on GitHub", + "command.issue.copyGithubDevLink.title": "Copy github.dev Link", + "command.issue.copyGithubPermalink.title": "Copy GitHub Permalink", + "command.issue.copyGithubHeadLink.title": "Copy GitHub Head Link", + "command.issue.copyMarkdownGithubPermalink.title": "Copy GitHub Permalink as Markdown", + "command.issue.openGithubPermalink.title": "Open Permalink on GitHub", + "command.issue.openIssue.title": "Open Issue on GitHub", + "command.issue.copyIssueNumber.title": "Copy Issue Number", + "command.issue.copyIssueUrl.title": "Copy Issue Link", + "command.issue.refresh.title": "Refresh", + "command.issue.suggestRefresh.title": "Refresh Suggestions", + "command.issue.startWorking.title": "Start Working on Issue", + "command.issue.startWorkingBranchDescriptiveTitle.title": "Start Working on Issue and Checkout Topic Branch", + "command.issue.continueWorking.title": "Continue Working on Issue", + "command.issue.startWorkingBranchPrompt.title": "Start Working and Set Branch...", + "command.issue.stopWorking.title": "Stop Working on Issue", + "command.issue.stopWorkingBranchDescriptiveTitle.title": "Stop Working on Issue and Leave Topic Branch", + "command.issue.statusBar.title": "Current Issue Options", + "command.issue.getCurrent.title": "Get current issue", + "command.issue.editQuery.title": "Edit Query", + "command.issue.createIssue.title": "Create an Issue", + "command.issue.createIssueFromFile.title": "Create Issue", + "command.issue.issueCompletion.title": "Issue Completion Chosen", + "command.issue.userCompletion.title": "User Completion Chosen", + "command.issue.signinAndRefreshList.title": "Sign in and Refresh", + "command.issue.goToLinkedCode.title": "Go to Linked Code", + "command.issues.openIssuesWebsite.title": "Open on GitHub", + "command.issues.configureIssuesViewlet.title": "Configure...", + "command.issue.chatSummarizeIssue.title": "Summarize With Copilot", + "command.issue.chatSuggestFix.title": "Suggest a Fix with Copilot", + "command.issue.assignToCodingAgent.title": "Assign to Coding Agent", + "command.notifications.category": "GitHub Notifications", + "command.notifications.refresh.title": "Refresh", + "command.notifications.pri.title": "Prioritize", + "command.notifications.loadMore.title": "Load More Notifications", + "command.notifications.sortByTimestamp.title": "Sort by Timestamp", + "command.notifications.sortByPriority.title": "Sort by Priority using Copilot", + "command.notifications.openOnGitHub.title": "Open on GitHub", + "command.notifications.markAsRead.title": "Mark as Read", + "command.notifications.markAsDone.title": "Mark as Done", + "command.notifications.markPullRequestsAsRead.title": "Mark Pull Requests as Read", + "command.notifications.markPullRequestsAsDone.title": "Mark Pull Requests as Done", + "command.notifications.configureNotificationsViewlet.title": "Configure...", + "command.notification.chatSummarizeNotification.title": "Summarize With Copilot", + "command.pr.checkoutChatSessionPullRequest.title": "Checkout Pull Request", + "welcome.github.login.contents": { + "message": "You have not yet signed in with GitHub\n[Sign in](command:pr.signin)", + "comment": [ + "Do not translate what's inside of (...). It is link syntax.", + "{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. 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": [ + "Do not translate what's inside of (...). It is link syntax.", + "{Locked='](command:pr.signinNoEnterprise)'}" + ] + }, + "welcome.github.loginWithEnterprise.contents": { + "message": "[Sign in with GitHub Enterprise](command:pr.signinenterprise)", + "comment": [ + "Do not translate what's inside of (...). It is link syntax.", + "{Locked='](command:pr.signinenterprise)'}" + ] + }, + "welcome.pr.github.uninitialized.contents": "Loading...", + "welcome.pr.github.noFolder.contents": { + "message": "You have not yet opened a folder.\n[Open Folder](command:workbench.action.files.openFolder)", + "comment": [ + "Do not translate what's inside of (...). It is link syntax.", + "{Locked='](command:workbench.action.files.openFolder)'}" + ] + }, + "welcome.pr.github.noRepo.contents": "No git repositories found", + "welcome.pr.github.parentRepo.contents": { + "message": "A git repository was found in the parent folders of the workspace or the open file(s).\n[Open Repository](command:git.openRepositoriesInParentFolders)\nUse the [git.openRepositoryInParentFolders](command:workbench.action.openSettings?%5B%22git.openRepositoryInParentFolders%22%5D) setting to control whether git repositories in parent folders of workspaces or open files are opened. To learn more [read our docs](https://aka.ms/vscode-git-repository-in-parent-folders).", + "comment": [ + "{Locked='](command:git.openRepositoriesInParentFolders'}", + "{Locked='](command:workbench.action.openSettings?%5B%22git.openRepositoryInParentFolders%22%5D'}", + "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", + "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" + ] + }, + "welcome.pr.github.parentRepos.contents": { + "message": "Git repositories were found in the parent folders of the workspace or the open file(s).\n[Open Repository](command:git.openRepositoriesInParentFolders)\nUse the [git.openRepositoryInParentFolders](command:workbench.action.openSettings?%5B%22git.openRepositoryInParentFolders%22%5D) setting to control whether git repositories in parent folders of workspace or open files are opened. To learn more [read our docs](https://aka.ms/vscode-git-repository-in-parent-folders).", + "comment": [ + "{Locked='](command:git.openRepositoriesInParentFolders'}", + "{Locked='](command:workbench.action.openSettings?%5B%22git.openRepositoryInParentFolders%22%5D'}", + "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", + "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" + ] + }, + "welcome.github.notificationsLoading.contents": "Loading...", + "welcome.github.notifications.contents": "No notifications, your inbox is empty $(rocket)", + "welcome.issues.github.uninitialized.contents": "Loading...", + "welcome.issues.github.noFolder.contents": "You have not yet opened a folder.", + "welcome.issues.github.noRepo.contents": "No git repositories found", + "welcome.github.activePullRequest.contents": "Loading...", + "languageModelTools.github-pull-request_issue_fetch.displayName": "Get a GitHub Issue or pull request", + "languageModelTools.github-pull-request_issue_summarize.displayName": "Summarize a GitHub Issue or pull request", + "languageModelTools.github-pull-request_notification_fetch.displayName": "Get a GitHub Notification", + "languageModelTools.github-pull-request_notification_summarize.displayName": "Summarize a GitHub Notification", + "languageModelTools.github-pull-request_suggest-fix.displayName": "Suggest a Fix for a GitHub Issue", + "languageModelTools.github-pull-request_formSearchQuery.displayName": "Convert natural language to a GitHub search query", + "languageModelTools.github-pull-request_doSearch.displayName": "Execute a GitHub search", + "languageModelTools.github-pull-request_renderIssues.displayName": "Render issue items in a markdown table", + "languageModelTools.github-pull-request_activePullRequest.displayName": "Active Pull Request", + "languageModelTools.github-pull-request_activePullRequest.description": "Get information about the active GitHub pull request. This information includes: comments, files changed, pull request title + description, pull request state, and pull request status checks/CI.", + "languageModelTools.github-pull-request_openPullRequest.displayName": "Open Pull Request", + "languageModelTools.github-pull-request_openPullRequest.description": "Get information about the open GitHub pull request. This information includes: comments, files changed, pull request title + description, pull request state, and pull request status checks/CI." } \ No newline at end of file diff --git a/resources/emojis.json b/resources/emojis.json new file mode 100644 index 0000000000..84556dcda5 --- /dev/null +++ b/resources/emojis.json @@ -0,0 +1 @@ +{"100":"💯","1234":"🔢","+1":"👍","-1":"👎","1st_place_medal":"🥇","2nd_place_medal":"🥈","3rd_place_medal":"🥉","8ball":"🎱","a":"🅰","ab":"🆎","abacus":"🧮","abc":"🔤","abcd":"🔡","accept":"🉑","adhesive_bandage":"🩹","adult":"🧑","aerial_tramway":"🚡","afghanistan":"🇦🇫","airplane":"✈","aland_islands":"🇦🇽","alarm_clock":"⏰","albania":"🇦🇱","alembi":"⚗","alembic":"⚗","algeria":"🇩🇿","alie":"👽","alien":"👽","ambulanc":"🚑","ambulance":"🚑","american_samoa":"🇦🇸","amphora":"🏺","anchor":"⚓","andorra":"🇦🇩","angel":"👼","anger":"💢","angola":"🇦🇴","angry":"😠","anguilla":"🇦🇮","anguished":"😧","ant":"🐜","antarctica":"🇦🇶","antigua_barbuda":"🇦🇬","apple":"🍎","aquarius":"♒","ar":"🎨","argentina":"🇦🇷","aries":"♈","armenia":"🇦🇲","arrow_backward":"◀","arrow_double_down":"⏬","arrow_double_up":"⏫","arrow_dow":"⬇️","arrow_down":"⬇","arrow_down_small":"🔽","arrow_forward":"▶","arrow_heading_down":"⤵","arrow_heading_up":"⤴","arrow_left":"⬅","arrow_lower_left":"↙","arrow_lower_right":"↘","arrow_right":"➡","arrow_right_hook":"↪","arrow_u":"⬆️","arrow_up":"⬆","arrow_up_down":"↕","arrow_up_small":"🔼","arrow_upper_left":"↖","arrow_upper_right":"↗","arrows_clockwise":"🔃","arrows_counterclockwise":"🔄","art":"🎨","articulated_lorry":"🚛","artificial_satellite":"🛰","artist":"🧑‍🎨","aruba":"🇦🇼","ascension_island":"🇦🇨","asterisk":"*️⃣","astonished":"😲","astronaut":"🧑‍🚀","athletic_shoe":"👟","atm":"🏧","atom_symbol":"⚛","australia":"🇦🇺","austria":"🇦🇹","auto_rickshaw":"🛺","avocado":"🥑","axe":"🪓","azerbaijan":"🇦🇿","b":"🅱","baby":"👶","baby_bottle":"🍼","baby_chick":"🐤","baby_symbol":"🚼","back":"🔙","bacon":"🥓","badger":"🦡","badminton":"🏸","bagel":"🥯","baggage_claim":"🛄","baguette_bread":"🥖","bahamas":"🇧🇸","bahrain":"🇧🇭","balance_scale":"⚖","bald_man":"👨‍🦲","bald_woman":"👩‍🦲","ballet_shoes":"🩰","balloon":"🎈","ballot_box":"🗳","ballot_box_with_check":"☑","bamboo":"🎍","banana":"🍌","bangbang":"‼","bangladesh":"🇧🇩","banjo":"🪕","bank":"🏦","bar_chart":"📊","barbados":"🇧🇧","barber":"💈","baseball":"⚾","basket":"🧺","basketball":"🏀","basketball_man":"⛹️‍♂️","basketball_woman":"⛹️‍♀️","bat":"🦇","bath":"🛀","bathtub":"🛁","battery":"🔋","beach_umbrella":"🏖","bear":"🐻","bearded_person":"🧔","bed":"🛏","bee":"🐝","beer":"🍺","beers":"🍻","beetle":"🐞","beginner":"🔰","belarus":"🇧🇾","belgium":"🇧🇪","belize":"🇧🇿","bell":"🔔","bellhop_bell":"🛎","benin":"🇧🇯","bent":"🍱","bento":"🍱","bermuda":"🇧🇲","beverage_box":"🧃","bhutan":"🇧🇹","bicyclist":"🚴","bike":"🚲","biking_man":"🚴‍♂️","biking_woman":"🚴‍♀️","bikini":"👙","billed_cap":"🧢","biohazard":"☣","bird":"🐦","birthday":"🎂","black_circle":"⚫","black_flag":"🏴","black_heart":"🖤","black_joker":"🃏","black_large_square":"⬛","black_medium_small_square":"◾","black_medium_square":"◼","black_nib":"✒","black_small_square":"▪","black_square_button":"🔲","blond_haired_man":"👱‍♂️","blond_haired_person":"👱","blond_haired_woman":"👱‍♀️","blonde_woman":"👱‍♀️","blossom":"🌼","blowfish":"🐡","blue_book":"📘","blue_car":"🚙","blue_heart":"💙","blue_square":"🟦","blush":"😊","boar":"🐗","boat":"⛵","bolivia":"🇧🇴","bomb":"💣","bone":"🦴","boo":"💥","book":"📖","bookmar":"🔖","bookmark":"🔖","bookmark_tabs":"📑","books":"📚","boom":"💥","boot":"👢","bosnia_herzegovina":"🇧🇦","botswana":"🇧🇼","bouncing_ball_man":"⛹️‍♂️","bouncing_ball_person":"⛹","bouncing_ball_woman":"⛹️‍♀️","bouquet":"💐","bouvet_island":"🇧🇻","bow":"🙇","bow_and_arrow":"🏹","bowing_man":"🙇‍♂️","bowing_woman":"🙇‍♀️","bowl_with_spoon":"🥣","bowling":"🎳","boxing_glove":"🥊","boy":"👦","brain":"🧠","brazil":"🇧🇷","bread":"🍞","breast_feeding":"🤱","bricks":"🧱","bride_with_veil":"👰","bridge_at_night":"🌉","briefcase":"💼","british_indian_ocean_territory":"🇮🇴","british_virgin_islands":"🇻🇬","broccoli":"🥦","broken_heart":"💔","broom":"🧹","brown_circle":"🟤","brown_heart":"🤎","brown_square":"🟫","brunei":"🇧🇳","bu":"🐛","bug":"🐛","building_constructio":"🏗","building_construction":"🏗","bul":"💡","bulb":"💡","bulgaria":"🇧🇬","bullettrain_front":"🚅","bullettrain_side":"🚄","burkina_faso":"🇧🇫","burrito":"🌯","burundi":"🇧🇮","bus":"🚌","business_suit_levitating":"🕴","busstop":"🚏","bust_in_silhouette":"👤","busts_in_silhouett":"👥","busts_in_silhouette":"👥","butter":"🧈","butterfly":"🦋","cactus":"🌵","cake":"🍰","calendar":"📆","call_me_hand":"🤙","calling":"📲","cambodia":"🇰🇭","camel":"🐫","camera":"📷","camera_flas":"📸","camera_flash":"📸","cameroon":"🇨🇲","camping":"🏕","canada":"🇨🇦","canary_islands":"🇮🇨","cancer":"♋","candle":"🕯","candy":"🍬","canned_food":"🥫","canoe":"🛶","cape_verde":"🇨🇻","capital_abcd":"🔠","capricorn":"♑","car":"🚗","card_file_bo":"🗃","card_file_box":"🗃","card_index":"📇","card_index_dividers":"🗂","caribbean_netherlands":"🇧🇶","carousel_horse":"🎠","carrot":"🥕","cartwheeling":"🤸","cat":"🐱","cat2":"🐈","cayman_islands":"🇰🇾","cd":"💿","central_african_republic":"🇨🇫","ceuta_melilla":"🇪🇦","chad":"🇹🇩","chains":"⛓","chair":"🪑","champagne":"🍾","chart":"💹","chart_with_downwards_trend":"📉","chart_with_upwards_tren":"📈","chart_with_upwards_trend":"📈","checkered_flag":"🏁","cheese":"🧀","cherries":"🍒","cherry_blossom":"🌸","chess_pawn":"♟","chestnut":"🌰","chicken":"🐔","child":"🧒","children_crossin":"🚸","children_crossing":"🚸","chile":"🇨🇱","chipmunk":"🐿","chocolate_bar":"🍫","chopsticks":"🥢","christmas_island":"🇨🇽","christmas_tree":"🎄","church":"⛪","cinema":"🎦","circus_tent":"🎪","city_sunrise":"🌇","city_sunset":"🌆","cityscape":"🏙","cl":"🆑","clamp":"🗜","clap":"👏","clapper":"🎬","classical_building":"🏛","climbing":"🧗","climbing_man":"🧗‍♂️","climbing_woman":"🧗‍♀️","clinking_glasses":"🥂","clipboard":"📋","clipperton_island":"🇨🇵","clock1":"🕐","clock10":"🕙","clock1030":"🕥","clock11":"🕚","clock1130":"🕦","clock12":"🕛","clock1230":"🕧","clock130":"🕜","clock2":"🕑","clock230":"🕝","clock3":"🕒","clock330":"🕞","clock4":"🕓","clock430":"🕟","clock5":"🕔","clock530":"🕠","clock6":"🕕","clock630":"🕡","clock7":"🕖","clock730":"🕢","clock8":"🕗","clock830":"🕣","clock9":"🕘","clock930":"🕤","closed_book":"📕","closed_lock_with_key":"🔐","closed_umbrella":"🌂","cloud":"☁","cloud_with_lightning":"🌩","cloud_with_lightning_and_rain":"⛈","cloud_with_rain":"🌧","cloud_with_snow":"🌨","clown_fac":"🤡","clown_face":"🤡","clubs":"♣","cn":"🇨🇳","coat":"🧥","cocktail":"🍸","coconut":"🥥","cocos_islands":"🇨🇨","coffee":"☕","coffin":"⚰","cold_face":"🥶","cold_sweat":"😰","collision":"💥","colombia":"🇨🇴","comet":"☄","comoros":"🇰🇲","compass":"🧭","computer":"💻","computer_mouse":"🖱","confetti_ball":"🎊","confounded":"😖","confused":"😕","congo_brazzaville":"🇨🇬","congo_kinshasa":"🇨🇩","congratulations":"㊗","constructio":"🚧","construction":"🚧","construction_worke":"👷","construction_worker":"👷","construction_worker_man":"👷‍♂️","construction_worker_woman":"👷‍♀️","control_knobs":"🎛","convenience_store":"🏪","cook":"🧑‍🍳","cook_islands":"🇨🇰","cookie":"🍪","cool":"🆒","cop":"👮","copyright":"©","corn":"🌽","costa_rica":"🇨🇷","cote_divoire":"🇨🇮","couch_and_lamp":"🛋","couple":"👫","couple_with_heart":"💑","couple_with_heart_man_man":"👨‍❤️‍👨","couple_with_heart_woman_man":"👩‍❤️‍👨","couple_with_heart_woman_woman":"👩‍❤️‍👩","couplekiss":"💏","couplekiss_man_man":"👨‍❤️‍💋‍👨","couplekiss_man_woman":"👩‍❤️‍💋‍👨","couplekiss_woman_woman":"👩‍❤️‍💋‍👩","cow":"🐮","cow2":"🐄","cowboy_hat_face":"🤠","crab":"🦀","crayon":"🖍","credit_card":"💳","crescent_moon":"🌙","cricket":"🦗","cricket_game":"🏏","croatia":"🇭🇷","crocodile":"🐊","croissant":"🥐","crossed_fingers":"🤞","crossed_flags":"🎌","crossed_swords":"⚔","crown":"👑","cry":"😢","crying_cat_face":"😿","crystal_ball":"🔮","cuba":"🇨🇺","cucumber":"🥒","cup_with_straw":"🥤","cupcake":"🧁","cupid":"💘","curacao":"🇨🇼","curling_stone":"🥌","curly_haired_man":"👨‍🦱","curly_haired_woman":"👩‍🦱","curly_loop":"➰","currency_exchange":"💱","curry":"🍛","cursing_face":"🤬","custard":"🍮","customs":"🛃","cut_of_meat":"🥩","cyclone":"🌀","cyprus":"🇨🇾","czech_republic":"🇨🇿","dagger":"🗡","dancer":"💃","dancers":"👯","dancing_men":"👯‍♂️","dancing_women":"👯‍♀️","dango":"🍡","dark_sunglasses":"🕶","dart":"🎯","dash":"💨","date":"📅","de":"🇩🇪","deaf_man":"🧏‍♂️","deaf_person":"🧏","deaf_woman":"🧏‍♀️","deciduous_tree":"🌳","deer":"🦌","denmark":"🇩🇰","department_store":"🏬","derelict_house":"🏚","desert":"🏜","desert_island":"🏝","desktop_computer":"🖥","detective":"🕵","diamond_shape_with_a_dot_inside":"💠","diamonds":"♦","diego_garcia":"🇩🇬","disappointed":"😞","disappointed_relieved":"😥","diving_mask":"🤿","diya_lamp":"🪔","dizz":"💫","dizzy":"💫","dizzy_face":"😵","djibouti":"🇩🇯","dna":"🧬","do_not_litter":"🚯","dog":"🐶","dog2":"🐕","dollar":"💵","dolls":"🎎","dolphin":"🐬","dominica":"🇩🇲","dominican_republic":"🇩🇴","door":"🚪","doughnut":"🍩","dove":"🕊","dragon":"🐉","dragon_face":"🐲","dress":"👗","dromedary_camel":"🐪","drooling_face":"🤤","drop_of_blood":"🩸","droplet":"💧","drum":"🥁","duck":"🦆","dumpling":"🥟","dvd":"📀","e-mail":"📧","eagle":"🦅","ear":"👂","ear_of_rice":"🌾","ear_with_hearing_aid":"🦻","earth_africa":"🌍","earth_americas":"🌎","earth_asia":"🌏","ecuador":"🇪🇨","eg":"🥚","egg":"🥚","eggplant":"🍆","egypt":"🇪🇬","eight":"8️⃣","eight_pointed_black_star":"✴","eight_spoked_asterisk":"✳","eject_button":"⏏","el_salvador":"🇸🇻","electric_plug":"🔌","elephant":"🐘","elf":"🧝","elf_man":"🧝‍♂️","elf_woman":"🧝‍♀️","email":"✉","end":"🔚","england":"🏴󠁧󠁢󠁥󠁮󠁧󠁿","envelope":"✉","envelope_with_arrow":"📩","equatorial_guinea":"🇬🇶","eritrea":"🇪🇷","es":"🇪🇸","estonia":"🇪🇪","ethiopia":"🇪🇹","eu":"🇪🇺","euro":"💶","european_castle":"🏰","european_post_office":"🏤","european_union":"🇪🇺","evergreen_tree":"🌲","exclamation":"❗","exploding_head":"🤯","expressionless":"😑","eye":"👁","eye_speech_bubble":"👁️‍🗨️","eyeglasses":"👓","eyes":"👀","face_with_head_bandage":"🤕","face_with_thermometer":"🤒","facepalm":"🤦","facepunch":"👊","factory":"🏭","factory_worker":"🧑‍🏭","fairy":"🧚","fairy_man":"🧚‍♂️","fairy_woman":"🧚‍♀️","falafel":"🧆","falkland_islands":"🇫🇰","fallen_leaf":"🍂","family":"👪","family_man_boy":"👨‍👦","family_man_boy_boy":"👨‍👦‍👦","family_man_girl":"👨‍👧","family_man_girl_boy":"👨‍👧‍👦","family_man_girl_girl":"👨‍👧‍👧","family_man_man_boy":"👨‍👨‍👦","family_man_man_boy_boy":"👨‍👨‍👦‍👦","family_man_man_girl":"👨‍👨‍👧","family_man_man_girl_boy":"👨‍👨‍👧‍👦","family_man_man_girl_girl":"👨‍👨‍👧‍👧","family_man_woman_boy":"👨‍👩‍👦","family_man_woman_boy_boy":"👨‍👩‍👦‍👦","family_man_woman_girl":"👨‍👩‍👧","family_man_woman_girl_boy":"👨‍👩‍👧‍👦","family_man_woman_girl_girl":"👨‍👩‍👧‍👧","family_woman_boy":"👩‍👦","family_woman_boy_boy":"👩‍👦‍👦","family_woman_girl":"👩‍👧","family_woman_girl_boy":"👩‍👧‍👦","family_woman_girl_girl":"👩‍👧‍👧","family_woman_woman_boy":"👩‍👩‍👦","family_woman_woman_boy_boy":"👩‍👩‍👦‍👦","family_woman_woman_girl":"👩‍👩‍👧","family_woman_woman_girl_boy":"👩‍👩‍👧‍👦","family_woman_woman_girl_girl":"👩‍👩‍👧‍👧","farmer":"🧑‍🌾","faroe_islands":"🇫🇴","fast_forward":"⏩","fax":"📠","fearful":"😨","feet":"🐾","female_detective":"🕵️‍♀️","female_sign":"♀","ferris_wheel":"🎡","ferry":"⛴","field_hockey":"🏑","fiji":"🇫🇯","file_cabinet":"🗄","file_folder":"📁","film_projector":"📽","film_strip":"🎞","finland":"🇫🇮","fir":"🔥","fire":"🔥","fire_engine":"🚒","fire_extinguisher":"🧯","firecracker":"🧨","firefighter":"🧑‍🚒","fireworks":"🎆","first_quarter_moon":"🌓","first_quarter_moon_with_face":"🌛","fish":"🐟","fish_cake":"🍥","fishing_pole_and_fish":"🎣","fist":"✊","fist_left":"🤛","fist_oncoming":"👊","fist_raised":"✊","fist_right":"🤜","five":"5️⃣","flags":"🎏","flamingo":"🦩","flashlight":"🔦","flat_shoe":"🥿","fleur_de_lis":"⚜","flight_arrival":"🛬","flight_departure":"🛫","flipper":"🐬","floppy_disk":"💾","flower_playing_cards":"🎴","flushed":"😳","flying_disc":"🥏","flying_saucer":"🛸","fog":"🌫","foggy":"🌁","foot":"🦶","football":"🏈","footprints":"👣","fork_and_knife":"🍴","fortune_cookie":"🥠","fountain":"⛲","fountain_pen":"🖋","four":"4️⃣","four_leaf_clover":"🍀","fox_face":"🦊","fr":"🇫🇷","framed_picture":"🖼","free":"🆓","french_guiana":"🇬🇫","french_polynesia":"🇵🇫","french_southern_territories":"🇹🇫","fried_egg":"🍳","fried_shrimp":"🍤","fries":"🍟","frog":"🐸","frowning":"😦","frowning_face":"☹","frowning_man":"🙍‍♂️","frowning_person":"🙍","frowning_woman":"🙍‍♀️","fu":"🖕","fuelpump":"⛽","full_moon":"🌕","full_moon_with_face":"🌝","funeral_urn":"⚱","gabon":"🇬🇦","gambia":"🇬🇲","game_die":"🎲","garlic":"🧄","gb":"🇬🇧","gear":"⚙","gem":"💎","gemini":"♊","genie":"🧞","genie_man":"🧞‍♂️","genie_woman":"🧞‍♀️","georgia":"🇬🇪","ghana":"🇬🇭","ghost":"👻","gibraltar":"🇬🇮","gift":"🎁","gift_heart":"💝","giraffe":"🦒","girl":"👧","globe_with_meridian":"🌐","globe_with_meridians":"🌐","gloves":"🧤","goal_ne":"🥅","goal_net":"🥅","goat":"🐐","goggles":"🥽","golf":"⛳","golfing":"🏌","golfing_man":"🏌️‍♂️","golfing_woman":"🏌️‍♀️","gorilla":"🦍","grapes":"🍇","greece":"🇬🇷","green_apple":"🍏","green_book":"📗","green_circle":"🟢","green_hear":"💚","green_heart":"💚","green_salad":"🥗","green_square":"🟩","greenland":"🇬🇱","grenada":"🇬🇩","grey_exclamation":"❕","grey_question":"❔","grimacing":"😬","grin":"😁","grinning":"😀","guadeloupe":"🇬🇵","guam":"🇬🇺","guard":"💂","guardsman":"💂‍♂️","guardswoman":"💂‍♀️","guatemala":"🇬🇹","guernsey":"🇬🇬","guide_dog":"🦮","guinea":"🇬🇳","guinea_bissau":"🇬🇼","guitar":"🎸","gun":"🔫","guyana":"🇬🇾","haircut":"💇","haircut_man":"💇‍♂️","haircut_woman":"💇‍♀️","haiti":"🇭🇹","hamburger":"🍔","hamme":"🔨","hammer":"🔨","hammer_and_pick":"⚒","hammer_and_wrench":"🛠","hamster":"🐹","hand":"✋","hand_over_mouth":"🤭","handbag":"👜","handball_person":"🤾","handshake":"🤝","hankey":"💩","hash":"#️⃣","hatched_chick":"🐥","hatching_chick":"🐣","headphones":"🎧","health_worker":"🧑‍⚕️","hear_no_evil":"🙉","heard_mcdonald_islands":"🇭🇲","heart":"❤","heart_decoration":"💟","heart_eyes":"😍","heart_eyes_cat":"😻","heartbeat":"💓","heartpulse":"💗","hearts":"♥","heavy_check_mark":"✔","heavy_division_sign":"➗","heavy_dollar_sign":"💲","heavy_exclamation_mark":"❗","heavy_heart_exclamation":"❣","heavy_minus_sig":"➖","heavy_minus_sign":"➖","heavy_multiplication_x":"✖","heavy_plus_sig":"➕","heavy_plus_sign":"➕","hedgehog":"🦔","helicopter":"🚁","herb":"🌿","hibiscus":"🌺","high_brightness":"🔆","high_heel":"👠","hiking_boot":"🥾","hindu_temple":"🛕","hippopotamus":"🦛","hocho":"🔪","hole":"🕳","honduras":"🇭🇳","honey_pot":"🍯","honeybee":"🐝","hong_kong":"🇭🇰","horse":"🐴","horse_racing":"🏇","hospital":"🏥","hot_face":"🥵","hot_pepper":"🌶","hotdog":"🌭","hotel":"🏨","hotsprings":"♨","hourglass":"⌛","hourglass_flowing_sand":"⏳","house":"🏠","house_with_garden":"🏡","houses":"🏘","hugs":"🤗","hungary":"🇭🇺","hushed":"😯","ice_cream":"🍨","ice_cube":"🧊","ice_hockey":"🏒","ice_skate":"⛸","icecream":"🍦","iceland":"🇮🇸","id":"🆔","ideograph_advantage":"🉐","imp":"👿","inbox_tray":"📥","incoming_envelope":"📨","india":"🇮🇳","indonesia":"🇮🇩","infinity":"♾","information_desk_person":"💁","information_source":"ℹ","innocent":"😇","interrobang":"⁉","iphon":"📱","iphone":"📱","iran":"🇮🇷","iraq":"🇮🇶","ireland":"🇮🇪","isle_of_man":"🇮🇲","israel":"🇮🇱","it":"🇮🇹","izakaya_lantern":"🏮","jack_o_lantern":"🎃","jamaica":"🇯🇲","japan":"🗾","japanese_castle":"🏯","japanese_goblin":"👺","japanese_ogre":"👹","jeans":"👖","jersey":"🇯🇪","jigsaw":"🧩","jordan":"🇯🇴","joy":"😂","joy_cat":"😹","joystick":"🕹","jp":"🇯🇵","judge":"🧑‍⚖️","juggling_person":"🤹","kaaba":"🕋","kangaroo":"🦘","kazakhstan":"🇰🇿","kenya":"🇰🇪","key":"🔑","keyboard":"⌨","keycap_ten":"🔟","kick_scooter":"🛴","kimono":"👘","kiribati":"🇰🇮","kiss":"💋","kissing":"😗","kissing_cat":"😽","kissing_closed_eyes":"😚","kissing_heart":"😘","kissing_smiling_eyes":"😙","kite":"🪁","kiwi_fruit":"🥝","kneeling_man":"🧎‍♂️","kneeling_person":"🧎","kneeling_woman":"🧎‍♀️","knife":"🔪","koala":"🐨","koko":"🈁","kosovo":"🇽🇰","kr":"🇰🇷","kuwait":"🇰🇼","kyrgyzstan":"🇰🇬","lab_coat":"🥼","labe":"🏷️","label":"🏷","lacrosse":"🥍","lantern":"🏮","laos":"🇱🇦","large_blue_circle":"🔵","large_blue_diamond":"🔷","large_orange_diamond":"🔶","last_quarter_moon":"🌗","last_quarter_moon_with_face":"🌜","latin_cross":"✝","latvia":"🇱🇻","laughing":"😆","leafy_green":"🥬","leaves":"🍃","lebanon":"🇱🇧","ledger":"📒","left_luggage":"🛅","left_right_arrow":"↔","left_speech_bubble":"🗨","leftwards_arrow_with_hook":"↩","leg":"🦵","lemon":"🍋","leo":"♌","leopard":"🐆","lesotho":"🇱🇸","level_slider":"🎚","liberia":"🇱🇷","libra":"♎","libya":"🇱🇾","liechtenstein":"🇱🇮","light_rail":"🚈","link":"🔗","lion":"🦁","lips":"👄","lipstic":"💄","lipstick":"💄","lithuania":"🇱🇹","lizard":"🦎","llama":"🦙","lobster":"🦞","loc":"🔒","lock":"🔒","lock_with_ink_pen":"🔏","lollipop":"🍭","loop":"➿","lotion_bottle":"🧴","lotus_position":"🧘","lotus_position_man":"🧘‍♂️","lotus_position_woman":"🧘‍♀️","loud_soun":"🔊","loud_sound":"🔊","loudspeaker":"📢","love_hotel":"🏩","love_letter":"💌","love_you_gesture":"🤟","low_brightness":"🔅","luggage":"🧳","luxembourg":"🇱🇺","lying_face":"🤥","m":"Ⓜ","ma":"🔍","macau":"🇲🇴","macedonia":"🇲🇰","madagascar":"🇲🇬","mag":"🔍","mag_right":"🔎","mage":"🧙","mage_man":"🧙‍♂️","mage_woman":"🧙‍♀️","magnet":"🧲","mahjong":"🀄","mailbox":"📫","mailbox_closed":"📪","mailbox_with_mail":"📬","mailbox_with_no_mail":"📭","malawi":"🇲🇼","malaysia":"🇲🇾","maldives":"🇲🇻","male_detective":"🕵️‍♂️","male_sign":"♂","mali":"🇲🇱","malta":"🇲🇹","man":"👨","man_artist":"👨‍🎨","man_astronaut":"👨‍🚀","man_cartwheeling":"🤸‍♂️","man_cook":"👨‍🍳","man_dancing":"🕺","man_facepalming":"🤦‍♂️","man_factory_worker":"👨‍🏭","man_farmer":"👨‍🌾","man_firefighter":"👨‍🚒","man_health_worker":"👨‍⚕️","man_in_manual_wheelchair":"👨‍🦽","man_in_motorized_wheelchair":"👨‍🦼","man_in_tuxedo":"🤵","man_judge":"👨‍⚖️","man_juggling":"🤹‍♂️","man_mechanic":"👨‍🔧","man_office_worker":"👨‍💼","man_pilot":"👨‍✈️","man_playing_handball":"🤾‍♂️","man_playing_water_polo":"🤽‍♂️","man_scientist":"👨‍🔬","man_shrugging":"🤷‍♂️","man_singer":"👨‍🎤","man_student":"👨‍🎓","man_teacher":"👨‍🏫","man_technologist":"👨‍💻","man_with_gua_pi_mao":"👲","man_with_probing_cane":"👨‍🦯","man_with_turban":"👳‍♂️","mandarin":"🍊","mango":"🥭","mans_shoe":"👞","mantelpiece_clock":"🕰","manual_wheelchair":"🦽","maple_leaf":"🍁","marshall_islands":"🇲🇭","martial_arts_uniform":"🥋","martinique":"🇲🇶","mask":"😷","massage":"💆","massage_man":"💆‍♂️","massage_woman":"💆‍♀️","mate":"🧉","mauritania":"🇲🇷","mauritius":"🇲🇺","mayotte":"🇾🇹","meat_on_bone":"🍖","mechanic":"🧑‍🔧","mechanical_arm":"🦾","mechanical_leg":"🦿","medal_military":"🎖","medal_sports":"🏅","medical_symbol":"⚕","mega":"📣","melon":"🍈","mem":"📝","memo":"📝","men_wrestling":"🤼‍♂️","menorah":"🕎","mens":"🚹","mermaid":"🧜‍♀️","merman":"🧜‍♂️","merperson":"🧜","metal":"🤘","metro":"🚇","mexico":"🇲🇽","microbe":"🦠","micronesia":"🇫🇲","microphone":"🎤","microscope":"🔬","middle_finger":"🖕","milk_glass":"🥛","milky_way":"🌌","minibus":"🚐","minidisc":"💽","mobile_phone_off":"📴","moldova":"🇲🇩","monaco":"🇲🇨","money_mouth_face":"🤑","money_with_wings":"💸","moneybag":"💰","mongolia":"🇲🇳","monkey":"🐒","monkey_face":"🐵","monocle_face":"🧐","monorail":"🚝","montenegro":"🇲🇪","montserrat":"🇲🇸","moon":"🌔","moon_cake":"🥮","morocco":"🇲🇦","mortar_board":"🎓","mosque":"🕌","mosquito":"🦟","motor_boat":"🛥","motor_scooter":"🛵","motorcycle":"🏍","motorized_wheelchair":"🦼","motorway":"🛣","mount_fuji":"🗻","mountain":"⛰","mountain_bicyclist":"🚵","mountain_biking_man":"🚵‍♂️","mountain_biking_woman":"🚵‍♀️","mountain_cableway":"🚠","mountain_railway":"🚞","mountain_snow":"🏔","mouse":"🐭","mouse2":"🐁","movie_camera":"🎥","moyai":"🗿","mozambique":"🇲🇿","mrs_claus":"🤶","muscle":"💪","mushroom":"🍄","musical_keyboard":"🎹","musical_note":"🎵","musical_score":"🎼","mut":"🔇","mute":"🔇","myanmar":"🇲🇲","nail_care":"💅","name_badge":"📛","namibia":"🇳🇦","national_park":"🏞","nauru":"🇳🇷","nauseated_face":"🤢","nazar_amulet":"🧿","necktie":"👔","negative_squared_cross_mark":"❎","nepal":"🇳🇵","nerd_face":"🤓","netherlands":"🇳🇱","neutral_face":"😐","new":"🆕","new_caledonia":"🇳🇨","new_moon":"🌑","new_moon_with_face":"🌚","new_zealand":"🇳🇿","newspaper":"📰","newspaper_roll":"🗞","next_track_button":"⏭","ng":"🆖","ng_man":"🙅‍♂️","ng_woman":"🙅‍♀️","nicaragua":"🇳🇮","niger":"🇳🇪","nigeria":"🇳🇬","night_with_stars":"🌃","nine":"9️⃣","niue":"🇳🇺","no_bell":"🔕","no_bicycles":"🚳","no_entry":"⛔","no_entry_sign":"🚫","no_good":"🙅","no_good_man":"🙅‍♂️","no_good_woman":"🙅‍♀️","no_mobile_phones":"📵","no_mouth":"😶","no_pedestrians":"🚷","no_smoking":"🚭","non-potable_water":"🚱","norfolk_island":"🇳🇫","north_korea":"🇰🇵","northern_mariana_islands":"🇲🇵","norway":"🇳🇴","nose":"👃","notebook":"📓","notebook_with_decorative_cover":"📔","notes":"🎶","nut_and_bolt":"🔩","o":"⭕","o2":"🅾","ocean":"🌊","octopus":"🐙","oden":"🍢","office":"🏢","office_worker":"🧑‍💼","oil_drum":"🛢","ok":"🆗","ok_hand":"👌","ok_man":"🙆‍♂️","ok_person":"🙆","ok_woman":"🙆‍♀️","old_key":"🗝","older_adult":"🧓","older_man":"👴","older_woman":"👵","om":"🕉","oman":"🇴🇲","on":"🔛","oncoming_automobile":"🚘","oncoming_bus":"🚍","oncoming_police_car":"🚔","oncoming_taxi":"🚖","one":"1️⃣","one_piece_swimsuit":"🩱","onion":"🧅","open_book":"📖","open_file_folder":"📂","open_hands":"👐","open_mouth":"😮","open_umbrella":"☂","ophiuchus":"⛎","orange":"🍊","orange_book":"📙","orange_circle":"🟠","orange_heart":"🧡","orange_square":"🟧","orangutan":"🦧","orthodox_cross":"☦","otter":"🦦","outbox_tray":"📤","owl":"🦉","ox":"🐂","oyster":"🦪","packag":"📦","package":"📦","page_facing_u":"📄","page_facing_up":"📄","page_with_curl":"📃","pager":"📟","paintbrush":"🖌","pakistan":"🇵🇰","palau":"🇵🇼","palestinian_territories":"🇵🇸","palm_tree":"🌴","palms_up_together":"🤲","panama":"🇵🇦","pancakes":"🥞","panda_face":"🐼","paperclip":"📎","paperclips":"🖇","papua_new_guinea":"🇵🇬","parachute":"🪂","paraguay":"🇵🇾","parasol_on_ground":"⛱","parking":"🅿","parrot":"🦜","part_alternation_mark":"〽","partly_sunny":"⛅","partying_face":"🥳","passenger_ship":"🛳","passport_control":"🛂","pause_button":"⏸","paw_prints":"🐾","peace_symbol":"☮","peach":"🍑","peacock":"🦚","peanuts":"🥜","pear":"🍐","pen":"🖊","pencil":"📝","pencil2":"✏","penguin":"🐧","pensive":"😔","people_holding_hands":"🧑‍🤝‍🧑","performing_arts":"🎭","persevere":"😣","person_bald":"🧑‍🦲","person_curly_hair":"🧑‍🦱","person_fencing":"🤺","person_in_manual_wheelchair":"🧑‍🦽","person_in_motorized_wheelchair":"🧑‍🦼","person_red_hair":"🧑‍🦰","person_white_hair":"🧑‍🦳","person_with_probing_cane":"🧑‍🦯","person_with_turban":"👳","peru":"🇵🇪","petri_dish":"🧫","philippines":"🇵🇭","phone":"☎","pick":"⛏","pie":"🥧","pig":"🐷","pig2":"🐖","pig_nose":"🐽","pill":"💊","pilot":"🧑‍✈️","pinching_hand":"🤏","pineapple":"🍍","ping_pong":"🏓","pirate_flag":"🏴‍☠️","pisces":"♓","pitcairn_islands":"🇵🇳","pizza":"🍕","place_of_worship":"🛐","plate_with_cutlery":"🍽","play_or_pause_button":"⏯","pleading_face":"🥺","point_down":"👇","point_left":"👈","point_right":"👉","point_up":"☝","point_up_2":"👆","poland":"🇵🇱","police_car":"🚓","police_officer":"👮","policeman":"👮‍♂️","policewoman":"👮‍♀️","poo":"💩","poodle":"🐩","poop":"💩","popcorn":"🍿","portugal":"🇵🇹","post_office":"🏣","postal_horn":"📯","postbox":"📮","potable_water":"🚰","potato":"🥔","pouch":"👝","poultry_leg":"🍗","pound":"💷","pout":"😡","pouting_cat":"😾","pouting_face":"🙎","pouting_man":"🙎‍♂️","pouting_woman":"🙎‍♀️","pray":"🙏","prayer_beads":"📿","pregnant_woman":"🤰","pretzel":"🥨","previous_track_button":"⏮","prince":"🤴","princess":"👸","printer":"🖨","probing_cane":"🦯","puerto_rico":"🇵🇷","punch":"👊","purple_circle":"🟣","purple_heart":"💜","purple_square":"🟪","purse":"👛","pushpi":"📌","pushpin":"📌","put_litter_in_its_place":"🚮","qatar":"🇶🇦","question":"❓","rabbit":"🐰","rabbit2":"🐇","raccoon":"🦝","racehorse":"🐎","racing_car":"🏎","radio":"📻","radio_button":"🔘","radioactive":"☢","rage":"😡","railway_car":"🚃","railway_track":"🛤","rainbow":"🌈","rainbow_flag":"🏳️‍🌈","raised_back_of_hand":"🤚","raised_eyebrow":"🤨","raised_hand":"✋","raised_hand_with_fingers_splayed":"🖐","raised_hands":"🙌","raising_hand":"🙋","raising_hand_man":"🙋‍♂️","raising_hand_woman":"🙋‍♀️","ram":"🐏","ramen":"🍜","rat":"🐀","razor":"🪒","receipt":"🧾","record_button":"⏺","recycl":"♻️","recycle":"♻","red_car":"🚗","red_circle":"🔴","red_envelope":"🧧","red_haired_man":"👨‍🦰","red_haired_woman":"👩‍🦰","red_square":"🟥","registered":"®","relaxed":"☺","relieved":"😌","reminder_ribbon":"🎗","repeat":"🔁","repeat_one":"🔂","rescue_worker_helmet":"⛑","restroom":"🚻","reunion":"🇷🇪","revolving_hearts":"💞","rewin":"⏪","rewind":"⏪","rhinoceros":"🦏","ribbon":"🎀","rice":"🍚","rice_ball":"🍙","rice_cracker":"🍘","rice_scene":"🎑","right_anger_bubble":"🗯","ring":"💍","ringed_planet":"🪐","robot":"🤖","rocke":"🚀","rocket":"🚀","rofl":"🤣","roll_eyes":"🙄","roll_of_paper":"🧻","roller_coaster":"🎢","romania":"🇷🇴","rooster":"🐓","rose":"🌹","rosette":"🏵","rotating_ligh":"🚨","rotating_light":"🚨","round_pushpin":"📍","rowboat":"🚣","rowing_man":"🚣‍♂️","rowing_woman":"🚣‍♀️","ru":"🇷🇺","rugby_football":"🏉","runner":"🏃","running":"🏃","running_man":"🏃‍♂️","running_shirt_with_sash":"🎽","running_woman":"🏃‍♀️","rwanda":"🇷🇼","sa":"🈂","safety_pin":"🧷","safety_vest":"🦺","sagittarius":"♐","sailboat":"⛵","sake":"🍶","salt":"🧂","samoa":"🇼🇸","san_marino":"🇸🇲","sandal":"👡","sandwich":"🥪","santa":"🎅","sao_tome_principe":"🇸🇹","sari":"🥻","sassy_man":"💁‍♂️","sassy_woman":"💁‍♀️","satellite":"📡","satisfied":"😆","saudi_arabia":"🇸🇦","sauna_man":"🧖‍♂️","sauna_person":"🧖","sauna_woman":"🧖‍♀️","sauropod":"🦕","saxophone":"🎷","scarf":"🧣","school":"🏫","school_satchel":"🎒","scientist":"🧑‍🔬","scissors":"✂","scorpion":"🦂","scorpius":"♏","scotland":"🏴󠁧󠁢󠁳󠁣󠁴󠁿","scream":"😱","scream_cat":"🙀","scroll":"📜","seat":"💺","secret":"㊙","see_no_evi":"🙈","see_no_evil":"🙈","seedlin":"🌱","seedling":"🌱","selfie":"🤳","senegal":"🇸🇳","serbia":"🇷🇸","service_dog":"🐕‍🦺","seven":"7️⃣","seychelles":"🇸🇨","shallow_pan_of_food":"🥘","shamrock":"☘","shark":"🦈","shaved_ice":"🍧","sheep":"🐑","shell":"🐚","shield":"🛡","shinto_shrine":"⛩","ship":"🚢","shirt":"👕","shit":"💩","shoe":"👞","shopping":"🛍","shopping_cart":"🛒","shorts":"🩳","shower":"🚿","shrimp":"🦐","shrug":"🤷","shushing_face":"🤫","sierra_leone":"🇸🇱","signal_strength":"📶","singapore":"🇸🇬","singer":"🧑‍🎤","sint_maarten":"🇸🇽","six":"6️⃣","six_pointed_star":"🔯","skateboard":"🛹","ski":"🎿","skier":"⛷","skull":"💀","skull_and_crossbones":"☠","skunk":"🦨","sled":"🛷","sleeping":"😴","sleeping_bed":"🛌","sleepy":"😪","slightly_frowning_face":"🙁","slightly_smiling_face":"🙂","slot_machine":"🎰","sloth":"🦥","slovakia":"🇸🇰","slovenia":"🇸🇮","small_airplane":"🛩","small_blue_diamond":"🔹","small_orange_diamond":"🔸","small_red_triangle":"🔺","small_red_triangle_down":"🔻","smile":"😄","smile_cat":"😸","smiley":"😃","smiley_cat":"😺","smiling_face_with_three_hearts":"🥰","smiling_imp":"😈","smirk":"😏","smirk_cat":"😼","smoking":"🚬","snail":"🐌","snake":"🐍","sneezing_face":"🤧","snowboarder":"🏂","snowflake":"❄","snowman":"⛄","snowman_with_snow":"☃","soap":"🧼","sob":"😭","soccer":"⚽","socks":"🧦","softball":"🥎","solomon_islands":"🇸🇧","somalia":"🇸🇴","soon":"🔜","sos":"🆘","sound":"🔉","south_africa":"🇿🇦","south_georgia_south_sandwich_islands":"🇬🇸","south_sudan":"🇸🇸","space_invader":"👾","spades":"♠","spaghetti":"🍝","sparkle":"❇","sparkler":"🎇","sparkles":"✨","sparkling_heart":"💖","speak_no_evil":"🙊","speaker":"🔈","speaking_head":"🗣","speech_balloo":"💬","speech_balloon":"💬","speedboat":"🚤","spider":"🕷","spider_web":"🕸","spiral_calendar":"🗓","spiral_notepad":"🗒","sponge":"🧽","spoon":"🥄","squid":"🦑","sri_lanka":"🇱🇰","st_barthelemy":"🇧🇱","st_helena":"🇸🇭","st_kitts_nevis":"🇰🇳","st_lucia":"🇱🇨","st_martin":"🇲🇫","st_pierre_miquelon":"🇵🇲","st_vincent_grenadines":"🇻🇨","stadium":"🏟","standing_man":"🧍‍♂️","standing_person":"🧍","standing_woman":"🧍‍♀️","star":"⭐","star2":"🌟","star_and_crescent":"☪","star_of_david":"✡","star_struck":"🤩","stars":"🌠","station":"🚉","statue_of_liberty":"🗽","steam_locomotive":"🚂","stethoscope":"🩺","stew":"🍲","stop_button":"⏹","stop_sign":"🛑","stopwatch":"⏱","straight_ruler":"📏","strawberry":"🍓","stuck_out_tongue":"😛","stuck_out_tongue_closed_eyes":"😝","stuck_out_tongue_winking_eye":"😜","student":"🧑‍🎓","studio_microphone":"🎙","stuffed_flatbread":"🥙","sudan":"🇸🇩","sun_behind_large_cloud":"🌥","sun_behind_rain_cloud":"🌦","sun_behind_small_cloud":"🌤","sun_with_face":"🌞","sunflower":"🌻","sunglasses":"😎","sunny":"☀","sunrise":"🌅","sunrise_over_mountains":"🌄","superhero":"🦸","superhero_man":"🦸‍♂️","superhero_woman":"🦸‍♀️","supervillain":"🦹","supervillain_man":"🦹‍♂️","supervillain_woman":"🦹‍♀️","surfer":"🏄","surfing_man":"🏄‍♂️","surfing_woman":"🏄‍♀️","suriname":"🇸🇷","sushi":"🍣","suspension_railway":"🚟","svalbard_jan_mayen":"🇸🇯","swan":"🦢","swaziland":"🇸🇿","sweat":"😓","sweat_drops":"💦","sweat_smile":"😅","sweden":"🇸🇪","sweet_potato":"🍠","swim_brief":"🩲","swimmer":"🏊","swimming_man":"🏊‍♂️","swimming_woman":"🏊‍♀️","switzerland":"🇨🇭","symbols":"🔣","synagogue":"🕍","syria":"🇸🇾","syringe":"💉","t-rex":"🦖","taco":"🌮","tad":"🎉","tada":"🎉","taiwan":"🇹🇼","tajikistan":"🇹🇯","takeout_box":"🥡","tanabata_tree":"🎋","tangerine":"🍊","tanzania":"🇹🇿","taurus":"♉","taxi":"🚕","tea":"🍵","teacher":"🧑‍🏫","technologist":"🧑‍💻","teddy_bear":"🧸","telephone":"☎","telephone_receiver":"📞","telescope":"🔭","tennis":"🎾","tent":"⛺","test_tube":"🧪","thailand":"🇹🇭","thermometer":"🌡","thinking":"🤔","thought_balloon":"💭","thread":"🧵","three":"3️⃣","thumbsdown":"👎","thumbsup":"👍","ticket":"🎫","tickets":"🎟","tiger":"🐯","tiger2":"🐅","timer_clock":"⏲","timor_leste":"🇹🇱","tipping_hand_man":"💁‍♂️","tipping_hand_person":"💁","tipping_hand_woman":"💁‍♀️","tired_face":"😫","tm":"™","togo":"🇹🇬","toilet":"🚽","tokelau":"🇹🇰","tokyo_tower":"🗼","tomato":"🍅","tonga":"🇹🇴","tongue":"👅","toolbox":"🧰","tooth":"🦷","top":"🔝","tophat":"🎩","tornado":"🌪","tr":"🇹🇷","trackball":"🖲","tractor":"🚜","traffic_light":"🚥","train":"🚋","train2":"🚆","tram":"🚊","triangular_flag_on_pos":"🚩","triangular_flag_on_post":"🚩","triangular_ruler":"📐","trident":"🔱","trinidad_tobago":"🇹🇹","tristan_da_cunha":"🇹🇦","triumph":"😤","trolleybus":"🚎","trophy":"🏆","tropical_drink":"🍹","tropical_fish":"🐠","truc":"🚚","truck":"🚚","trumpet":"🎺","tshirt":"👕","tulip":"🌷","tumbler_glass":"🥃","tunisia":"🇹🇳","turkey":"🦃","turkmenistan":"🇹🇲","turks_caicos_islands":"🇹🇨","turtle":"🐢","tuvalu":"🇹🇻","tv":"📺","twisted_rightwards_arrow":"🔀","twisted_rightwards_arrows":"🔀","two":"2️⃣","two_hearts":"💕","two_men_holding_hands":"👬","two_women_holding_hands":"👭","u5272":"🈹","u5408":"🈴","u55b6":"🈺","u6307":"🈯","u6708":"🈷","u6709":"🈶","u6e80":"🈵","u7121":"🈚","u7533":"🈸","u7981":"🈲","u7a7a":"🈳","uganda":"🇺🇬","uk":"🇬🇧","ukraine":"🇺🇦","umbrella":"☔","unamused":"😒","underage":"🔞","unicorn":"🦄","united_arab_emirates":"🇦🇪","united_nations":"🇺🇳","unlock":"🔓","up":"🆙","upside_down_face":"🙃","uruguay":"🇺🇾","us":"🇺🇸","us_outlying_islands":"🇺🇲","us_virgin_islands":"🇻🇮","uzbekistan":"🇺🇿","v":"✌","vampire":"🧛","vampire_man":"🧛‍♂️","vampire_woman":"🧛‍♀️","vanuatu":"🇻🇺","vatican_city":"🇻🇦","venezuela":"🇻🇪","vertical_traffic_light":"🚦","vhs":"📼","vibration_mode":"📳","video_camera":"📹","video_game":"🎮","vietnam":"🇻🇳","violin":"🎻","virgo":"♍","volcano":"🌋","volleyball":"🏐","vomiting_face":"🤮","vs":"🆚","vulcan_salute":"🖖","waffle":"🧇","wales":"🏴󠁧󠁢󠁷󠁬󠁳󠁿","walking":"🚶","walking_man":"🚶‍♂️","walking_woman":"🚶‍♀️","wallis_futuna":"🇼🇫","waning_crescent_moon":"🌘","waning_gibbous_moon":"🌖","warning":"⚠","wastebaske":"🗑","wastebasket":"🗑","watch":"⌚","water_buffalo":"🐃","water_polo":"🤽","watermelon":"🍉","wave":"👋","wavy_dash":"〰","waxing_crescent_moon":"🌒","waxing_gibbous_moon":"🌔","wc":"🚾","weary":"😩","wedding":"💒","weight_lifting":"🏋","weight_lifting_man":"🏋️‍♂️","weight_lifting_woman":"🏋️‍♀️","western_sahara":"🇪🇭","whale":"🐳","whale2":"🐋","wheel_of_dharma":"☸","wheelchai":"♿️","wheelchair":"♿","white_check_mar":"✅","white_check_mark":"✅","white_circle":"⚪","white_flag":"🏳","white_flower":"💮","white_haired_man":"👨‍🦳","white_haired_woman":"👩‍🦳","white_heart":"🤍","white_large_square":"⬜","white_medium_small_square":"◽","white_medium_square":"◻","white_small_square":"▫","white_square_button":"🔳","wilted_flower":"🥀","wind_chime":"🎐","wind_face":"🌬","wine_glass":"🍷","wink":"😉","wolf":"🐺","woman":"👩","woman_artist":"👩‍🎨","woman_astronaut":"👩‍🚀","woman_cartwheeling":"🤸‍♀️","woman_cook":"👩‍🍳","woman_dancing":"💃","woman_facepalming":"🤦‍♀️","woman_factory_worker":"👩‍🏭","woman_farmer":"👩‍🌾","woman_firefighter":"👩‍🚒","woman_health_worker":"👩‍⚕️","woman_in_manual_wheelchair":"👩‍🦽","woman_in_motorized_wheelchair":"👩‍🦼","woman_judge":"👩‍⚖️","woman_juggling":"🤹‍♀️","woman_mechanic":"👩‍🔧","woman_office_worker":"👩‍💼","woman_pilot":"👩‍✈️","woman_playing_handball":"🤾‍♀️","woman_playing_water_polo":"🤽‍♀️","woman_scientist":"👩‍🔬","woman_shrugging":"🤷‍♀️","woman_singer":"👩‍🎤","woman_student":"👩‍🎓","woman_teacher":"👩‍🏫","woman_technologist":"👩‍💻","woman_with_headscarf":"🧕","woman_with_probing_cane":"👩‍🦯","woman_with_turban":"👳‍♀️","womans_clothes":"👚","womans_hat":"👒","women_wrestling":"🤼‍♀️","womens":"🚺","woozy_face":"🥴","world_map":"🗺","worried":"😟","wrenc":"🔧","wrench":"🔧","wrestling":"🤼","writing_hand":"✍","x":"❌","yarn":"🧶","yawning_face":"🥱","yellow_circle":"🟡","yellow_heart":"💛","yellow_square":"🟨","yemen":"🇾🇪","yen":"💴","yin_yang":"☯","yo_yo":"🪀","yum":"😋","za":"⚡️","zambia":"🇿🇲","zany_face":"🤪","zap":"⚡","zebra":"🦓","zero":"0️⃣","zimbabwe":"🇿🇼","zipper_mouth_face":"🤐","zombie":"🧟","zombie_man":"🧟‍♂️","zombie_woman":"🧟‍♀️","zzz":"💤"} \ No newline at end of file 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/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/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..c564bb8b4c --- /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..9ceb299a04 --- /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/notebook-template.svg b/resources/icons/codicons/notebook-template.svg new file mode 100644 index 0000000000..67aaf65d5c --- /dev/null +++ b/resources/icons/codicons/notebook-template.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..9380137dc8 --- /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..4dc1dd30a5 --- /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..104147bec2 --- /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-error.svg b/resources/icons/copilot-error.svg new file mode 100644 index 0000000000..3a4a1d74f4 --- /dev/null +++ b/resources/icons/copilot-error.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/resources/icons/copilot-in-progress.svg b/resources/icons/copilot-in-progress.svg new file mode 100644 index 0000000000..d71b6c9c51 --- /dev/null +++ b/resources/icons/copilot-in-progress.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/resources/icons/copilot-success.svg b/resources/icons/copilot-success.svg new file mode 100644 index 0000000000..5b203adbf5 --- /dev/null +++ b/resources/icons/copilot-success.svg @@ -0,0 +1,4 @@ + + + + \ 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/git-pull-request_webview.svg b/resources/icons/dark/git-pull-request_webview.svg new file mode 100644 index 0000000000..f29e761a58 --- /dev/null +++ b/resources/icons/dark/git-pull-request_webview.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/icons/dark/output.svg b/resources/icons/dark/output.svg new file mode 100644 index 0000000000..7724b0e90f --- /dev/null +++ b/resources/icons/dark/output.svg @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file 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/git-pull-request_webview.svg b/resources/icons/git-pull-request_webview.svg new file mode 100644 index 0000000000..9d180ba9a7 --- /dev/null +++ b/resources/icons/git-pull-request_webview.svg @@ -0,0 +1,3 @@ + + + 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/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_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/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/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/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/scripts/check-commands.js b/scripts/check-commands.js new file mode 100644 index 0000000000..fd0d112f3a --- /dev/null +++ b/scripts/check-commands.js @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* + * Verifies that every command declared in package.json under contributes.commands + * has a corresponding vscode.commands.registerCommand('' ...) call in the source. + * Exits with non-zero status if any are missing. + */ + +const fs = require('fs'); +const path = require('path'); +const glob = require('glob'); + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function getDeclaredCommands(pkg) { + const contributes = pkg.contributes || {}; + const commands = contributes.commands || []; + const ids = []; + for (const cmd of commands) { + if (cmd && typeof cmd.command === 'string') { + ids.push(cmd.command); + } + } + return ids; +} + +function getRegisteredCommands(workspaceRoot) { + const files = glob.sync('src/**/*.ts', { cwd: workspaceRoot, ignore: ['**/node_modules/**', '**/dist/**', '**/out/**'] }); + const registered = new Set(); + const regex = /vscode\.commands\.(registerCommand|registerDiffInformationCommand)\s*\(\s*[']([^'\n]+)[']/g; + for (const rel of files) { + const full = path.join(workspaceRoot, rel); + let content; + try { + content = fs.readFileSync(full, 'utf8'); + } catch { + continue; + } + let match; + while ((match = regex.exec(content)) !== null) { + registered.add(match[2]); + } + } + return registered; +} + +function main() { + const workspaceRoot = path.resolve(__dirname, '..'); + const pkgPath = path.join(workspaceRoot, 'package.json'); + const pkg = readJson(pkgPath); + const declared = getDeclaredCommands(pkg); + const registered = getRegisteredCommands(workspaceRoot); + + const missing = declared.filter(id => !registered.has(id)); + + if (missing.length) { + console.error('ERROR: The following commands are declared in package.json but not registered:'); + for (const m of missing) { + console.error(' - ' + m); + } + console.error('\nAdd a corresponding vscode.commands.registerCommand("", ...) call.'); + process.exit(1); + } else { + console.log('All declared commands are registered.'); + } +} + +main(); diff --git a/scripts/ci/common-setup.yml b/scripts/ci/common-setup.yml index f3ea27abd0..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: '14.x' + versionSpec: "20.x" - script: yarn install --frozen-lockfile --check-files displayName: Install dependencies diff --git a/scripts/prepare-nightly-build.js b/scripts/prepare-nightly-build.js index 90a8166e07..10ab642291 100644 --- a/scripts/prepare-nightly-build.js +++ b/scripts/prepare-nightly-build.js @@ -1,5 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + const fs = require('fs'); -const argv = require('minimist')(process.argv.slice(2)); const json = JSON.parse(fs.readFileSync('./package.json').toString()); const stableVersion = json.version.match(/(\d+)\.(\d+)\.(\d+)/); @@ -15,14 +19,12 @@ function prependZero(number) { // update name, publisher and description // calculate version -let patch = argv['v']; -if (typeof patch !== 'string') { - const date = new Date(); - const month = date.getMonth() + 1; - const day = date.getDate(); - const hours = date.getHours(); - patch = `${date.getFullYear()}${prependZero(month)}${prependZero(day)}${prependZero(hours)}`; -} +// If the format of the patch version is ever changed, the isPreRelease utility function should be updated. +const date = new Date(); +const month = date.getMonth() + 1; +const day = date.getDate(); +const hours = date.getHours(); +const patch = `${date.getFullYear()}${prependZero(month)}${prependZero(day)}${prependZero(hours)}`; // The stable version should always be ..patch // For the nightly build, we keep the major, make the minor an odd number with +1, and add the timestamp as a patch. diff --git a/scripts/preprocess-fixtures.js b/scripts/preprocess-fixtures.js new file mode 100644 index 0000000000..d7aaca4ea1 --- /dev/null +++ b/scripts/preprocess-fixtures.js @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const fs = require('fs'); +const minimist = require('minimist'); +const path = require('path'); + +const argv = minimist(process.argv.slice(2), { + string: ['in', 'out'], + boolean: ['help'], + alias: { h: 'help', i: 'in', o: 'out' }, + unknown: param => { + console.error(`Unrecognized command-line argument: ${param}\n`); + printUsage(console.error, 1); + }, +}); + +if (argv.help) { + printUsage(console.log, 0); +} + +const inFilename = argv.in; +const outFilename = argv.out; + +function copyFixtures(inputDir, outputDir) { + // Get a list of all files and directories in the input directory + const files = fs.readdirSync(inputDir); + + // Iterate over each file/directory + for (const file of files) { + const filePath = path.join(inputDir, file); + const stats = fs.statSync(filePath); + const isDir = stats.isDirectory(); + + if (isDir) { + if (file === 'fixtures') { + const outputFilePath = path.join(outputDir, inputDir, file); + const inputFilePath = path.join(inputDir, file); + fs.cpSync(inputFilePath, outputFilePath, { recursive: true, force: true, }); + + } else { + copyFixtures(filePath, outputDir); + } + } + } +} + +copyFixtures(inFilename, outFilename); \ No newline at end of file diff --git a/src/@types/git.d.ts b/src/@types/git.d.ts index b3d6fbceaf..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 }>; @@ -208,11 +253,14 @@ export interface Repository { deleteBranch(name: string, force?: boolean): Promise; getBranch(name: string): Promise; getBranches(query: BranchQuery, cancellationToken?: CancellationToken): Promise; + 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; @@ -233,6 +281,12 @@ export interface Repository { log(options?: LogOptions): Promise; 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 { @@ -273,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 { @@ -291,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 { @@ -316,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', @@ -358,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.activeComment.d.ts b/src/@types/vscode.proposed.activeComment.d.ts new file mode 100644 index 0000000000..deef101dff --- /dev/null +++ b/src/@types/vscode.proposed.activeComment.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' { + // @alexr00 https://github.com/microsoft/vscode/issues/204484 + + export interface CommentController { + /** + * The currently active comment or `undefined`. The active comment is the one + * that currently has focus or, when none has focus, undefined. + */ + // readonly activeComment: Comment | undefined; + + /** + * The currently active comment thread or `undefined`. The active comment thread is the one + * in the CommentController that most recently had focus or, when a different CommentController's + * thread has most recently had focus, undefined. + */ + readonly activeCommentThread: CommentThread | undefined; + } +} diff --git a/src/@types/vscode.proposed.badges.d.ts b/src/@types/vscode.proposed.badges.d.ts deleted file mode 100644 index 5aed72b601..0000000000 --- a/src/@types/vscode.proposed.badges.d.ts +++ /dev/null @@ -1,41 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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' { - - // https://github.com/microsoft/vscode/issues/62783 @matthewjamesadam - - /** - * A badge presenting a value for a view - */ - export interface ViewBadge { - - /** - * A label to present in tooltips for the badge - */ - readonly tooltip: string; - - /** - * The value to present in the badge - */ - readonly value: number; - } - - export interface TreeView { - /** - * The badge to display for this TreeView. - * To remove the badge, set to undefined. - */ - badge?: ViewBadge | undefined; - } - - export interface WebviewView { - /** - * The badge to display for this webview view. - * To remove the badge, set to undefined. - */ - badge?: ViewBadge | undefined; - } -} diff --git a/src/@types/vscode.proposed.chatContextProvider.d.ts b/src/@types/vscode.proposed.chatContextProvider.d.ts new file mode 100644 index 0000000000..47a9284099 --- /dev/null +++ b/src/@types/vscode.proposed.chatContextProvider.d.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. + *--------------------------------------------------------------------------------------------*/ + + +declare module 'vscode' { + + // https://github.com/microsoft/vscode/issues/271104 @alexr00 + + export namespace chat { + + /** + * Register a chat context provider. Chat context can be provided: + * - For a resource. Make sure to pass a selector that matches the resource you want to provide context for. + * Providers registered without a selector will not be called for resource-based context. + * - Explicitly. These context items are shown as options when the user explicitly attaches context. + * + * To ensure your extension is activated when chat context is requested, make sure to include the following activations events: + * - If your extension implements `provideWorkspaceChatContext` or `provideChatContextForResource`, find an activation event which is a good signal to activate. + * Ex: `onLanguage:`, `onWebviewPanel:`, etc.` + * - If your extension implements `provideChatContextExplicit`, your extension will be automatically activated when the user requests explicit context. + * + * @param selector Optional document selector to filter which resources the provider is called for. If omitted, the provider will only be called for explicit context requests. + * @param id Unique identifier for the provider. + * @param provider The chat context provider. + */ + export function registerChatContextProvider(selector: DocumentSelector | undefined, id: string, provider: ChatContextProvider): Disposable; + + } + + export interface ChatContextItem { + /** + * Icon for the context item. + */ + icon: ThemeIcon; + /** + * Human readable label for the context item. + */ + label: string; + /** + * An optional description of the context item, e.g. to describe the item to the language model. + */ + modelDescription?: string; + /** + * An optional tooltip to show when hovering over the context item in the UI. + */ + tooltip?: MarkdownString; + /** + * The value of the context item. Can be omitted when returned from one of the `provide` methods if the provider supports `resolveChatContext`. + */ + value?: string; + /** + * An optional command that is executed when the context item is clicked. + * The original context item will be passed as the first argument to the command. + */ + command?: Command; + } + + export interface ChatContextProvider { + + /** + * An optional event that should be fired when the workspace chat context has changed. + */ + onDidChangeWorkspaceChatContext?: Event; + + /** + * TODO @API: should this be a separate provider interface? + * + * Provide a list of chat context items to be included as workspace context for all chat requests. + * This should be used very sparingly to avoid providing useless context and to avoid using up the context window. + * A good example use case is to provide information about which branch the user is working on in a source control context. + * + * @param token A cancellation token. + */ + provideWorkspaceChatContext?(token: CancellationToken): ProviderResult; + + /** + * Provide a list of chat context items that a user can choose from. These context items are shown as options when the user explicitly attaches context. + * Chat context items can be provided without a `value`, as the `value` can be resolved later using `resolveChatContext`. + * `resolveChatContext` is only called for items that do not have a `value`. + * + * @param token A cancellation token. + */ + provideChatContextExplicit?(token: CancellationToken): ProviderResult; + + /** + * Given a particular resource, provide a chat context item for it. This is used for implicit context (see the settings `chat.implicitContext.enabled` and `chat.implicitContext.suggestedContext`). + * Chat context items can be provided without a `value`, as the `value` can be resolved later using `resolveChatContext`. + * `resolveChatContext` is only called for items that do not have a `value`. + * + * Called when the resource is a webview or a text editor. + * + * @param options Options include the resource for which to provide context. + * @param token A cancellation token. + */ + provideChatContextForResource?(options: { resource: Uri }, token: CancellationToken): ProviderResult; + + /** + * If a chat context item is provided without a `value`, from either of the `provide` methods, this method is called to resolve the `value` for the item. + * + * @param context The context item to resolve. + * @param token A cancellation token. + */ + resolveChatContext(context: T, token: CancellationToken): ProviderResult; + } + +} diff --git a/src/@types/vscode.proposed.chatParticipantAdditions.d.ts b/src/@types/vscode.proposed.chatParticipantAdditions.d.ts new file mode 100644 index 0000000000..df0f1045c8 --- /dev/null +++ b/src/@types/vscode.proposed.chatParticipantAdditions.d.ts @@ -0,0 +1,852 @@ +/*--------------------------------------------------------------------------------------------- + * 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' { + + export interface ChatParticipant { + readonly onDidPerformAction: Event; + } + + /** + * Now only used for the "intent detection" API below + */ + export interface ChatCommand { + readonly name: string; + readonly description: string; + } + + export interface ChatVulnerability { + title: string; + description: string; + // id: string; // Later we will need to be able to link these across multiple content chunks. + } + + export class ChatResponseMarkdownWithVulnerabilitiesPart { + value: MarkdownString; + vulnerabilities: ChatVulnerability[]; + constructor(value: string | MarkdownString, vulnerabilities: ChatVulnerability[]); + } + + export class ChatResponseCodeblockUriPart { + isEdit?: boolean; + value: Uri; + undoStopId?: string; + constructor(value: Uri, isEdit?: boolean, undoStopId?: string); + } + + /** + * Displays a {@link Command command} as a button in the chat response. + */ + export interface ChatCommandButton { + command: Command; + } + + export interface ChatDocumentContext { + uri: Uri; + version: number; + ranges: Range[]; + } + + export class ChatResponseTextEditPart { + uri: Uri; + edits: TextEdit[]; + isDone?: boolean; + constructor(uri: Uri, done: true); + constructor(uri: Uri, edits: TextEdit | TextEdit[]); + } + + export class ChatResponseNotebookEditPart { + uri: Uri; + edits: NotebookEdit[]; + isDone?: boolean; + constructor(uri: Uri, done: true); + constructor(uri: Uri, edits: NotebookEdit | NotebookEdit[]); + } + + /** + * Represents a file-level edit (creation, deletion, or rename). + */ + export interface ChatWorkspaceFileEdit { + /** + * The original file URI (undefined for new files). + */ + oldResource?: Uri; + + /** + * The new file URI (undefined for deleted files). + */ + newResource?: Uri; + } + + /** + * Represents a workspace edit containing file-level operations. + */ + export class ChatResponseWorkspaceEditPart { + edits: ChatWorkspaceFileEdit[]; + constructor(edits: ChatWorkspaceFileEdit[]); + } + + export class ChatResponseConfirmationPart { + title: string; + message: string | MarkdownString; + data: any; + buttons?: string[]; + constructor(title: string, message: string | MarkdownString, data: any, buttons?: string[]); + } + + export class ChatResponseCodeCitationPart { + value: Uri; + license: string; + snippet: string; + constructor(value: Uri, license: string, snippet: string); + } + + export interface ChatToolInvocationStreamData { + /** + * Partial or not-yet-validated arguments that have streamed from the language model. + * Tools may use this to render interim UI while the full invocation input is collected. + */ + readonly partialInput?: unknown; + } + + export interface ChatTerminalToolInvocationData { + commandLine: { + original: string; + userEdited?: string; + toolEdited?: string; + }; + language: string; + + /** + * Terminal command output. Displayed when the terminal is no longer available. + */ + output?: { + /** The raw output text, may include ANSI escape codes. */ + text: string; + }; + + /** + * Command execution state. + */ + state?: { + /** Exit code of the command. */ + exitCode?: number; + /** Duration of execution in milliseconds. */ + duration?: number; + }; + } + + export class McpToolInvocationContentData { + /** + * The mime type which determines how the data property is interpreted. + */ + mimeType: string; + + /** + * The byte data for this part. + */ + data: Uint8Array; + + /** + * Construct a generic data part with the given content. + * @param data The byte data for this part. + * @param mimeType The mime type of the data. + */ + constructor(data: Uint8Array, mimeType: string); + } + + export interface ChatMcpToolInvocationData { + input: string; + output: McpToolInvocationContentData[]; + } + + export class ChatToolInvocationPart { + toolName: string; + toolCallId: string; + isError?: boolean; + invocationMessage?: string | MarkdownString; + originMessage?: string | MarkdownString; + pastTenseMessage?: string | MarkdownString; + isConfirmed?: boolean; + isComplete?: boolean; + toolSpecificData?: ChatTerminalToolInvocationData; + fromSubAgent?: boolean; + presentation?: 'hidden' | 'hiddenAfterComplete' | undefined; + + constructor(toolName: string, toolCallId: string, isError?: boolean); + } + + /** + * Represents a single file diff entry in a multi diff view. + */ + export interface ChatResponseDiffEntry { + /** + * The original file URI (undefined for new files). + */ + originalUri?: Uri; + + /** + * The modified file URI (undefined for deleted files). + */ + modifiedUri?: Uri; + + /** + * Optional URI to navigate to when clicking on the file. + */ + goToFileUri?: Uri; + + /** + * Added data (e.g. line numbers) to show in the UI + */ + added?: number; + + /** + * Removed data (e.g. line numbers) to show in the UI + */ + removed?: number; + } + + /** + * Represents a part of a chat response that shows multiple file diffs. + */ + export class ChatResponseMultiDiffPart { + /** + * Array of file diff entries to display. + */ + value: ChatResponseDiffEntry[]; + + /** + * The title for the multi diff editor. + */ + 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, readOnly?: boolean); + } + + 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); + } + + export class ChatResponseProgressPart2 extends ChatResponseProgressPart { + value: string; + task?: (progress: Progress) => Thenable; + constructor(value: string, task?: (progress: Progress) => Thenable); + } + + /** + * A specialized progress part for displaying thinking/reasoning steps. + */ + export class ChatResponseThinkingProgressPart { + value: string | string[]; + id?: string; + metadata?: { readonly [key: string]: any }; + task?: (progress: Progress) => Thenable; + + /** + * Creates a new thinking progress part. + * @param value An initial progress message + * @param task A task that will emit thinking parts during its execution + */ + constructor(value: string | string[], id?: string, metadata?: { readonly [key: string]: any }, task?: (progress: Progress) => Thenable); + } + + export class ChatResponseReferencePart2 { + /** + * The reference target. + */ + value: Uri | Location | { variableName: string; value?: Uri | Location } | string; + + /** + * The icon for the reference. + */ + iconPath?: Uri | ThemeIcon | { + /** + * The icon path for the light theme. + */ + light: Uri; + /** + * The icon path for the dark theme. + */ + dark: Uri; + }; + options?: { status?: { description: string; kind: ChatResponseReferencePartStatusKind } }; + + /** + * Create a new ChatResponseReferencePart. + * @param value A uri or location + * @param iconPath Icon for the reference shown in UI + */ + constructor(value: Uri | Location | { variableName: string; value?: Uri | Location } | string, iconPath?: Uri | ThemeIcon | { + /** + * The icon path for the light theme. + */ + light: Uri; + /** + * The icon path for the dark theme. + */ + dark: Uri; + }, options?: { status?: { description: string; kind: ChatResponseReferencePartStatusKind } }); + } + + export class ChatResponseMovePart { + + readonly uri: Uri; + readonly range: Range; + + constructor(uri: Uri, range: Range); + } + + export interface ChatResponseAnchorPart { + /** + * The target of this anchor. + * + * If this is a {@linkcode Uri} or {@linkcode Location}, this is rendered as a normal link. + * + * If this is a {@linkcode SymbolInformation}, this is rendered as a symbol link. + * + * TODO mjbvz: Should this be a full `SymbolInformation`? Or just the parts we need? + * TODO mjbvz: Should we allow a `SymbolInformation` without a location? For example, until `resolve` completes? + */ + value2: Uri | Location | SymbolInformation; + + /** + * Optional method which fills in the details of the anchor. + * + * THis is currently only implemented for symbol links. + */ + resolve?(token: CancellationToken): Thenable; + } + + export class ChatResponseExtensionsPart { + + readonly extensions: string[]; + + constructor(extensions: string[]); + } + + export class ChatResponsePullRequestPart { + readonly uri: Uri; + readonly linkTag: string; + readonly title: string; + readonly description: string; + readonly author: string; + constructor(uri: Uri, title: string, description: string, author: string, linkTag: string); + } + + export interface ChatResponseStream { + + /** + * Push a progress part to this stream. Short-hand for + * `push(new ChatResponseProgressPart(value))`. + * + * @param value A progress message + * @param task If provided, a task to run while the progress is displayed. When the Thenable resolves, the progress will be marked complete in the UI, and the progress message will be updated to the resolved string if one is specified. + * @returns This stream. + */ + progress(value: string, task?: (progress: Progress) => Thenable): void; + + thinkingProgress(thinkingDelta: ThinkingDelta): void; + + textEdit(target: Uri, edits: TextEdit | TextEdit[]): void; + + textEdit(target: Uri, isDone: true): void; + + notebookEdit(target: Uri, edits: NotebookEdit | NotebookEdit[]): void; + + notebookEdit(target: Uri, isDone: true): void; + + /** + * Push a workspace edit containing file-level operations (create, delete, rename). + * @param edits Array of file-level edits to apply + */ + workspaceEdit(edits: ChatWorkspaceFileEdit[]): 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; + + /** + * Show an inline message in the chat view asking the user to confirm an action. + * Multiple confirmations may be shown per response. The UI might show "Accept All" / "Reject All" actions. + * @param title The title of the confirmation entry + * @param message An extra message to display to the user + * @param data An arbitrary JSON-stringifiable object that will be included in the ChatRequest when + * the confirmation is accepted or rejected + * TODO@API should this be MarkdownString? + * TODO@API should actually be a more generic function that takes an array of buttons + */ + confirmation(title: string, message: string | MarkdownString, data: any, buttons?: string[]): void; + + /** + * Push a warning to this stream. Short-hand for + * `push(new ChatResponseWarningPart(message))`. + * + * @param message A warning message + * @returns This stream. + */ + warning(message: string | MarkdownString): void; + + reference(value: Uri | Location | { variableName: string; value?: Uri | Location }, iconPath?: Uri | ThemeIcon | { light: Uri; dark: Uri }): void; + + reference2(value: Uri | Location | string | { variableName: string; value?: Uri | Location }, iconPath?: Uri | ThemeIcon | { light: Uri; dark: Uri }, options?: { status?: { description: string; kind: ChatResponseReferencePartStatusKind } }): void; + + codeCitation(value: Uri, license: string, snippet: string): void; + + /** + * Begin a tool invocation in streaming mode. This creates a tool invocation that will + * display streaming progress UI until the tool is actually invoked. + * @param toolCallId Unique identifier for this tool call, used to correlate streaming updates and final invocation. + * @param toolName The name of the tool being invoked. + * @param streamData Optional initial streaming data with partial arguments. + */ + beginToolInvocation(toolCallId: string, toolName: string, streamData?: ChatToolInvocationStreamData & { subagentInvocationId?: string }): void; + + /** + * Update the streaming data for a tool invocation that was started with `beginToolInvocation`. + * @param toolCallId The tool call ID that was passed to `beginToolInvocation`. + * @param streamData New streaming data with updated partial arguments. + */ + updateToolInvocation(toolCallId: string, streamData: ChatToolInvocationStreamData): void; + + push(part: ExtendedChatResponsePart): void; + + clearToPreviousToolInvocation(reason: ChatResponseClearToPreviousToolInvocationReason): void; + } + + export enum ChatResponseReferencePartStatusKind { + Complete = 1, + Partial = 2, + Omitted = 3 + } + + export type ThinkingDelta = { + text?: string | string[]; + id: string; + metadata?: { readonly [key: string]: any }; + } | { + text?: string | string[]; + id?: string; + metadata: { readonly [key: string]: any }; + } | + { + text: string | string[]; + id?: string; + metadata?: { readonly [key: string]: any }; + }; + + export enum ChatResponseClearToPreviousToolInvocationReason { + NoReason = 0, + FilteredContentRetry = 1, + CopyrightContentRetry = 2, + } + + /** + * Does this piggy-back on the existing ChatRequest, or is it a different type of request entirely? + * Does it show up in history? + */ + export interface ChatRequest { + /** + * The `data` for any confirmations that were accepted + */ + acceptedConfirmationData?: any[]; + + /** + * The `data` for any confirmations that were rejected + */ + rejectedConfirmationData?: any[]; + } + + export interface ChatRequest { + + /** + * A map of all tools that should (`true`) and should not (`false`) be used in this request. + */ + readonly tools: Map; + } + + export namespace lm { + /** + * Fired when the set of tools on a chat request changes. + */ + export const onDidChangeChatRequestTools: Event; + } + + export class LanguageModelToolExtensionSource { + /** + * ID of the extension that published the tool. + */ + readonly id: string; + + /** + * Label of the extension that published the tool. + */ + readonly label: string; + + private constructor(id: string, label: string); + } + + export class LanguageModelToolMCPSource { + /** + * Editor-configured label of the MCP server that published the tool. + */ + readonly label: string; + + /** + * Server-defined name of the MCP server. + */ + readonly name: string; + + /** + * Server-defined instructions for MCP tool use. + */ + readonly instructions?: string; + + private constructor(label: string, name: string, instructions?: string); + } + + export interface LanguageModelToolInformation { + source: LanguageModelToolExtensionSource | LanguageModelToolMCPSource | undefined; + } + + // TODO@API fit this into the stream + export interface ChatUsedContext { + documents: ChatDocumentContext[]; + } + + export interface ChatParticipant { + /** + * Provide a set of variables that can only be used with this participant. + */ + participantVariableProvider?: { provider: ChatParticipantCompletionItemProvider; triggerCharacters: string[] }; + + /** + * Event that fires when a request is paused or unpaused. + * Chat requests are initially unpaused in the {@link requestHandler}. + */ + readonly onDidChangePauseState: Event; + } + + export interface ChatParticipantPauseStateEvent { + request: ChatRequest; + isPaused: boolean; + } + + export interface ChatParticipantCompletionItemProvider { + provideCompletionItems(query: string, token: CancellationToken): ProviderResult; + } + + export class ChatCompletionItem { + id: string; + label: string | CompletionItemLabel; + values: ChatVariableValue[]; + fullName?: string; + icon?: ThemeIcon; + insertText?: string; + detail?: string; + documentation?: string | MarkdownString; + command?: Command; + + constructor(id: string, label: string | CompletionItemLabel, values: ChatVariableValue[]); + } + + export type ChatExtendedRequestHandler = (request: ChatRequest, context: ChatContext, response: ChatResponseStream, token: CancellationToken) => ProviderResult; + + /** + * Details about the prompt token usage by category and label. + */ + export interface ChatResultPromptTokenDetail { + /** + * The category this token usage belongs to (e.g., "System", "Context", "Conversation"). + */ + readonly category: string; + + /** + * The label for this specific token usage (e.g., "System prompt", "Attached files"). + */ + readonly label: string; + + /** + * The percentage of the total prompt tokens this represents (0-100). + */ + readonly percentageOfPrompt: number; + } + + /** + * Token usage information for a chat request. + */ + export interface ChatResultUsage { + /** + * The number of prompt tokens used in this request. + */ + readonly promptTokens: number; + + /** + * The number of completion tokens generated in this response. + */ + readonly completionTokens: number; + + /** + * Optional breakdown of prompt token usage by category and label. + * If the percentages do not sum to 100%, the remaining will be shown as "Uncategorized". + */ + readonly promptTokenDetails?: readonly ChatResultPromptTokenDetail[]; + } + + export interface ChatResult { + nextQuestion?: { + prompt: string; + participant?: string; + command?: string; + }; + /** + * An optional detail string that will be rendered at the end of the response in certain UI contexts. + */ + details?: string; + + /** + * Token usage information for this request, if available. + * This is typically provided by the underlying language model. + */ + readonly usage?: ChatResultUsage; + } + + export namespace chat { + /** + * Create a chat participant with the extended progress type + */ + export function createChatParticipant(id: string, handler: ChatExtendedRequestHandler): ChatParticipant; + } + + /* + * User action events + */ + + export enum ChatCopyKind { + // Keyboard shortcut or context menu + Action = 1, + Toolbar = 2 + } + + export interface ChatCopyAction { + // eslint-disable-next-line local/vscode-dts-string-type-literals + kind: 'copy'; + codeBlockIndex: number; + copyKind: ChatCopyKind; + copiedCharacters: number; + totalCharacters: number; + copiedText: string; + totalLines: number; + copiedLines: number; + modelId: string; + languageId?: string; + } + + export interface ChatInsertAction { + // eslint-disable-next-line local/vscode-dts-string-type-literals + kind: 'insert'; + codeBlockIndex: number; + totalCharacters: number; + totalLines: number; + languageId?: string; + modelId: string; + newFile?: boolean; + } + + export interface ChatApplyAction { + // eslint-disable-next-line local/vscode-dts-string-type-literals + kind: 'apply'; + codeBlockIndex: number; + totalCharacters: number; + totalLines: number; + languageId?: string; + modelId: string; + newFile?: boolean; + codeMapper?: string; + } + + export interface ChatTerminalAction { + // eslint-disable-next-line local/vscode-dts-string-type-literals + kind: 'runInTerminal'; + codeBlockIndex: number; + languageId?: string; + } + + export interface ChatCommandAction { + // eslint-disable-next-line local/vscode-dts-string-type-literals + kind: 'command'; + commandButton: ChatCommandButton; + } + + export interface ChatFollowupAction { + // eslint-disable-next-line local/vscode-dts-string-type-literals + kind: 'followUp'; + followup: ChatFollowup; + } + + export interface ChatBugReportAction { + // eslint-disable-next-line local/vscode-dts-string-type-literals + kind: 'bug'; + } + + export interface ChatEditorAction { + // eslint-disable-next-line local/vscode-dts-string-type-literals + kind: 'editor'; + accepted: boolean; + } + + export interface ChatEditingSessionAction { + // eslint-disable-next-line local/vscode-dts-string-type-literals + kind: 'chatEditingSessionAction'; + uri: Uri; + hasRemainingEdits: boolean; + outcome: ChatEditingSessionActionOutcome; + } + + export interface ChatEditingHunkAction { + // eslint-disable-next-line local/vscode-dts-string-type-literals + kind: 'chatEditingHunkAction'; + uri: Uri; + lineCount: number; + linesAdded: number; + linesRemoved: number; + outcome: ChatEditingSessionActionOutcome; + hasRemainingEdits: boolean; + } + + export enum ChatEditingSessionActionOutcome { + Accepted = 1, + Rejected = 2, + Saved = 3 + } + + export interface ChatUserActionEvent { + readonly result: ChatResult; + readonly action: ChatCopyAction | ChatInsertAction | ChatApplyAction | ChatTerminalAction | ChatCommandAction | ChatFollowupAction | ChatBugReportAction | ChatEditorAction | ChatEditingSessionAction | ChatEditingHunkAction; + } + + export interface ChatPromptReference { + /** + * TODO Needed for now to drive the variableName-type reference, but probably both of these should go away in the future. + */ + readonly name: string; + + /** + * The list of tools were referenced in the value of the reference + */ + readonly toolReferences?: readonly ChatLanguageModelToolReference[]; + } + + export interface ChatResultFeedback { + readonly unhelpfulReason?: string; + } + + export namespace lm { + export function fileIsIgnored(uri: Uri, token?: CancellationToken): Thenable; + } + + export interface ChatVariableValue { + /** + * The detail level of this chat variable value. If possible, variable resolvers should try to offer shorter values that will consume fewer tokens in an LLM prompt. + */ + level: ChatVariableLevel; + + /** + * The variable's value, which can be included in an LLM prompt as-is, or the chat participant may decide to read the value and do something else with it. + */ + value: string | Uri; + + /** + * A description of this value, which could be provided to the LLM as a hint. + */ + description?: string; + } + + /** + * The detail level of this chat variable value. + */ + export enum ChatVariableLevel { + Short = 1, + Medium = 2, + Full = 3 + } + + export interface LanguageModelToolInvocationOptions { + model?: LanguageModelChat; + chatStreamToolCallId?: string; + } + + export interface LanguageModelToolInvocationStreamOptions { + /** + * Raw argument payload, such as the streamed JSON fragment from the language model. + */ + readonly rawInput?: unknown; + + readonly chatRequestId?: string; + /** @deprecated Use {@link chatSessionResource} instead */ + readonly chatSessionId?: string; + readonly chatSessionResource?: Uri; + readonly chatInteractionId?: string; + } + + export interface LanguageModelToolStreamResult { + /** + * A customized progress message to show while the tool runs. + */ + invocationMessage?: string | MarkdownString; + } + + export interface LanguageModelTool { + /** + * Called zero or more times before {@link LanguageModelTool.prepareInvocation} while the + * language model streams argument data for the invocation. Use this to update progress + * or UI with the partial arguments that have been generated so far. + * + * Implementations must be free of side-effects and should be resilient to receiving + * malformed or incomplete input. + */ + handleToolStream?(options: LanguageModelToolInvocationStreamOptions, token: CancellationToken): ProviderResult; + } + + export interface ChatRequest { + 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 new file mode 100644 index 0000000000..4ab722c122 --- /dev/null +++ b/src/@types/vscode.proposed.chatParticipantPrivate.d.ts @@ -0,0 +1,342 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// version: 11 + +declare module 'vscode' { + + /** + * The location at which the chat is happening. + */ + export enum ChatLocation { + /** + * The chat panel + */ + Panel = 1, + /** + * Terminal inline chat + */ + Terminal = 2, + /** + * Notebook inline chat + */ + Notebook = 3, + /** + * Code editor inline chat + */ + Editor = 4, + } + + export class ChatRequestEditorData { + + readonly editor: TextEditor; + + //TODO@API should be the editor + document: TextDocument; + selection: Selection; + + /** @deprecated */ + wholeRange: Range; + + constructor(editor: TextEditor, document: TextDocument, selection: Selection, wholeRange: Range); + } + + export class ChatRequestNotebookData { + //TODO@API should be the editor + readonly cell: TextDocument; + + constructor(cell: TextDocument); + } + + export interface ChatRequest { + /** + * The id of the chat request. Used to identity an interaction with any of the chat surfaces. + */ + readonly id: string; + /** + * The attempt number of the request. The first request has attempt number 0. + */ + readonly attempt: number; + + /** + * The session identifier for this chat request. + * + * @deprecated Use {@link chatSessionResource} instead. + */ + readonly sessionId: string; + + /** + * The resource URI for the chat session this request belongs to. + */ + readonly sessionResource: Uri; + + /** + * If automatic command detection is enabled. + */ + readonly enableCommandDetection: boolean; + + /** + * If the chat participant or command was automatically assigned. + */ + readonly isParticipantDetected: boolean; + + /** + * The location at which the chat is happening. This will always be one of the supported values + * + * @deprecated + */ + readonly location: ChatLocation; + + /** + * Information that is specific to the location at which chat is happening, e.g within a document, notebook, + * or terminal. Will be `undefined` for the chat panel. + */ + readonly location2: ChatRequestEditorData | ChatRequestNotebookData | undefined; + + /** + * Events for edited files in this session collected since the last request. + */ + readonly editedFileEvents?: ChatRequestEditedFileEvent[]; + + /** + * Unique ID for the subagent invocation, used to group tool calls from the same subagent run together. + * Pass this to tool invocations when calling tools from within a subagent context. + */ + readonly subAgentInvocationId?: string; + + /** + * Display name of the subagent that is invoking this request. + */ + readonly subAgentName?: string; + } + + export enum ChatRequestEditedFileEventKind { + Keep = 1, + Undo = 2, + UserModification = 3, + } + + export interface ChatRequestEditedFileEvent { + readonly uri: Uri; + readonly eventKind: ChatRequestEditedFileEventKind; + } + + /** + * ChatRequestTurn + private additions. Note- at runtime this is the SAME as ChatRequestTurn and instanceof is safe. + */ + export class ChatRequestTurn2 { + /** + * The prompt as entered by the user. + * + * Information about references used in this request is stored in {@link ChatRequestTurn.references}. + * + * *Note* that the {@link ChatParticipant.name name} of the participant and the {@link ChatCommand.name command} + * are not part of the prompt. + */ + readonly prompt: string; + + /** + * The id of the chat participant to which this request was directed. + */ + readonly participant: string; + + /** + * The name of the {@link ChatCommand command} that was selected for this request. + */ + readonly command?: string; + + /** + * The references that were used in this message. + */ + readonly references: ChatPromptReference[]; + + /** + * The list of tools were attached to this request. + */ + readonly toolReferences: readonly ChatLanguageModelToolReference[]; + + /** + * Events for edited files in this session collected between the previous request and this one. + */ + readonly editedFileEvents?: ChatRequestEditedFileEvent[]; + + /** + * @hidden + */ + constructor(prompt: string, command: string | undefined, references: ChatPromptReference[], participant: string, toolReferences: ChatLanguageModelToolReference[], editedFileEvents: ChatRequestEditedFileEvent[] | undefined); + } + + export class ChatResponseTurn2 { + /** + * The id of the chat response. Used to identity an interaction with any of the chat surfaces. + */ + readonly id?: string; + + /** + * The content that was received from the chat participant. Only the stream parts that represent actual content (not metadata) are represented. + */ + readonly response: ReadonlyArray; + + /** + * The result that was received from the chat participant. + */ + readonly result: ChatResult; + + /** + * The id of the chat participant that this response came from. + */ + readonly participant: string; + + /** + * The name of the command that this response came from. + */ + readonly command?: string; + + constructor(response: ReadonlyArray, result: ChatResult, participant: string); + } + + export interface ChatParticipant { + supportIssueReporting?: boolean; + } + + export enum ChatErrorLevel { + Info = 0, + Warning = 1, + Error = 2, + } + + export interface ChatErrorDetails { + /** + * If set to true, the message content is completely hidden. Only ChatErrorDetails#message will be shown. + */ + responseIsRedacted?: boolean; + + isQuotaExceeded?: boolean; + + isRateLimited?: boolean; + + level?: ChatErrorLevel; + + code?: string; + } + + export namespace chat { + export function createDynamicChatParticipant(id: string, dynamicProps: DynamicChatParticipantProps, handler: ChatExtendedRequestHandler): ChatParticipant; + } + + /** + * These don't get set on the ChatParticipant after creation, like other props, because they are typically defined in package.json and we want them at the time of creation. + */ + export interface DynamicChatParticipantProps { + name: string; + publisherName: string; + description?: string; + fullName?: string; + } + + export namespace lm { + export function registerIgnoredFileProvider(provider: LanguageModelIgnoredFileProvider): Disposable; + } + + export interface LanguageModelIgnoredFileProvider { + provideFileIgnored(uri: Uri, token: CancellationToken): ProviderResult; + } + + export interface LanguageModelToolInvocationOptions { + chatRequestId?: string; + /** @deprecated Use {@link chatSessionResource} instead */ + chatSessionId?: string; + chatSessionResource?: Uri; + chatInteractionId?: string; + terminalCommand?: string; + /** + * Unique ID for the subagent invocation, used to group tool calls from the same subagent run together. + */ + subAgentInvocationId?: string; + } + + export interface LanguageModelToolInvocationPrepareOptions { + /** + * The input that the tool is being invoked with. + */ + input: T; + chatRequestId?: string; + /** @deprecated Use {@link chatSessionResource} instead */ + chatSessionId?: string; + chatSessionResource?: Uri; + chatInteractionId?: string; + } + + export interface PreparedToolInvocation { + pastTenseMessage?: string | MarkdownString; + presentation?: 'hidden' | 'hiddenAfterComplete' | undefined; + } + + export class ExtendedLanguageModelToolResult extends LanguageModelToolResult { + toolResultMessage?: string | MarkdownString; + toolResultDetails?: Array; + toolMetadata?: unknown; + /** Whether there was an error calling the tool. The tool may still have partially succeeded. */ + hasError?: boolean; + } + + // #region Chat participant detection + + export interface ChatParticipantMetadata { + participant: string; + command?: string; + disambiguation: { category: string; description: string; examples: string[] }[]; + } + + export interface ChatParticipantDetectionResult { + participant: string; + command?: string; + } + + export interface ChatParticipantDetectionProvider { + provideParticipantDetection(chatRequest: ChatRequest, context: ChatContext, options: { participants?: ChatParticipantMetadata[]; location: ChatLocation }, token: CancellationToken): ProviderResult; + } + + export namespace chat { + export function registerChatParticipantDetectionProvider(participantDetectionProvider: ChatParticipantDetectionProvider): Disposable; + + export const onDidDisposeChatSession: Event; + } + + // #endregion + + // #region ChatErrorDetailsWithConfirmation + + export interface ChatErrorDetails { + confirmationButtons?: ChatErrorDetailsConfirmationButton[]; + } + + export interface ChatErrorDetailsConfirmationButton { + data: any; + label: string; + } + + // #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 new file mode 100644 index 0000000000..84cd547599 --- /dev/null +++ b/src/@types/vscode.proposed.chatSessionsProvider.d.ts @@ -0,0 +1,559 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// version: 3 + +declare module 'vscode' { + /** + * Represents the status of a chat session. + */ + export enum ChatSessionStatus { + /** + * The chat session failed to complete. + */ + Failed = 0, + + /** + * The chat session completed successfully. + */ + Completed = 1, + + /** + * The chat session is currently in progress. + */ + InProgress = 2 + } + + export namespace chat { + /** + * Registers a new {@link ChatSessionItemProvider chat session item provider}. + * + * To use this, also make sure to also add `chatSessions` contribution in the `package.json`. + * + * @param chatSessionType The type of chat session the provider is for. + * @param provider The provider to register. + * + * @returns A disposable that unregisters the provider when disposed. + */ + export function registerChatSessionItemProvider(chatSessionType: string, provider: ChatSessionItemProvider): Disposable; + + /** + * Creates a new {@link ChatSessionItemController chat session item controller} with the given unique identifier. + */ + export function createChatSessionItemController(id: string, refreshHandler: () => Thenable): ChatSessionItemController; + } + + /** + * Provides a list of information about chat sessions. + */ + export interface ChatSessionItemProvider { + /** + * Event that the provider can fire to signal that chat sessions have changed. + */ + 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. + */ + readonly onDidCommitChatSessionItem: Event<{ original: ChatSessionItem /** untitled */; modified: ChatSessionItem /** newly created */ }>; + + // #endregion + } + + /** + * Provides a list of information about chat sessions. + */ + export interface ChatSessionItemController { + readonly id: string; + + /** + * Unregisters the controller, disposing of its associated chat session items. + */ + dispose(): void; + + /** + * Managed collection of chat session items + */ + readonly items: ChatSessionItemCollection; + + /** + * Creates a new managed chat session item that be added to the collection. + */ + createChatSessionItem(resource: Uri, label: string): ChatSessionItem; + + /** + * Handler called to refresh the collection of chat session items. + * + * This is also called on first load to get the initial set of items. + */ + refreshHandler: () => Thenable; + + /** + * Fired when an item's archived state changes. + */ + readonly onDidChangeChatSessionItemState: Event; + } + + /** + * A collection of chat session items. It provides operations for managing and iterating over the items. + */ + export interface ChatSessionItemCollection extends Iterable { + /** + * Gets the number of items in the collection. + */ + readonly size: number; + + /** + * Replaces the items stored by the collection. + * @param items Items to store. + */ + replace(items: readonly ChatSessionItem[]): void; + + /** + * Iterate over each entry in this collection. + * + * @param callback Function to execute for each entry. + * @param thisArg The `this` context used when invoking the handler function. + */ + forEach(callback: (item: ChatSessionItem, collection: ChatSessionItemCollection) => unknown, thisArg?: any): void; + + /** + * Adds the chat session item to the collection. If an item with the same resource URI already + * exists, it'll be replaced. + * @param item Item to add. + */ + add(item: ChatSessionItem): void; + + /** + * Removes a single chat session item from the collection. + * @param resource Item resource to delete. + */ + delete(resource: Uri): void; + + /** + * Efficiently gets a chat session item by resource, if it exists, in the collection. + * @param resource Item resource to get. + * @returns The found item or undefined if it does not exist. + */ + get(resource: Uri): ChatSessionItem | undefined; + } + + export interface ChatSessionItem { + /** + * The resource associated with the chat session. + * + * This is uniquely identifies the chat session and is used to open the chat session. + */ + resource: Uri; + + /** + * Human readable name of the session shown in the UI + */ + label: string; + + /** + * An icon for the participant shown in UI. + */ + iconPath?: IconPath; + + /** + * An optional description that provides additional context about the chat session. + */ + description?: string | MarkdownString; + + /** + * An optional badge that provides additional context about the chat session. + */ + badge?: string | MarkdownString; + + /** + * An optional status indicating the current state of the session. + */ + status?: ChatSessionStatus; + + /** + * The tooltip text when you hover over this item. + */ + tooltip?: string | MarkdownString; + + /** + * Whether the chat session has been archived. + */ + archived?: boolean; + + /** + * Timing information for the chat session + */ + timing?: { + /** + * Timestamp when the session was created in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + */ + created: number; + + /** + * Timestamp when the most recent request started in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * + * Should be undefined if no requests have been made yet. + */ + lastRequestStarted?: number; + + /** + * Timestamp when the most recent request completed in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * + * Should be undefined if the most recent request is still in progress or if no requests have been made yet. + */ + lastRequestEnded?: number; + + /** + * Session start timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * @deprecated Use `created` and `lastRequestStarted` instead. + */ + startTime?: number; + + /** + * Session end timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * @deprecated Use `lastRequestEnded` instead. + */ + endTime?: number; + }; + + /** + * Statistics about the chat session. + */ + changes?: readonly ChatSessionChangedFile[] | readonly ChatSessionChangedFile2[] | { + /** + * Number of files edited during the session. + */ + files: number; + + /** + * Number of insertions made during the session. + */ + insertions: number; + + /** + * Number of deletions made during the session. + */ + deletions: number; + }; + } + + export class ChatSessionChangedFile { + /** + * URI of the file. + */ + modifiedUri: Uri; + + /** + * File opened when the user takes the 'compare' action. + */ + originalUri?: Uri; + + /** + * Number of insertions made during the session. + */ + insertions: number; + + /** + * Number of deletions made during the session. + */ + deletions: number; + + constructor(modifiedUri: Uri, insertions: number, deletions: number, originalUri?: Uri); + } + + export class ChatSessionChangedFile2 { + /** + * URI of the file. + */ + readonly uri: Uri; + + /** + * URI of the original file. Undefined if the file was created. + */ + readonly originalUri: Uri | undefined; + + /** + * URI of the modified file. Undefined if the file was deleted. + */ + readonly modifiedUri: Uri | undefined; + + /** + * Number of insertions made during the session. + */ + insertions: number; + + /** + * Number of deletions made during the session. + */ + deletions: number; + + constructor(uri: Uri, originalUri: Uri | undefined, modifiedUri: Uri | undefined, insertions: number, deletions: number); + } + + export interface ChatSession { + /** + * The full history of the session + * + * This should not include any currently active responses + */ + // TODO: Are these the right types to use? + // 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'). + * Values can be either: + * - A string (the option item ID) for backwards compatibility + * - A ChatSessionProviderOptionItem object to include metadata like locked state + * 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 + * callback resolved. + * + * If not provided, the chat session is assumed to not currently be running. + */ + readonly activeResponseCallback?: (stream: ChatResponseStream, token: CancellationToken) => Thenable; + + /** + * Handles new request for the session. + * + * If not set, then the session will be considered read-only and no requests can be made. + */ + // TODO: Should we introduce our own type for `ChatRequestHandler` since not all field apply to chat sessions? + // TODO: Revisit this to align with code. + readonly requestHandler: ChatRequestHandler | undefined; + } + + /** + * Event fired when chat session options change. + */ + export interface ChatSessionOptionChangeEvent { + /** + * Identifier of the chat session being updated. + */ + readonly resource: Uri; + /** + * Collection of option identifiers and their new values. Only the options that changed are included. + */ + readonly updates: ReadonlyArray<{ + /** + * 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 | ChatSessionProviderOptionItem; + }>; + } + + /** + * Provides the content for a chat session rendered using the native chat UI. + */ + export interface ChatSessionContentProvider { + /** + * Event that the provider can fire to signal that the options for a chat session have changed. + */ + readonly onDidChangeChatSessionOptions?: Event; + + /** + * Event that the provider can fire to signal that the available provider options have changed. + * + * When fired, the editor will re-query {@link ChatSessionContentProvider.provideChatSessionProviderOptions} + * and update the UI to reflect the new option groups. + */ + readonly onDidChangeChatSessionProviderOptions?: Event; + + /** + * Provides the chat session content for a given uri. + * + * The returned {@linkcode ChatSession} is used to populate the history of the chat UI. + * + * @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 + */ + provideChatSessionProviderOptions?(token: CancellationToken): Thenable; + } + + 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 { + /** + * Registers a new {@link ChatSessionContentProvider chat session content provider}. + * + * @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(scheme: string, provider: ChatSessionContentProvider, chatParticipant: ChatParticipant, capabilities?: ChatSessionCapabilities): Disposable; + } + + export interface ChatContext { + readonly chatSessionContext?: ChatSessionContext; + } + + export interface ChatSessionContext { + readonly chatSessionItem: ChatSessionItem; // Maps to URI of chat session editor (could be 'untitled-1', etc..) + readonly isUntitled: boolean; + } + + export interface ChatSessionCapabilities { + /** + * Whether sessions can be interrupted and resumed without side-effects. + */ + supportsInterruptions?: boolean; + } + + /** + * Represents a single selectable item within a provider option group. + */ + export interface ChatSessionProviderOptionItem { + /** + * Unique identifier for the option item. + */ + readonly id: string; + + /** + * Human-readable name displayed in the UI. + */ + readonly name: string; + + /** + * Optional description shown in tooltips. + */ + readonly description?: string; + + /** + * When true, this option is locked and cannot be changed by the user. + * The option will still be visible in the UI but will be disabled. + * Use this when an option is set but cannot be hot-swapped (e.g., model already initialized). + */ + readonly locked?: boolean; + + /** + * An icon for the option item shown in UI. + */ + readonly icon?: ThemeIcon; + + /** + * Indicates if this option should be selected by default. + * Only one item per option group should be marked as default. + */ + readonly default?: boolean; + } + + /** + * 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 items: ChatSessionProviderOptionItem[]; + + /** + * A context key expression that controls when this option group picker is visible. + * When specified, the picker is only shown when the expression evaluates to true. + * The expression can reference other option group values via `chatSessionOption.`. + * + * Example: `"chatSessionOption.models == 'gpt-4'"` - only show this picker when + * the 'models' option group has 'gpt-4' selected. + */ + readonly when?: string; + + /** + * When true, displays a searchable QuickPick with a "See more..." option. + * Recommended for option groups with additional async items (e.g., repositories). + */ + readonly searchable?: boolean; + + /** + * An icon for the option group shown in UI. + */ + readonly icon?: ThemeIcon; + + /** + * Handler for dynamic search when `searchable` is true. + * Called when the user types in the searchable QuickPick or clicks "See more..." to load additional items. + * + * @param query The search query entered by the user. Empty string for initial load. + * @param token A cancellation token. + * @returns Additional items to display in the searchable QuickPick. + */ + readonly onSearch?: (query: string, token: CancellationToken) => Thenable; + + /** + * Optional commands. + * + * These commands will be displayed at the bottom of the group. + */ + readonly commands?: Command[]; + } + + export interface ChatSessionProviderOptions { + /** + * Provider-defined option groups (0-2 groups supported). + * Examples: models picker, sub-agents picker, etc. + */ + optionGroups?: ChatSessionProviderOptionGroup[]; + } +} diff --git a/src/@types/vscode.proposed.commentsResolvedState.d.ts b/src/@types/vscode.proposed.codeActionRanges.d.ts similarity index 54% rename from src/@types/vscode.proposed.commentsResolvedState.d.ts rename to src/@types/vscode.proposed.codeActionRanges.d.ts index 95fb288dd2..350be2d553 100644 --- a/src/@types/vscode.proposed.commentsResolvedState.d.ts +++ b/src/@types/vscode.proposed.codeActionRanges.d.ts @@ -5,20 +5,11 @@ declare module 'vscode' { - // https://github.com/microsoft/vscode/issues/127473 - - /** - * The state of a comment thread. - */ - export enum CommentThreadState { - Unresolved = 0, - Resolved = 1 - } - - export interface CommentThread { + export interface CodeAction { /** - * The optional state of a comment thread, which may affect how the comment is displayed. + * The ranges to which this Code Action applies to, which will be highlighted. + * For example: A refactoring action will highlight the range of text that will be affected. */ - state?: CommentThreadState; + ranges?: Range[]; } } diff --git a/src/@types/vscode.proposed.commentReactor.d.ts b/src/@types/vscode.proposed.commentReactor.d.ts new file mode 100644 index 0000000000..25a433cd8c --- /dev/null +++ b/src/@types/vscode.proposed.commentReactor.d.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * 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' { + + // @alexr00 https://github.com/microsoft/vscode/issues/201131 + + export interface CommentReaction { + readonly reactors?: readonly CommentAuthorInformation[]; + } +} diff --git a/src/@types/vscode.proposed.commentReplyAuthor.d.ts b/src/@types/vscode.proposed.commentReplyAuthor.d.ts new file mode 100644 index 0000000000..d91462b7dd --- /dev/null +++ b/src/@types/vscode.proposed.commentReplyAuthor.d.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * 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' { + + // @alexr00 https://github.com/microsoft/vscode/issues/246088 + + export interface CommentThread2 { + canReply: boolean | CommentAuthorInformation; + + readonly uri: Uri; + range: Range | undefined; + comments: readonly Comment[]; + collapsibleState: CommentThreadCollapsibleState; + contextValue?: string; + label?: string; + state?: CommentThreadState | { resolved?: CommentThreadState; applicability?: CommentThreadApplicability }; + dispose(): void; + } +} diff --git a/src/@types/vscode.proposed.commentReveal.d.ts b/src/@types/vscode.proposed.commentReveal.d.ts new file mode 100644 index 0000000000..3aa005d0a9 --- /dev/null +++ b/src/@types/vscode.proposed.commentReveal.d.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * 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' { + + // @alexr00 https://github.com/microsoft/vscode/issues/167253 + + export enum CommentThreadFocus { + /** + * Focus the comment editor if the thread supports replying. + */ + Reply = 1, + /** + * Focus the revealed comment. + */ + Comment = 2 + } + + /** + * Options to reveal a comment thread in an editor. + */ + export interface CommentThreadRevealOptions { + + /** + * Where to move the focus to when revealing the comment thread. + * If undefined, the focus will not be changed. + */ + focus?: CommentThreadFocus; + } + + export interface CommentThread2 { + /** + * Reveal the comment thread in an editor. If no comment is provided, the first comment in the thread will be revealed. + */ + reveal(comment?: Comment, options?: CommentThreadRevealOptions): Thenable; + + /** + * Collapse the comment thread in an editor. + */ + hide(): Thenable; + } + +} diff --git a/src/@types/vscode.proposed.commentThreadApplicability.d.ts b/src/@types/vscode.proposed.commentThreadApplicability.d.ts new file mode 100644 index 0000000000..772771eef7 --- /dev/null +++ b/src/@types/vscode.proposed.commentThreadApplicability.d.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * 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' { + + // @alexr00 https://github.com/microsoft/vscode/issues/207402 + + export enum CommentThreadApplicability { + Current = 0, + Outdated = 1 + } + + export interface CommentThread2 { + /* @api this is a bit weird for the extension now. The CommentThread is a managed object, which means it listens + * to when it's properties are set, but not if it's properties are modified. This means that this will not work to update the resolved state + * + * thread.state.resolved = CommentThreadState.Resolved; + * + * but this will work + * + * thread.state = { + * resolved: CommentThreadState.Resolved + * applicability: thread.state.applicability + * }; + * + * Worth noting that we already have this problem for the `comments` property. + */ + state?: CommentThreadState | { resolved?: CommentThreadState; applicability?: CommentThreadApplicability }; + readonly uri: Uri; + range: Range | undefined; + comments: readonly Comment[]; + collapsibleState: CommentThreadCollapsibleState; + canReply: boolean | CommentAuthorInformation; + contextValue?: string; + label?: string; + dispose(): void; + } +} diff --git a/src/@types/vscode.proposed.commentingRangeHint.d.ts b/src/@types/vscode.proposed.commentingRangeHint.d.ts new file mode 100644 index 0000000000..595e4f0f62 --- /dev/null +++ b/src/@types/vscode.proposed.commentingRangeHint.d.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * 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' { + + // @alexr00 https://github.com/microsoft/vscode/issues/185551 + + /** + * Commenting range provider for a {@link CommentController comment controller}. + */ + export interface CommentingRangeProvider { + readonly resourceHints?: { schemes: readonly string[] }; + } +} diff --git a/src/@types/vscode.proposed.commentTimestamp.d.ts b/src/@types/vscode.proposed.commentsDraftState.d.ts similarity index 70% rename from src/@types/vscode.proposed.commentTimestamp.d.ts rename to src/@types/vscode.proposed.commentsDraftState.d.ts index 2867dcbfea..ee41130910 100644 --- a/src/@types/vscode.proposed.commentTimestamp.d.ts +++ b/src/@types/vscode.proposed.commentsDraftState.d.ts @@ -4,11 +4,15 @@ *--------------------------------------------------------------------------------------------*/ declare module 'vscode' { + + // https://github.com/microsoft/vscode/issues/171166 + + export enum CommentState { + Published = 0, + Draft = 1 + } + export interface Comment { - /** - * An optional timestamp that will be displayed in comments. - * The date will be formatted according to the user's locale and settings. - */ - timestamp?: Date; + state?: CommentState; } } diff --git a/src/@types/vscode.proposed.contribAccessibilityHelpContent.d.ts b/src/@types/vscode.proposed.contribAccessibilityHelpContent.d.ts new file mode 100644 index 0000000000..c04a0c65e5 --- /dev/null +++ b/src/@types/vscode.proposed.contribAccessibilityHelpContent.d.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. + *--------------------------------------------------------------------------------------------*/ + +// empty placeholder declaration for the `accessibilityHelpContent`-property of the `views`-contribution + +// https://github.com/microsoft/vscode/issues/209855 @meganrogge + +/** + * View contributions can include an `accessibilityHelpContent` property that provides help content for screen readers + * when the accessibility help dialog is invoked by the user with focus in the view. + * + * The content is provided as a markdown string and can contain commands that will be resolved along with their keybindings. + */ diff --git a/src/@types/vscode.proposed.contribCommentsViewThreadMenus.d.ts b/src/@types/vscode.proposed.contribCommentsViewThreadMenus.d.ts new file mode 100644 index 0000000000..9dc199c51c --- /dev/null +++ b/src/@types/vscode.proposed.contribCommentsViewThreadMenus.d.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// empty placeholder declaration for the `commentsView/commentThread/context` menu contribution point diff --git a/src/@types/vscode.proposed.contribEditorContentMenu.d.ts b/src/@types/vscode.proposed.contribEditorContentMenu.d.ts new file mode 100644 index 0000000000..6b45f3468b --- /dev/null +++ b/src/@types/vscode.proposed.contribEditorContentMenu.d.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// empty placeholder declaration for the `editor/content` menu diff --git a/src/@types/vscode.proposed.contribShareMenu.d.ts b/src/@types/vscode.proposed.contribShareMenu.d.ts index a38d03f4fd..e308029d4e 100644 --- a/src/@types/vscode.proposed.contribShareMenu.d.ts +++ b/src/@types/vscode.proposed.contribShareMenu.d.ts @@ -4,3 +4,4 @@ *--------------------------------------------------------------------------------------------*/ // empty placeholder declaration for the `file/share`-submenu contribution point +// https://github.com/microsoft/vscode/issues/176316 diff --git a/src/@types/vscode.proposed.contribViewSize.d.ts b/src/@types/vscode.proposed.contribViewSize.d.ts deleted file mode 100644 index 01ec28d95f..0000000000 --- a/src/@types/vscode.proposed.contribViewSize.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// empty placeholder for view size - -// https://github.com/microsoft/vscode/issues/122283 @alexr00 - -/** - * View contributions can include a `size`, which can be a number. A number works similar to the css flex property. - * - * For example, if you have 3 views, with sizes 1, 1, and 2, the views of size 1 will together take up the same amount of space as the view of size 2. - * - * A number value will only be used as an initial size. After a user has changed the size of the view, the user's choice will be restored. -*/ - diff --git a/src/@types/vscode.proposed.fileComments.d.ts b/src/@types/vscode.proposed.fileComments.d.ts new file mode 100644 index 0000000000..96e9b181bc --- /dev/null +++ b/src/@types/vscode.proposed.fileComments.d.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * 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' { + + export interface CommentThread2 { + /** + * The range the comment thread is located within the document. The thread icon will be shown + * at the last line of the range. When set to undefined, the comment will be associated with the + * file, and not a specific range. + */ + range: Range | undefined; + } + + /** + * The ranges a CommentingRangeProvider enables commenting on. + */ + export interface CommentingRanges { + /** + * Enables comments to be added to a file without a specific range. + */ + enableFileComments: boolean; + + /** + * The ranges which allow new comment threads creation. + */ + ranges?: Range[]; + } + + export interface CommentController { + createCommentThread(uri: Uri, range: Range | undefined, comments: readonly Comment[]): CommentThread | CommentThread2; + } + + export interface CommentingRangeProvider2 { + /** + * Provide a list of ranges which allow new comment threads creation or null for a given document + */ + provideCommentingRanges(document: TextDocument, token: CancellationToken): ProviderResult; + } +} diff --git a/src/@types/vscode.proposed.languageModelDataPart.d.ts b/src/@types/vscode.proposed.languageModelDataPart.d.ts new file mode 100644 index 0000000000..4d491a66ca --- /dev/null +++ b/src/@types/vscode.proposed.languageModelDataPart.d.ts @@ -0,0 +1,164 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// version: 3 + +declare module 'vscode' { + + export interface LanguageModelChat { + sendRequest(messages: Array, options?: LanguageModelChatRequestOptions, token?: CancellationToken): Thenable; + countTokens(text: string | LanguageModelChatMessage | LanguageModelChatMessage2, token?: CancellationToken): Thenable; + } + + /** + * Represents a message in a chat. Can assume different roles, like user or assistant. + */ + export class LanguageModelChatMessage2 { + + /** + * Utility to create a new user message. + * + * @param content The content of the message. + * @param name The optional name of a user for the message. + */ + static User(content: string | Array, name?: string): LanguageModelChatMessage2; + + /** + * Utility to create a new assistant message. + * + * @param content The content of the message. + * @param name The optional name of a user for the message. + */ + static Assistant(content: string | Array, name?: string): LanguageModelChatMessage2; + + /** + * The role of this message. + */ + role: LanguageModelChatMessageRole; + + /** + * A string or heterogeneous array of things that a message can contain as content. Some parts may be message-type + * specific for some models. + */ + content: Array; + + /** + * The optional name of a user for this message. + */ + name: string | undefined; + + /** + * Create a new user message. + * + * @param role The role of the message. + * @param content The content of the message. + * @param name The optional name of a user for the message. + */ + constructor(role: LanguageModelChatMessageRole, content: string | Array, name?: string); + } + + /** + * A language model response part containing arbitrary data, returned from a {@link LanguageModelChatResponse}. + */ + export class LanguageModelDataPart { + /** + * Factory function to create a `LanguageModelDataPart` for an image. + * @param data Binary image data + * @param mimeType The MIME type of the image + */ + // TODO@API just use string, no enum required + static image(data: Uint8Array, mimeType: ChatImageMimeType): LanguageModelDataPart; + + static json(value: any, mime?: string): LanguageModelDataPart; + + static text(value: string, mime?: string): LanguageModelDataPart; + + /** + * The mime type which determines how the data property is interpreted. + */ + mimeType: string; + + /** + * The data of the part. + */ + data: Uint8Array; + + /** + * Construct a generic data part with the given content. + * @param value The data of the part. + */ + constructor(data: Uint8Array, mimeType: string); + } + + /** + * Enum for supported image MIME types. + */ + export enum ChatImageMimeType { + PNG = 'image/png', + JPEG = 'image/jpeg', + GIF = 'image/gif', + WEBP = 'image/webp', + BMP = 'image/bmp', + } + + /** + * The result of a tool call. This is the counterpart of a {@link LanguageModelToolCallPart tool call} and + * it can only be included in the content of a User message + */ + export class LanguageModelToolResultPart2 { + /** + * The ID of the tool call. + * + * *Note* that this should match the {@link LanguageModelToolCallPart.callId callId} of a tool call part. + */ + callId: string; + + /** + * The value of the tool result. + */ + content: Array; + + /** + * @param callId The ID of the tool call. + * @param content The content of the tool result. + */ + constructor(callId: string, content: Array); + } + + + /** + * A tool that can be invoked by a call to a {@link LanguageModelChat}. + */ + export interface LanguageModelTool { + /** + * Invoke the tool with the given input and return a result. + * + * The provided {@link LanguageModelToolInvocationOptions.input} has been validated against the declared schema. + */ + invoke(options: LanguageModelToolInvocationOptions, token: CancellationToken): ProviderResult; + } + + /** + * A result returned from a tool invocation. If using `@vscode/prompt-tsx`, this result may be rendered using a `ToolResult`. + */ + export class LanguageModelToolResult2 { + /** + * A list of tool result content parts. Includes `unknown` becauses this list may be extended with new content types in + * the future. + * @see {@link lm.invokeTool}. + */ + content: Array; + + /** + * Create a LanguageModelToolResult + * @param content A list of tool result content parts + */ + constructor(content: Array); + } + + export namespace lm { + export function invokeTool(name: string, options: LanguageModelToolInvocationOptions, token?: CancellationToken): Thenable; + } +} diff --git a/src/@types/vscode.proposed.languageModelToolResultAudience.d.ts b/src/@types/vscode.proposed.languageModelToolResultAudience.d.ts new file mode 100644 index 0000000000..07b32b02bb --- /dev/null +++ b/src/@types/vscode.proposed.languageModelToolResultAudience.d.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * 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' { + + export enum LanguageModelPartAudience { + /** + * The part should be shown to the language model. + */ + Assistant = 0, + /** + * The part should be shown to the user. + */ + User = 1, + /** + * The part should should be retained for internal bookkeeping within + * extensions. + */ + Extension = 2, + } + + /** + * A language model response part containing a piece of text, returned from a {@link LanguageModelChatResponse}. + */ + export class LanguageModelTextPart2 extends LanguageModelTextPart { + audience: LanguageModelPartAudience[] | undefined; + constructor(value: string, audience?: LanguageModelPartAudience[]); + } + + export class LanguageModelDataPart2 extends LanguageModelDataPart { + audience: LanguageModelPartAudience[] | undefined; + constructor(data: Uint8Array, mimeType: string, audience?: LanguageModelPartAudience[]); + } +} diff --git a/src/@types/vscode.proposed.localization.d.ts b/src/@types/vscode.proposed.localization.d.ts deleted file mode 100644 index 5bae5d5b35..0000000000 --- a/src/@types/vscode.proposed.localization.d.ts +++ /dev/null @@ -1,78 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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' { - /** - * The namespace for localization-related functionality in the extension API. To use this properly, - * you must have `l10n` defined in your `package.json` and have bundle.l10n..json files. - * For more information on how to generate bundle.l10n..json files, check out the - * [vscode-l10n repo](https://github.com/microsoft/vscode-l10n). - */ - export namespace l10n { - /** - * Marks a string for localization. If a localized bundle is available for the language specified by - * {@link env.language} and the bundle has a localized value for this message, then that localized - * value will be returned (with injected {@link args} values for any templated values). - * @param message The message to localize. Supports index templating where strings like {0} and {1} are - * replaced by the item at that index in the {@link args} array. - * @param args The arguments to be used in the localized string. The index of the argument is used to - * match the template placeholder in the localized string. - * @returns localized string with injected arguments. - * @example l10n.localize('hello', 'Hello {0}!', 'World'); - */ - export function t(message: string, ...args: Array): string; - - /** - * Marks a string for localization. If a localized bundle is available for the language specified by - * {@link env.language} and the bundle has a localized value for this message, then that localized - * value will be returned (with injected {@link args} values for any templated values). - * @param message The message to localize. Supports named templating where strings like {foo} and {bar} are - * replaced by the value in the Record for that key (foo, bar, etc). - * @param args The arguments to be used in the localized string. The name of the key in the record is used to - * match the template placeholder in the localized string. - * @returns localized string with injected arguments. - * @example l10n.t('Hello {name}', { name: 'Erich' }); - */ - export function t(message: string, args: Record): string; - /** - * Marks a string for localization. If a localized bundle is available for the language specified by - * {@link env.language} and the bundle has a localized value for this message, then that localized - * value will be returned (with injected args values for any templated values). - * @param options The options to use when localizing the message. - * @returns localized string with injected arguments. - */ - export function t(options: { - /** - * The message to localize. If {@link args} is an array, this message supports index templating where strings like - * {0} and {1} are replaced by the item at that index in the {@link args} array. If args is a Record, - * this supports named templating where strings like {foo} and {bar} are replaced by the value in - * the Record for that key (foo, bar, etc). - */ - message: string; - /** - * The arguments to be used in the localized string. As an array, the index of the argument is used to - * match the template placeholder in the localized string. As a Record, the key is used to match the template - * placeholder in the localized string. - */ - args?: Array | Record; - /** - * A comment to help translators understand the context of the message. - */ - comment: string[]; - }): string; - /** - * The bundle of localized strings that have been loaded for the extension. - * It's undefined if no bundle has been loaded. The bundle is typically not loaded if - * there was no bundle found or when we are running with the default language. - */ - export const bundle: { [key: string]: string } | undefined; - /** - * The URI of the localization bundle that has been loaded for the extension. - * It's undefined if no bundle has been loaded. The bundle is typically not loaded if - * there was no bundle found or when we are running with the default language. - */ - export const uri: Uri | undefined; - } -} 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.quickDiffProvider.d.ts b/src/@types/vscode.proposed.quickDiffProvider.d.ts index c0c0078f56..43f4c93599 100644 --- a/src/@types/vscode.proposed.quickDiffProvider.d.ts +++ b/src/@types/vscode.proposed.quickDiffProvider.d.ts @@ -8,6 +8,15 @@ declare module 'vscode' { // https://github.com/microsoft/vscode/issues/169012 export namespace window { - export function registerQuickDiffProvider(selector: DocumentSelector, quickDiffProvider: QuickDiffProvider, label: string, rootUri?: Uri): Disposable; + export function registerQuickDiffProvider(selector: DocumentSelector, quickDiffProvider: QuickDiffProvider, id: string, label: string, rootUri?: Uri): Disposable; + } + + export interface SourceControl { + secondaryQuickDiffProvider?: QuickDiffProvider; + } + + export interface QuickDiffProvider { + readonly id?: string; + readonly label?: string; } } diff --git a/src/@types/vscode.proposed.readonlyMessage.d.ts b/src/@types/vscode.proposed.readonlyMessage.d.ts new file mode 100644 index 0000000000..03bad3a664 --- /dev/null +++ b/src/@types/vscode.proposed.readonlyMessage.d.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// https://github.com/microsoft/vscode/issues/166971 + +declare module 'vscode' { + + export namespace workspace { + + export function registerFileSystemProvider(scheme: string, provider: FileSystemProvider, options?: { readonly isCaseSensitive?: boolean; readonly isReadonly?: boolean | MarkdownString }): Disposable; + } +} diff --git a/src/@types/vscode.proposed.remoteCodingAgents.d.ts b/src/@types/vscode.proposed.remoteCodingAgents.d.ts new file mode 100644 index 0000000000..e818b76d8e --- /dev/null +++ b/src/@types/vscode.proposed.remoteCodingAgents.d.ts @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// empty placeholder for coding agent contribution point from core + +// @joshspicer diff --git a/src/@types/vscode.proposed.shareProvider.d.ts b/src/@types/vscode.proposed.shareProvider.d.ts new file mode 100644 index 0000000000..8c432341a7 --- /dev/null +++ b/src/@types/vscode.proposed.shareProvider.d.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// https://github.com/microsoft/vscode/issues/176316 @joyceerhl + +declare module 'vscode' { + + /** + * Data about an item which can be shared. + */ + export interface ShareableItem { + /** + * A resource in the workspace that can be shared. + */ + resourceUri: Uri; + + /** + * If present, a selection within the `resourceUri`. + */ + selection?: Range; + } + + /** + * A provider which generates share links for resources in the editor. + */ + export interface ShareProvider { + + /** + * A unique ID for the provider. + * This will be used to activate specific extensions contributing share providers if necessary. + */ + readonly id: string; + + /** + * A label which will be used to present this provider's options in the UI. + */ + readonly label: string; + + /** + * The order in which the provider should be listed in the UI when there are multiple providers. + */ + readonly priority: number; + + /** + * + * @param item Data about an item which can be shared. + * @param token A cancellation token. + * @returns A {@link Uri} representing an external link or sharing text. The provider result + * will be copied to the user's clipboard and presented in a confirmation dialog. + */ + provideShare(item: ShareableItem, token: CancellationToken): ProviderResult; + } + + export namespace window { + + /** + * Register a share provider. An extension may register multiple share providers. + * There may be multiple share providers for the same {@link ShareableItem}. + * @param selector A document selector to filter whether the provider should be shown for a {@link ShareableItem}. + * @param provider A share provider. + */ + export function registerShareProvider(selector: DocumentSelector, provider: ShareProvider): Disposable; + } + + export interface TreeItem { + + /** + * An optional property which, when set, inlines a `Share` option in the context menu for this tree item. + */ + shareableItem?: ShareableItem; + } +} diff --git a/src/@types/vscode.proposed.tabInputMultiDiff.d.ts b/src/@types/vscode.proposed.tabInputMultiDiff.d.ts new file mode 100644 index 0000000000..242ebf4ada --- /dev/null +++ b/src/@types/vscode.proposed.tabInputMultiDiff.d.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// https://github.com/microsoft/vscode/issues/206411 + +declare module 'vscode' { + + export class TabInputTextMultiDiff { + + readonly textDiffs: TabInputTextDiff[]; + + constructor(textDiffs: TabInputTextDiff[]); + } + + export interface Tab { + + readonly input: TabInputText | TabInputTextDiff | TabInputTextMultiDiff | TabInputCustom | TabInputWebview | TabInputNotebook | TabInputNotebookDiff | TabInputTerminal | unknown; + + } +} diff --git a/src/@types/vscode.proposed.tabInputTextMerge.d.ts b/src/@types/vscode.proposed.tabInputTextMerge.d.ts new file mode 100644 index 0000000000..da95fd1d35 --- /dev/null +++ b/src/@types/vscode.proposed.tabInputTextMerge.d.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// https://github.com/microsoft/vscode/issues/153213 + +declare module 'vscode' { + + export class TabInputTextMerge { + + readonly base: Uri; + readonly input1: Uri; + readonly input2: Uri; + readonly result: Uri; + + constructor(base: Uri, input1: Uri, input2: Uri, result: Uri); + } + + export interface Tab { + + readonly input: TabInputText | TabInputTextDiff | TabInputTextMerge | TabInputCustom | TabInputWebview | TabInputNotebook | TabInputNotebookDiff | TabInputTerminal | unknown; + + } +} diff --git a/src/@types/vscode.proposed.treeItemCheckbox.d.ts b/src/@types/vscode.proposed.treeItemCheckbox.d.ts deleted file mode 100644 index 74337f3990..0000000000 --- a/src/@types/vscode.proposed.treeItemCheckbox.d.ts +++ /dev/null @@ -1,45 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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' { - - export class TreeItem2 extends TreeItem { - /** - * [TreeItemCheckboxState](#TreeItemCheckboxState) of the tree item. - */ - checkboxState?: TreeItemCheckboxState | { readonly state: TreeItemCheckboxState; readonly tooltip?: string }; - } - - /** - * Checkbox state of the tree item - */ - export enum TreeItemCheckboxState { - /** - * Determines an item is unchecked - */ - Unchecked = 0, - /** - * Determines an item is checked - */ - Checked = 1 - } - - /** - * A data provider that provides tree data - */ - export interface TreeView { - /** - * An event to signal that an element or root has either been checked or unchecked. - */ - onDidChangeCheckboxState: Event>; - } - - export interface TreeCheckboxChangeEvent { - /** - * The item that was checked or unchecked. - */ - readonly items: ReadonlyArray<[T, TreeItemCheckboxState]>; - } -} 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/@types/vscode.proposed.treeViewMarkdownMessage.d.ts b/src/@types/vscode.proposed.treeViewMarkdownMessage.d.ts new file mode 100644 index 0000000000..ad4655d9bc --- /dev/null +++ b/src/@types/vscode.proposed.treeViewMarkdownMessage.d.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * 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' { + + export interface TreeView2 extends Disposable { + readonly onDidExpandElement: Event>; + readonly onDidCollapseElement: Event>; + readonly selection: readonly T[]; + readonly onDidChangeSelection: Event>; + readonly visible: boolean; + readonly onDidChangeVisibility: Event; + readonly onDidChangeCheckboxState: Event>; + title?: string; + description?: string; + badge?: ViewBadge | undefined; + reveal(element: T, options?: { select?: boolean; focus?: boolean; expand?: boolean | number }): Thenable; + + /** + * An optional human-readable message that will be rendered in the view. + * Only a subset of markdown is supported. + * Setting the message to null, undefined, or empty string will remove the message from the view. + */ + message?: string | MarkdownString; + } +} diff --git a/src/api/api.d.ts b/src/api/api.d.ts index 85c2a14319..011cbd9cfa 100644 --- a/src/api/api.d.ts +++ b/src/api/api.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, Event, Uri } from 'vscode'; +import { CancellationToken, Disposable, Event, Uri } from 'vscode'; import { APIState, PublishEvent } from '../@types/git'; export interface InputBox { @@ -53,27 +53,7 @@ export interface Remote { readonly isReadOnly: boolean; } -export const enum Status { - INDEX_MODIFIED, - INDEX_ADDED, - INDEX_DELETED, - INDEX_RENAMED, - INDEX_COPIED, - - MODIFIED, - DELETED, - UNTRACKED, - IGNORED, - INTENT_TO_ADD, - - ADDED_BY_US, - ADDED_BY_THEM, - DELETED_BY_US, - DELETED_BY_THEM, - BOTH_ADDED, - BOTH_DELETED, - BOTH_MODIFIED, -} +export { Status } from './api1'; export interface Change { /** @@ -173,6 +153,7 @@ export interface Repository { * The counterpart of `getConfig` */ 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 }>; @@ -203,10 +184,11 @@ export interface Repository { deleteBranch(name: string, force?: boolean): Promise; getBranch(name: string): Promise; getBranches(query: BranchQuery): Promise; + getBranchBase(name: string): Promise; setBranchUpstream(name: string, upstream: string): Promise; - getRefs(query: RefQuery, cancellationToken?: CancellationToken): 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; @@ -225,6 +207,8 @@ export interface Repository { commit(message: string, opts?: CommitOptions): Promise; add(paths: string[]): Promise; + merge(ref: string): Promise; + mergeAbort(): Promise; } /** @@ -234,6 +218,8 @@ 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; } export interface PostCommitCommandsProvider { @@ -253,6 +239,36 @@ 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 }[], template?: string }, token: CancellationToken): Promise<{ title: string, description?: string } | undefined>; +} + +export interface ReviewerComments { + // To tell which files we should add a comment icon in the "Files Changed" view + files: Uri[]; + succeeded: boolean; + // For removing comments + disposable?: Disposable; +} + +export interface ReviewerCommentsProvider { + provideReviewerComments(context: { repositoryRoot: string, commitMessages: string[], patches: { patch: string, fileUri: string, previousFileUri?: string }[] }, token: CancellationToken): Promise; +} + +export interface RepositoryDescription { + owner: string; + repositoryName: string; + defaultBranch: string; + pullRequest?: { + title: string; + url: string; + number: number; + id: number; + }; } export interface API { @@ -268,4 +284,23 @@ export interface API { * @return A git provider or `undefined` */ getGitProvider(uri: Uri): IGit | undefined; + + /** + * Register a PR title and description provider. + */ + registerTitleAndDescriptionProvider(title: string, provider: TitleAndDescriptionProvider): Disposable; + + /** + * Register a PR reviewer comments provider. + */ + registerReviewerCommentsProvider(title: string, provider: ReviewerCommentsProvider): Disposable; + + /** + * Get the repository description for a given URI, where the URI is a subpath of one of the workspace folders. + * This includes the owner, repository name, default branch, + * and pull request information (if applicable). + * + * @returns A promise that resolves to a `RepositoryDescription` object or `undefined` if no repository is found. + */ + getRepositoryDescription(uri: vscode.Uri): Promise; } diff --git a/src/api/api1.ts b/src/api/api1.ts index 53c8556081..4eabe95f06 100644 --- a/src/api/api1.ts +++ b/src/api/api1.ts @@ -4,10 +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 { API, IGit, PostCommitCommandsProvider, Repository } from './api'; +import { RepositoriesManager } from '../github/repositoriesManager'; export const enum RefType { Head, @@ -50,10 +52,57 @@ export const enum GitErrorCodes { PatchDoesNotApply = 'PatchDoesNotApply', } -export class GitApiImpl implements API, IGit, vscode.Disposable { +export const enum Status { + INDEX_MODIFIED, + INDEX_ADDED, + INDEX_DELETED, + INDEX_RENAMED, + INDEX_COPIED, + + MODIFIED, + DELETED, + UNTRACKED, + IGNORED, + INTENT_TO_ADD, + + ADDED_BY_US, + ADDED_BY_THEM, + DELETED_BY_US, + DELETED_BY_THEM, + BOTH_ADDED, + BOTH_DELETED, + BOTH_MODIFIED, +} + +export class GitApiImpl extends Disposable implements API, IGit { + static readonly ID = 'GitAPI'; private static _handlePool: number = 0; private _providers = new Map(); + public constructor( + private readonly repositoriesManager: RepositoriesManager) { + 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[] = []; @@ -89,11 +138,6 @@ export class GitApiImpl implements API, IGit, vscode.Disposable { private _onDidPublish = new vscode.EventEmitter(); readonly onDidPublish: vscode.Event = this._onDidPublish.event; - private _disposables: vscode.Disposable[]; - constructor() { - this._disposables = []; - } - private _updateReposContext() { const reposCount = Array.from(this._providers.values()).reduce((prev, current) => { return prev + current.repositories.length; @@ -102,21 +146,21 @@ export class GitApiImpl implements API, IGit, vscode.Disposable { } registerGitProvider(provider: IGit): vscode.Disposable { - Logger.appendLine(`Registering git provider`); + Logger.appendLine(`Registering git provider`, GitApiImpl.ID); const handle = this._nextHandle(); this._providers.set(handle, provider); - this._disposables.push(provider.onDidCloseRepository(e => this._onDidCloseRepository.fire(e))); - this._disposables.push(provider.onDidOpenRepository(e => { - Logger.appendLine(`Repository ${e.rootUri} has been opened`); + this._register(provider.onDidCloseRepository(e => this._onDidCloseRepository.fire(e))); + this._register(provider.onDidOpenRepository(e => { + Logger.appendLine(`Repository ${e.rootUri} has been opened`, GitApiImpl.ID); this._updateReposContext(); this._onDidOpenRepository.fire(e); })); if (provider.onDidChangeState) { - this._disposables.push(provider.onDidChangeState(e => this._onDidChangeState.fire(e))); + this._register(provider.onDidChangeState(e => this._onDidChangeState.fire(e))); } if (provider.onDidPublish) { - this._disposables.push(provider.onDidPublish(e => this._onDidPublish.fire(e))); + this._register(provider.onDidPublish(e => this._onDidPublish.fire(e))); } this._updateReposContext(); @@ -166,7 +210,61 @@ export class GitApiImpl implements API, IGit, vscode.Disposable { return GitApiImpl._handlePool++; } - dispose() { - this._disposables.forEach(disposable => disposable.dispose()); + private _titleAndDescriptionProviders: Set<{ title: string, provider: TitleAndDescriptionProvider }> = new Set(); + registerTitleAndDescriptionProvider(title: string, provider: TitleAndDescriptionProvider): vscode.Disposable { + const registeredValue = { title, provider }; + this._titleAndDescriptionProviders.add(registeredValue); + const disposable = this._register({ + dispose: () => this._titleAndDescriptionProviders.delete(registeredValue) + }); + return disposable; + } + + getTitleAndDescriptionProvider(searchTerm?: string): { title: string, provider: TitleAndDescriptionProvider } | undefined { + if (!searchTerm) { + return this._titleAndDescriptionProviders.size > 0 ? this._titleAndDescriptionProviders.values().next().value : undefined; + } else { + for (const provider of this._titleAndDescriptionProviders) { + if (provider.title.toLowerCase().includes(searchTerm.toLowerCase())) { + return provider; + } + } + } + } + + private _reviewerCommentsProviders: Set<{ title: string, provider: ReviewerCommentsProvider }> = new Set(); + registerReviewerCommentsProvider(title: string, provider: ReviewerCommentsProvider): vscode.Disposable { + const registeredValue = { title, provider }; + this._reviewerCommentsProviders.add(registeredValue); + const disposable = this._register({ + dispose: () => this._reviewerCommentsProviders.delete(registeredValue) + }); + return disposable; + } + + getReviewerCommentsProvider(): { title: string, provider: ReviewerCommentsProvider } | undefined { + return this._reviewerCommentsProviders.size > 0 ? this._reviewerCommentsProviders.values().next().value : undefined; + } + + async getRepositoryDescription(uri: vscode.Uri) { + const folderManagerForRepo = this.repositoriesManager.getManagerForFile(uri); + + if (folderManagerForRepo && folderManagerForRepo.gitHubRepositories.length > 0) { + const repositoryMetadata = await folderManagerForRepo.gitHubRepositories[0].getMetadata(); + const pullRequest = folderManagerForRepo.activePullRequest; + if (repositoryMetadata) { + return { + owner: repositoryMetadata.owner.login, + repositoryName: repositoryMetadata.name, + defaultBranch: repositoryMetadata.default_branch, + pullRequest: pullRequest ? { + title: pullRequest.title, + url: pullRequest.html_url, + number: pullRequest.number, + id: pullRequest.id + } : undefined + }; + } + } } } diff --git a/src/authentication/githubServer.ts b/src/authentication/githubServer.ts index 6f9dd03ffa..993fc9cda2 100644 --- a/src/authentication/githubServer.ts +++ b/src/authentication/githubServer.ts @@ -5,16 +5,17 @@ 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'); - private static readonly _neverGitHubServers = new Set().add('bitbucket.org').add('gitlab.com'); - private _servers: Map = new Map(Array.from(GitHubManager._githubDotComServers.keys()).map(key => [key, GitHubServerType.GitHubDotCom])); + private static readonly _gheServers = new Set().add('ghe.com'); + private static readonly _neverGitHubServers = new Set().add('bitbucket.org').add('gitlab.com').add('codeberg.org'); + private _knownServers: Map = new Map([...Array.from(GitHubManager._githubDotComServers.keys()).map(key => [key, GitHubServerType.GitHubDotCom]), ...Array.from(GitHubManager._gheServers.keys()).map(key => [key, GitHubServerType.Enterprise])] as [string, GitHubServerType][]); public static isGithubDotCom(host: string): boolean { return this._githubDotComServers.has(host); @@ -28,23 +29,26 @@ export class GitHubManager { if (host === null) { return GitHubServerType.None; } + const authority = host.authority.toLowerCase(); // .wiki/.git repos are not supported - if (host.path.endsWith('.wiki') || host.authority.match(/gist[.]github[.]com/)) { + if (host.path.endsWith('.wiki') || authority.match(/gist[.]github[.]com/)) { return GitHubServerType.None; } - if (GitHubManager.isGithubDotCom(host.authority)) { + if (GitHubManager.isGithubDotCom(authority)) { return GitHubServerType.GitHubDotCom; } + const matchingKnownServer = Array.from(this._knownServers.keys()).find(server => authority.endsWith(server)); + const knownEnterprise = getEnterpriseUri(); - if ((host.authority.toLowerCase() === knownEnterprise?.authority.toLowerCase()) && (!this._servers.has(host.authority) || (this._servers.get(host.authority) === GitHubServerType.None))) { + if ((host.authority.toLowerCase() === knownEnterprise?.authority.toLowerCase()) && (!matchingKnownServer || (this._knownServers.get(matchingKnownServer) === GitHubServerType.None))) { return GitHubServerType.Enterprise; } - if (this._servers.has(host.authority)) { - return this._servers.get(host.authority) ?? GitHubServerType.None; + if (matchingKnownServer) { + return this._knownServers.get(matchingKnownServer) ?? GitHubServerType.None; } const [uri, options] = await GitHubManager.getOptions(host, 'HEAD', '/rate_limit'); @@ -85,7 +89,7 @@ export class GitHubManager { return isGitHub; } finally { Logger.debug(`Host ${host} is associated with GitHub: ${isGitHub}`, 'GitHubServer'); - this._servers.set(host.authority, isGitHub); + this._knownServers.set(authority, isGitHub); } } diff --git a/src/commands.ts b/src/commands.ts index 218e563c74..a45f9d8d7e 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -8,28 +8,41 @@ import * as pathLib from 'path'; import * as vscode from 'vscode'; import { Repository } from './api/api'; import { GitErrorCodes } from './api/api1'; -import { CommentReply, resolveCommentHandler } from './commentHandlerResolver'; -import { IComment } from './common/comment'; +import { CommentReply, findActiveHandler, resolveCommentHandler } from './commentHandlerResolver'; +import { commands } from './common/executeCommands'; import Logger from './common/logger'; +import { FILE_LIST_LAYOUT, HIDE_VIEWED_FILES, PR_SETTINGS_NAMESPACE } from './common/settingKeys'; +import { editQuery } from './common/settingsUtils'; import { ITelemetry } from './common/telemetry'; -import { asImageDataURI, fromReviewUri, Schemes } from './common/uri'; +import { SessionLinkInfo } from './common/timelineEvent'; +import { asTempStorageURI, fromPRUri, fromReviewUri, Schemes, toPRUri } from './common/uri'; import { formatError } from './common/utils'; import { EXTENSION_ID } from './constants'; +import { CrossChatSessionWithPR } from './github/copilotApi'; +import { CopilotRemoteAgentManager, SessionIdForPr } from './github/copilotRemoteAgent'; import { FolderRepositoryManager } from './github/folderRepositoryManager'; import { GitHubRepository } from './github/githubRepository'; -import { PullRequest } from './github/interface'; -import { NotificationProvider } from './github/notifications'; +import { Issue } from './github/interface'; +import { IssueModel } from './github/issueModel'; +import { IssueOverviewPanel } from './github/issueOverview'; import { GHPRComment, GHPRCommentThread, TemporaryComment } from './github/prComment'; import { PullRequestModel } from './github/pullRequestModel'; import { PullRequestOverviewPanel } from './github/pullRequestOverview'; +import { chooseItem } from './github/quickPicks'; import { RepositoriesManager } from './github/repositoriesManager'; -import { getIssuesUrl, getPullsUrl, isInCodespaces, vscodeDevPrLink } from './github/utils'; +import { codespacesPrLink, getIssuesUrl, getPullsUrl, isInCodespaces, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput, vscodeDevPrLink } from './github/utils'; +import { OverviewContext } from './github/views'; +import { IssueChatContextItem } from './lm/issueContextProvider'; +import { PRChatContextItem } from './lm/pullRequestContextProvider'; +import { isNotificationTreeItem, NotificationTreeItem } from './notifications/notificationItem'; +import { NotificationsManager } from './notifications/notificationsManager'; import { PullRequestsTreeDataProvider } from './view/prsTreeDataProvider'; +import { PrsTreeModel } from './view/prsTreeModel'; import { ReviewCommentController } from './view/reviewCommentController'; import { ReviewManager } from './view/reviewManager'; +import { ReviewsManager } from './view/reviewsManager'; import { CategoryTreeNode } from './view/treeNodes/categoryNode'; import { CommitNode } from './view/treeNodes/commitNode'; -import { DescriptionNode } from './view/treeNodes/descriptionNode'; import { FileChangeNode, GitFileChangeNode, @@ -38,11 +51,11 @@ import { RemoteFileChangeNode, } from './view/treeNodes/fileChangeNode'; import { PRNode } from './view/treeNodes/pullRequestNode'; +import { RepositoryChangesNode } from './view/treeNodes/repositoryChangesNode'; -const _onDidUpdatePR = new vscode.EventEmitter(); -export const onDidUpdatePR: vscode.Event = _onDidUpdatePR.event; - -function ensurePR(folderRepoManager: FolderRepositoryManager, pr?: PRNode | PullRequestModel): PullRequestModel { +function ensurePR(folderRepoManager: FolderRepositoryManager, pr?: PRNode): PullRequestModel; +function ensurePR>(folderRepoManager: FolderRepositoryManager, pr?: TIssueModel): TIssueModel; +function ensurePR>(folderRepoManager: FolderRepositoryManager, pr?: PRNode | TIssueModel): TIssueModel { // If the command is called from the command palette, no arguments are passed. if (!pr) { if (!folderRepoManager.activePullRequest) { @@ -50,58 +63,46 @@ function ensurePR(folderRepoManager: FolderRepositoryManager, pr?: PRNode | Pull throw new Error('Unable to find current pull request.'); } - return folderRepoManager.activePullRequest; + return folderRepoManager.activePullRequest as unknown as TIssueModel; } else { - return pr instanceof PRNode ? pr.pullRequestModel : pr; + return (pr instanceof PRNode ? pr.pullRequestModel : pr) as TIssueModel; } } export async function openDescription( - context: vscode.ExtensionContext, telemetry: ITelemetry, - pullRequestModel: PullRequestModel, - descriptionNode: DescriptionNode | undefined, + issueModel: IssueModel, + descriptionNode: PRNode | RepositoryChangesNode | undefined, folderManager: FolderRepositoryManager, - notificationProvider?: NotificationProvider + revealNode: boolean, + preserveFocus: boolean = true, ) { - const pullRequest = ensurePR(folderManager, pullRequestModel); - descriptionNode?.reveal(descriptionNode, { select: true, focus: true }); - // Create and show a new webview - await PullRequestOverviewPanel.createOrShow(context.extensionUri, folderManager, pullRequest); - - if (notificationProvider?.hasNotification(pullRequest)) { - notificationProvider.markPrNotificationsAsRead(pullRequest); - } - - /* __GDPR__ - "pr.openDescription" : {} - */ - telemetry.sendTelemetryEvent('pr.openDescription'); -} - -async function chooseItem( - activePullRequests: T[], - propertyGetter: (itemValue: T) => string, - options?: vscode.QuickPickOptions, -): Promise { - if (activePullRequests.length === 1) { - return activePullRequests[0]; + const issue = ensurePR(folderManager, issueModel); + if (revealNode) { + descriptionNode?.reveal(descriptionNode, { select: true, focus: true }); } - interface Item extends vscode.QuickPickItem { - itemValue: T; + const identity = { + owner: issue.remote.owner, + repo: issue.remote.repositoryName, + number: issue.number + }; + // Create and show a new webview + if (issue instanceof PullRequestModel) { + await PullRequestOverviewPanel.createOrShow(telemetry, folderManager.context.extensionUri, folderManager, identity, issue, undefined, preserveFocus); + } else { + await IssueOverviewPanel.createOrShow(telemetry, folderManager.context.extensionUri, folderManager, identity, issue); + /* __GDPR__ + "issue.openDescription" : {} + */ + telemetry.sendTelemetryEvent('issue.openDescription'); } - const items: Item[] = activePullRequests.map(currentItem => { - return { - label: propertyGetter(currentItem), - itemValue: currentItem, - }; - }); - return (await vscode.window.showQuickPick(items, options))?.itemValue; } -export async function openPullRequestOnGitHub(e: PRNode | DescriptionNode | PullRequestModel, telemetry: ITelemetry) { - if (e instanceof PRNode || e instanceof DescriptionNode) { +export async function openPullRequestOnGitHub(e: PRNode | RepositoryChangesNode | IssueModel | NotificationTreeItem, telemetry: ITelemetry) { + if (e instanceof PRNode || e instanceof RepositoryChangesNode) { vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(e.pullRequestModel.html_url)); + } else if (isNotificationTreeItem(e)) { + vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(e.model.html_url)); } else { vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(e.html_url)); } @@ -112,17 +113,38 @@ export async function openPullRequestOnGitHub(e: PRNode | DescriptionNode | Pull telemetry.sendTelemetryEvent('pr.openInGitHub'); } +export async function closeAllPrAndReviewEditors() { + const tabs = vscode.window.tabGroups; + const editors = tabs.all.map(group => group.tabs).flat(); + + for (const tab of editors) { + const scheme = tab.input instanceof vscode.TabInputTextDiff ? tab.input.original.scheme : (tab.input instanceof vscode.TabInputText ? tab.input.uri.scheme : undefined); + if (scheme && (scheme === Schemes.Pr) || (scheme === Schemes.Review)) { + await tabs.close(tab); + } + } +} + +function isCrossChatSessionWithPR(value: any): value is CrossChatSessionWithPR { + const asCrossChatSessionWithPR = value as Partial; + return !!asCrossChatSessionWithPR.pullRequestDetails; +} + export function registerCommands( context: vscode.ExtensionContext, reposManager: RepositoriesManager, - reviewManagers: ReviewManager[], + reviewsManager: ReviewsManager, telemetry: ITelemetry, + copilotRemoteAgentManager: CopilotRemoteAgentManager, + notificationManager: NotificationsManager, + prsTreeModel: PrsTreeModel, tree: PullRequestsTreeDataProvider ) { + const logId = 'RegisterCommands'; context.subscriptions.push( vscode.commands.registerCommand( 'pr.openPullRequestOnGitHub', - async (e: PRNode | DescriptionNode | PullRequestModel | undefined) => { + async (e: PRNode | RepositoryChangesNode | PullRequestModel | undefined) => { if (!e) { const activePullRequests: PullRequestModel[] = reposManager.folderManagers .map(folderManager => folderManager.activePullRequest!) @@ -131,7 +153,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); @@ -143,6 +165,16 @@ export function registerCommands( }, ), ); + context.subscriptions.push( + vscode.commands.registerCommand( + 'notification.openOnGitHub', + async (e: NotificationTreeItem | undefined) => { + if (e) { + openPullRequestOnGitHub(e, telemetry); + } + }, + ), + ); context.subscriptions.push( vscode.commands.registerCommand( @@ -158,7 +190,7 @@ export function registerCommands( ? ( await chooseItem( activePullRequestsWithFolderManager, - itemValue => itemValue.activePr.html_url, + itemValue => ({ label: itemValue.activePr.html_url }), ) ) : activePullRequestsWithFolderManager[0]; @@ -168,7 +200,7 @@ export function registerCommands( } const { folderManager } = activePullRequestAndFolderManager; - const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewManagers, folderManager); + const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager); if (!reviewManager) { return; @@ -180,71 +212,6 @@ export function registerCommands( ), ); - context.subscriptions.push( - vscode.commands.registerCommand('review.suggestDiff', async e => { - try { - const folderManager = await chooseItem( - reposManager.folderManagers, - itemValue => pathLib.basename(itemValue.repository.rootUri.fsPath), - ); - if (!folderManager || !folderManager.activePullRequest) { - return; - } - - const { indexChanges, workingTreeChanges } = folderManager.repository.state; - - if (!indexChanges.length) { - if (workingTreeChanges.length) { - const yes = vscode.l10n.t('Yes'); - const stageAll = await vscode.window.showWarningMessage( - vscode.l10n.t('There are no staged changes to suggest.\n\nWould you like to automatically stage all your of changes and suggest them?'), - { modal: true }, - yes, - ); - if (stageAll === yes) { - await vscode.commands.executeCommand('git.stageAll'); - } else { - return; - } - } else { - vscode.window.showInformationMessage(vscode.l10n.t('There are no changes to suggest.')); - return; - } - } - - const diff = await folderManager.repository.diff(true); - - let suggestEditMessage = vscode.l10n.t('Suggested edit:\n'); - if (e && e.inputBox && e.inputBox.value) { - suggestEditMessage = `${e.inputBox.value}\n`; - e.inputBox.value = ''; - } - - const suggestEditText = `${suggestEditMessage}\`\`\`diff\n${diff}\n\`\`\``; - await folderManager.activePullRequest.createIssueComment(suggestEditText); - - // Reset HEAD and then apply reverse diff - await vscode.commands.executeCommand('git.unstageAll'); - - const tempFilePath = pathLib.join( - folderManager.repository.rootUri.fsPath, - '.git', - `${folderManager.activePullRequest.number}.diff`, - ); - const encoder = new TextEncoder(); - const tempUri = vscode.Uri.file(tempFilePath); - - await vscode.workspace.fs.writeFile(tempUri, encoder.encode(diff)); - await folderManager.repository.apply(tempFilePath, true); - await vscode.workspace.fs.delete(tempUri); - } catch (err) { - const moreError = `${err}${err.stderr ? `\n${err.stderr}` : ''}`; - Logger.error(`Applying patch failed: ${moreError}`); - vscode.window.showErrorMessage(vscode.l10n.t('Applying patch failed: {0}', formatError(err))); - } - }), - ); - context.subscriptions.push( vscode.commands.registerCommand('pr.openFileOnGitHub', async (e: GitFileChangeNode | RemoteFileChangeNode) => { if (e instanceof RemoteFileChangeNode) { @@ -268,12 +235,46 @@ export function registerCommands( }), ); + context.subscriptions.push( + vscode.commands.registerCommand('pr.revealFileInOS', (e: GitFileChangeNode | InMemFileChangeNode | undefined) => { + let fileChangeNode: FileChangeNode | undefined = e; + // When invoked from a keybinding, get the selected item from the tree view + if (!fileChangeNode) { + // First check the prStatus:github tree (checked out PRs) + for (const reviewManager of reviewsManager.reviewManagers) { + const selection = reviewManager.changesInPrDataProvider.view.selection; + const selectedFileChange = selection.find((node): node is GitFileChangeNode => node instanceof GitFileChangeNode); + if (selectedFileChange) { + fileChangeNode = selectedFileChange; + break; + } + } + // Then check the pr:github tree (non-checked out PRs) + if (!fileChangeNode) { + const prTreeSelection = tree.view.selection; + const selectedInMemFileChange = prTreeSelection.find((node): node is InMemFileChangeNode => node instanceof InMemFileChangeNode); + if (selectedInMemFileChange) { + fileChangeNode = selectedInMemFileChange; + } + } + } + if (!fileChangeNode) { + return; + } + const folderManager = reposManager.getManagerForIssueModel(fileChangeNode.pullRequest); + if (folderManager) { + const filePath = vscode.Uri.joinPath(folderManager.repository.rootUri, fileChangeNode.changeModel.fileName); + vscode.commands.executeCommand('revealFileInOS', filePath); + } + }), + ); + context.subscriptions.push( vscode.commands.registerCommand('pr.openOriginalFile', async (e: GitFileChangeNode) => { // if this is an image, encode it as a base64 data URI const folderManager = reposManager.getManagerForIssueModel(e.pullRequest); if (folderManager) { - const imageDataURI = await asImageDataURI(e.changeModel.parentFilePath, folderManager.repository); + const imageDataURI = await asTempStorageURI(e.changeModel.parentFilePath, folderManager.repository); vscode.commands.executeCommand('vscode.open', imageDataURI || e.changeModel.parentFilePath); } }), @@ -297,29 +298,48 @@ export function registerCommands( }), ); + async function openDiffView(fileChangeNode: GitFileChangeNode | InMemFileChangeNode | vscode.Uri | undefined) { + if (fileChangeNode && !(fileChangeNode instanceof vscode.Uri)) { + const folderManager = reposManager.getManagerForIssueModel(fileChangeNode.pullRequest); + if (!folderManager) { + return; + } + return fileChangeNode.openDiff(folderManager); + } else if (fileChangeNode || vscode.window.activeTextEditor) { + const editor = fileChangeNode instanceof vscode.Uri ? vscode.window.visibleTextEditors.find(editor => editor.document.uri.toString() === fileChangeNode.toString())! : vscode.window.activeTextEditor!; + const visibleRanges = editor.visibleRanges; + const folderManager = reposManager.getManagerForFile(editor.document.uri); + if (!folderManager?.activePullRequest) { + return; + } + const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager); + if (!reviewManager) { + return; + } + const change = reviewManager.reviewModel.localFileChanges.find(change => change.resourceUri.with({ query: '' }).toString() === editor.document.uri.toString()); + await change?.openDiff(folderManager); + const tabInput = vscode.window.tabGroups.activeTabGroup.activeTab?.input; + const diffEditor = (tabInput instanceof vscode.TabInputTextDiff && tabInput.modified.toString() === editor.document.uri.toString()) ? vscode.window.activeTextEditor : undefined; + if (diffEditor) { + diffEditor.revealRange(visibleRanges[0]); + } + } + } + context.subscriptions.push( vscode.commands.registerCommand( 'pr.openDiffView', (fileChangeNode: GitFileChangeNode | InMemFileChangeNode | undefined) => { - if (fileChangeNode) { - const folderManager = reposManager.getManagerForIssueModel(fileChangeNode.pullRequest); - if (!folderManager) { - return; - } - fileChangeNode.openDiff(folderManager); - } else if (vscode.window.activeTextEditor) { - const activeTextEditor = vscode.window.activeTextEditor; - const folderManager = reposManager.getManagerForFile(activeTextEditor.document.uri); - if (!folderManager?.activePullRequest) { - return; - } - const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewManagers, folderManager); - if (!reviewManager) { - return; - } - const change = reviewManager.reviewModel.localFileChanges.find(change => change.resourceUri.with({ query: '' }).toString() === activeTextEditor.document.uri.toString()); - change?.openDiff(folderManager); - } + return openDiffView(fileChangeNode); + }, + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'pr.openDiffViewFromEditor', + (uri: vscode.Uri) => { + return openDiffView(uri); }, ), ); @@ -368,8 +388,6 @@ export function registerCommands( "pr.deleteLocalPullRequest.success" : {} */ telemetry.sendTelemetryEvent('pr.deleteLocalPullRequest.success'); - // fire and forget - vscode.commands.executeCommand('pr.refreshList'); } }), ); @@ -377,15 +395,15 @@ export function registerCommands( function chooseReviewManager(repoPath?: string) { if (repoPath) { const uri = vscode.Uri.file(repoPath).toString(); - for (const mgr of reviewManagers) { + for (const mgr of reviewsManager.reviewManagers) { if (mgr.repository.rootUri.toString() === uri) { return mgr; } } } return chooseItem( - reviewManagers, - itemValue => pathLib.basename(itemValue.repository.rootUri.fsPath), + reviewsManager.reviewManagers, + itemValue => ({ label: pathLib.basename(itemValue.repository.rootUri.fsPath) }), { placeHolder: vscode.l10n.t('Choose a repository to create a pull request in'), ignoreFocusOut: true }, ); } @@ -414,11 +432,23 @@ export function registerCommands( async (args?: any | Repository) => { if (isSourceControl(args)) { const reviewManager = await chooseReviewManager(args.rootUri.fsPath); + const folderManager = reposManager.getManagerForFile(args.rootUri); + let create = true; + if (folderManager?.activePullRequest) { + const push = vscode.l10n.t('Push'); + const result = await vscode.window.showInformationMessage(vscode.l10n.t('You already have a pull request for this branch. Do you want to push your changes to the remote branch?'), { modal: true }, push); + if (result !== push) { + return; + } + create = false; + } if (reviewManager) { if (args.state.HEAD?.upstream) { await args.push(); } - reviewManager.createPullRequest(); + if (create) { + reviewManager.createPullRequest(); + } } } }, @@ -426,58 +456,406 @@ export function registerCommands( ); context.subscriptions.push( - vscode.commands.registerCommand('pr.pick', async (pr: PRNode | DescriptionNode | PullRequestModel) => { + vscode.commands.registerCommand('pr.pick', async (pr: PRNode | RepositoryChangesNode | PullRequestModel) => { if (pr === undefined) { // This is unexpected, but has happened a few times. - Logger.error('Unexpectedly received undefined when picking a PR.'); + Logger.error('Unexpectedly received undefined when picking a PR.', logId); return vscode.window.showErrorMessage(vscode.l10n.t('No pull request was selected to checkout, please try again.')); } let pullRequestModel: PullRequestModel; let repository: Repository | undefined; - if (pr instanceof PRNode || pr instanceof DescriptionNode) { + if (pr instanceof PRNode || pr instanceof RepositoryChangesNode) { pullRequestModel = pr.pullRequestModel; repository = pr.repository; } else { pullRequestModel = pr; } + // Get the folder manager to access the repository + const folderManager = reposManager.getManagerForIssueModel(pullRequestModel); + if (!folderManager) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to find repository for this pull request.')); + } + const fromDescriptionPage = pr instanceof PullRequestModel; - /* __GDPR__ - "pr.checkout" : { - "fromDescriptionPage" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + return reviewsManager.switchToPr(folderManager, pullRequestModel, repository, fromDescriptionPage); + + })); + + const resolvePr = async (context: OverviewContext | undefined): Promise<{ folderManager: FolderRepositoryManager, pr: PullRequestModel } | undefined> => { + if (!context) { + return undefined; + } + + const folderManager = reposManager.getManagerForRepository(context.owner, context.repo) ?? reposManager.folderManagers[0]; + if (!folderManager) { + return undefined; + } + + const pr = await folderManager.resolvePullRequest(context.owner, context.repo, context.number, true); + if (!pr) { + return undefined; + } + + return { folderManager, pr }; + }; + + const applyPullRequestChanges = async (task: vscode.Progress<{ message?: string; increment?: number; }>, folderManager: FolderRepositoryManager, pullRequest: PullRequestModel): Promise => { + let patch: string | undefined; + try { + patch = await pullRequest.getPatch(); + + if (!patch.trim()) { + vscode.window.showErrorMessage(vscode.l10n.t('No patch data available for pull request #{0}', pullRequest.number.toString())); + return; } - */ - telemetry.sendTelemetryEvent('pr.checkout', { fromDescription: fromDescriptionPage.toString() }); - return vscode.window.withProgress( + const tempFilePath = pathLib.join( + folderManager.repository.rootUri.fsPath, + '.git', + `pr-${pullRequest.number}.patch`, + ); + const encoder = new TextEncoder(); + const tempUri = vscode.Uri.file(tempFilePath); + + 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); + Logger.error(`Failed to apply PR changes: ${errorMessage}`, 'Commands'); + + const copyGitApply = vscode.l10n.t('Copy git apply'); + const result = await vscode.window.showErrorMessage( + vscode.l10n.t('Failed to apply changes from pull request: {0}', errorMessage), + copyGitApply + ); + + if (result === copyGitApply) { + if (patch) { + const gitApplyCommand = `git apply --3way <<'EOF'\n${patch}\nEOF`; + await vscode.env.clipboard.writeText(gitApplyCommand); + vscode.window.showInformationMessage(vscode.l10n.t('Git apply command copied to clipboard')); + } else { + vscode.window.showErrorMessage(vscode.l10n.t('Unable to copy git apply command - patch content is not available')); + } + } + } + }; + + /** + * Metadata passed from chat/agent sessions containing repository information. + * This is provided by VS Code when commands are invoked from chat session toolbars. + */ + interface SessionMetadata { + /** GitHub repository owner/organization name */ + owner?: string; + /** GitHub repository name */ + name?: string; + [key: string]: unknown; + } + + /** + * Get the folder manager and GitHub repository for a repository based on metadata. + * Falls back to the first folder manager if metadata is not provided or repository not found. + * @param metadata Session metadata containing owner and repo information + * @returns Object with folderManager and githubRepo, or undefined if no folder managers exist + */ + function getFolderManagerFromMetadata(metadata: SessionMetadata | undefined): { folderManager: FolderRepositoryManager; githubRepo: GitHubRepository } | undefined { + if (metadata?.owner && metadata?.name) { + const folderManager = reposManager.getManagerForRepository(metadata.owner, metadata.name) ?? reposManager.folderManagers[0]; + if (!folderManager || folderManager.gitHubRepositories.length === 0) { + return undefined; + } + const githubRepo = folderManager.gitHubRepositories.find( + repo => repo.remote.owner === metadata.owner && repo.remote.repositoryName === metadata.name + ) ?? folderManager.gitHubRepositories[0]; + return { folderManager, githubRepo }; + } + if (reposManager.folderManagers.length === 0) { + return undefined; + } + const folderManager = reposManager.folderManagers[0]; + if (folderManager.gitHubRepositories.length === 0) { + return undefined; + } + return { folderManager, githubRepo: folderManager.gitHubRepositories[0] }; + } + + function contextHasPath(ctx: OverviewContext | { path: string } | undefined): ctx is { path: string } { + const contextAsPath: Partial<{ path: string }> = (ctx as { path: string }); + 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, metadata?: SessionMetadata) => { + if (!ctx) { + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request context provided for checkout.')); + } + + if (contextHasPath(ctx)) { + const { path } = ctx; + const prNumber = prNumberFromUriPath(path); + if (!prNumber) { + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request number found in context path.')); + } + // Use metadata to find the correct repository if available + const result = getFolderManagerFromMetadata(metadata); + if (!result) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to find repository manager.')); + } + const { folderManager, githubRepo } = result; + const pullRequest = await folderManager.fetchById(githubRepo, Number(prNumber)); + if (!pullRequest) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to find pull request #{0}', prNumber.toString())); + } + + 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 reviewsManager.switchToPr(resolved.folderManager, resolved.pr, resolved.folderManager.repository, true); + + })); + + context.subscriptions.push(vscode.commands.registerCommand('pr.applyChangesFromDescription', async (ctx: OverviewContext | { path: string } | undefined, metadata?: SessionMetadata) => { + if (!ctx) { + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request context provided for applying changes.')); + } + + if (contextHasPath(ctx)) { + const { path } = ctx; + const prNumber = prNumberFromUriPath(path); + if (!prNumber) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to parse pull request number.')); + } + + await vscode.window.withProgress( { - location: vscode.ProgressLocation.SourceControl, - title: vscode.l10n.t('Switching to Pull Request #{0}', pullRequestModel.number), + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t('Applying changes from pull request #{0}', prNumber.toString()), + cancellable: false }, - async () => { - await ReviewManager.getReviewManagerForRepository( - reviewManagers, - pullRequestModel.githubRepository, - repository - )?.switch(pullRequestModel); - }, - ); + async (task) => { + task.report({ increment: 30 }); + + // Use metadata to find the correct repository if available + const result = getFolderManagerFromMetadata(metadata); + if (!result) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to find repository manager.')); + } + const { folderManager, githubRepo } = result; + const pullRequest = await folderManager.fetchById(githubRepo, 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; + } + + 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 | CrossChatSessionWithPR | { 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); + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request was selected to checkout, please try again.')); + } + + let pullRequestModel: PullRequestModel | undefined; + + if (pr instanceof PRNode || pr instanceof RepositoryChangesNode) { + pullRequestModel = pr.pullRequestModel; + } else if (pr instanceof PullRequestModel) { + pullRequestModel = pr; + } 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; + } + + if (!pullRequestModel) { + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request found to open changes.')); + } + + const folderReposManager = reposManager.getManagerForIssueModel(pullRequestModel); + if (!folderReposManager) { + return; + } + return PullRequestModel.openChanges(folderReposManager, pullRequestModel); }), ); + let isCheckingOutFromReadonlyFile = false; + context.subscriptions.push(vscode.commands.registerCommand('pr.checkoutFromReadonlyFile', async () => { + const uri = vscode.window.activeTextEditor?.document.uri; + if (uri?.scheme !== Schemes.Pr) { + return; + } + const prUriPropserties = fromPRUri(uri); + if (prUriPropserties === undefined) { + return; + } + let githubRepository: GitHubRepository | undefined; + const folderManager = reposManager.folderManagers.find(folderManager => { + githubRepository = folderManager.gitHubRepositories.find(githubRepo => githubRepo.remote.remoteName === prUriPropserties.remoteName); + return !!githubRepository; + }); + if (!folderManager || !githubRepository) { + return; + } + const prModel = await vscode.window.withProgress({ location: vscode.ProgressLocation.Window }, () => folderManager.fetchById(githubRepository!, Number(prUriPropserties.prNumber))); + if (prModel && !isCheckingOutFromReadonlyFile) { + isCheckingOutFromReadonlyFile = true; + try { + await ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager)?.switch(prModel); + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Unable to check out pull request from read-only file: {0}', e instanceof Error ? e.message : 'unknown')); + } + isCheckingOutFromReadonlyFile = false; + } + })); + + const pickPullRequest = async (pr: PRNode | RepositoryChangesNode | PullRequestModel, linkGenerator: (pr: PullRequestModel) => string, requiresHead: boolean = false) => { + if (pr === undefined) { + // This is unexpected, but has happened a few times. + Logger.error('Unexpectedly received undefined when picking a PR.', logId); + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request was selected to checkout, please try again.')); + } + + let pullRequestModel: PullRequestModel; + + if (pr instanceof PRNode || pr instanceof RepositoryChangesNode) { + pullRequestModel = pr.pullRequestModel; + } else { + pullRequestModel = pr; + } + + if (requiresHead && !pullRequestModel.head) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to checkout pull request: missing head branch information.')); + } + + return vscode.env.openExternal(vscode.Uri.parse(linkGenerator(pullRequestModel))); + }; + + context.subscriptions.push( + vscode.commands.registerCommand('pr.pickOnVscodeDev', async (pr: PRNode | RepositoryChangesNode | PullRequestModel) => + pickPullRequest(pr, vscodeDevPrLink) + ), + ); + context.subscriptions.push( - vscode.commands.registerCommand('pr.exit', async (pr: PRNode | DescriptionNode | PullRequestModel | undefined) => { + vscode.commands.registerCommand('pr.pickOnCodespaces', async (pr: PRNode | RepositoryChangesNode | PullRequestModel) => + pickPullRequest(pr, codespacesPrLink, true) + ), + ); + + context.subscriptions.push(vscode.commands.registerCommand('pr.checkoutOnVscodeDevFromDescription', async (context: OverviewContext | undefined) => { + if (!context) { + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request context provided for checkout.')); + } + const resolved = await resolvePr(context); + if (!resolved) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to resolve pull request for checkout.')); + } + return vscode.env.openExternal(vscode.Uri.parse(vscodeDevPrLink(resolved.pr))); + })); + + context.subscriptions.push(vscode.commands.registerCommand('pr.checkoutOnCodespacesFromDescription', async (context: OverviewContext | undefined) => { + if (!context) { + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request context provided for checkout.')); + } + const resolved = await resolvePr(context); + if (!resolved) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to resolve pull request for checkout.')); + } + if (!resolved.pr.head) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to checkout pull request: missing head branch information.')); + } + return vscode.env.openExternal(vscode.Uri.parse(codespacesPrLink(resolved.pr))); + })); + + context.subscriptions.push(vscode.commands.registerCommand('pr.openSessionLogFromDescription', async (context: SessionLinkInfo | undefined) => { + if (!context) { + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request context provided for checkout.')); + } + const resource = SessionIdForPr.getResource(context.pullNumber, context.sessionIndex); + return vscode.commands.executeCommand('vscode.open', resource); + })); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.exit', async (pr: PRNode | RepositoryChangesNode | PullRequestModel | undefined) => { let pullRequestModel: PullRequestModel | undefined; - if (pr instanceof PRNode || pr instanceof DescriptionNode) { + if (pr instanceof PRNode || pr instanceof RepositoryChangesNode) { pullRequestModel = pr.pullRequestModel; } else if (pr === undefined) { 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; @@ -505,7 +883,7 @@ export function registerCommands( const manager = reposManager.getManagerForIssueModel(pullRequestModel); if (manager) { const prBranch = manager.repository.state.HEAD?.name; - await manager.checkoutDefaultBranch(branch); + await manager.checkoutDefaultBranch(branch, pullRequestModel); if (prBranch) { await manager.cleanupAfterPullRequest(prBranch, pullRequestModel!); } @@ -545,7 +923,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)}`); @@ -557,142 +935,120 @@ export function registerCommands( ); context.subscriptions.push( - vscode.commands.registerCommand('pr.readyForReview', async (pr?: PRNode) => { - const folderManager = reposManager.getManagerForIssueModel(pr?.pullRequestModel); - if (!folderManager) { - return; + vscode.commands.registerCommand('pr.dismissNotification', node => { + if (node instanceof PRNode) { + notificationManager.markPrNotificationsAsRead(node.pullRequestModel); + prsTreeModel.clearCopilotNotification(node.pullRequestModel.remote.owner, node.pullRequestModel.remote.repositoryName, node.pullRequestModel.number); } - const pullRequest = ensurePR(folderManager, pr); - const yes = vscode.l10n.t('Yes'); - return vscode.window - .showWarningMessage( - vscode.l10n.t('Are you sure you want to mark this pull request as ready to review on GitHub?'), - { modal: true }, - yes, - ) - .then(async value => { - let isDraft; - if (value === yes) { - try { - isDraft = await pullRequest.setReadyForReview(); - vscode.commands.executeCommand('pr.refreshList'); - return isDraft; - } catch (e) { - vscode.window.showErrorMessage( - `Unable to mark pull request as ready to review. ${formatError(e)}`, - ); - return isDraft; - } - } - }); }), ); context.subscriptions.push( - vscode.commands.registerCommand('pr.close', async (pr?: PRNode | PullRequestModel, message?: string) => { - let pullRequestModel: PullRequestModel | undefined; - if (pr) { - pullRequestModel = pr instanceof PullRequestModel ? pr : pr.pullRequestModel; - } else { - const activePullRequests: PullRequestModel[] = reposManager.folderManagers - .map(folderManager => folderManager.activePullRequest!) - .filter(activePR => !!activePR); - pullRequestModel = await chooseItem( - activePullRequests, - itemValue => `${itemValue.number}: ${itemValue.title}`, - { placeHolder: vscode.l10n.t('Pull request to close') }, - ); + vscode.commands.registerCommand('pr.markAllCopilotNotificationsAsRead', node => { + if (node instanceof CategoryTreeNode && node.isCopilot && node.repo) { + prsTreeModel.clearAllCopilotNotifications(node.repo.owner, node.repo.repositoryName); } - if (!pullRequestModel) { - return; - } - const pullRequest: PullRequestModel = pullRequestModel; - const yes = vscode.l10n.t('Yes'); - return vscode.window - .showWarningMessage( - vscode.l10n.t('Are you sure you want to close this pull request on GitHub? This will close the pull request without merging.'), - { modal: true }, - yes, - vscode.l10n.t('No'), - ) - .then(async value => { - if (value === yes) { - try { - let newComment: IComment | undefined = undefined; - if (message) { - newComment = await pullRequest.createIssueComment(message); - } + }), + ); - const newPR = await pullRequest.close(); - vscode.commands.executeCommand('pr.refreshList'); - _onDidUpdatePR.fire(newPR); - return newComment; - } catch (e) { - vscode.window.showErrorMessage(`Unable to close pull request. ${formatError(e)}`); - _onDidUpdatePR.fire(); - } - } + async function openDescriptionCommand(argument: RepositoryChangesNode | PRNode | IssueModel | CrossChatSessionWithPR | PRChatContextItem | IssueChatContextItem | undefined) { + let issueModel: IssueModel | undefined; + if (!argument) { + const activePullRequests: PullRequestModel[] = reposManager.folderManagers + .map(manager => manager.activePullRequest!) + .filter(activePR => !!activePR); + if (activePullRequests.length >= 1) { + issueModel = await chooseItem( + activePullRequests, + itemValue => ({ label: itemValue.title }), + ); + } + } else { + if (argument instanceof RepositoryChangesNode) { + issueModel = argument.pullRequestModel; + } else if (argument instanceof PRNode) { + issueModel = argument.pullRequestModel; + } else if (isCrossChatSessionWithPR(argument)) { + issueModel = (await resolvePr({ + owner: argument.pullRequestDetails.repository.owner.login, + repo: argument.pullRequestDetails.repository.name, + number: argument.pullRequestDetails.number, + preventDefaultContextMenuItems: true, + }))?.pr; + } else if (PRChatContextItem.is(argument)) { + issueModel = argument.pr; + } else if (IssueChatContextItem.is(argument)) { + issueModel = argument.issue; + } else { + issueModel = argument; + } + } - _onDidUpdatePR.fire(); - }); - }), - ); + if (!issueModel) { + Logger.appendLine('No pull request found.', logId); + return; + } - context.subscriptions.push( - vscode.commands.registerCommand('pr.dismissNotification', node => { - if (node instanceof PRNode) { - tree.notificationProvider.markPrNotificationsAsRead(node.pullRequestModel).then( - () => tree.refresh(node) - ); + const folderManager = reposManager.getManagerForIssueModel(issueModel) ?? reposManager.folderManagers[0]; + let descriptionNode: PRNode | RepositoryChangesNode | undefined; + if (argument instanceof PRNode) { + descriptionNode = argument; + } else if ((issueModel instanceof PullRequestModel) && folderManager.activePullRequest?.equals(issueModel)) { + const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager); + if (!reviewManager) { + return; } - }), - ); - context.subscriptions.push( - vscode.commands.registerCommand( - 'pr.openDescription', - async (argument: DescriptionNode | PullRequestModel | undefined) => { - let pullRequestModel: PullRequestModel | undefined; - if (!argument) { - const activePullRequests: PullRequestModel[] = reposManager.folderManagers - .map(manager => manager.activePullRequest!) - .filter(activePR => !!activePR); - if (activePullRequests.length >= 1) { - pullRequestModel = await chooseItem( - activePullRequests, - itemValue => itemValue.title, - ); - } - } else { - pullRequestModel = argument instanceof DescriptionNode ? argument.pullRequestModel : argument; - } + descriptionNode = reviewManager.changesInPrDataProvider.getDescriptionNode(folderManager); + } - if (!pullRequestModel) { - Logger.appendLine('No pull request found.'); - return; - } + const revealDescription = !(argument instanceof PRNode); - const folderManager = reposManager.getManagerForIssueModel(pullRequestModel); - if (!folderManager) { - return; - } + await openDescription(telemetry, issueModel, descriptionNode, folderManager, revealDescription, !(argument instanceof RepositoryChangesNode)); + } - let descriptionNode: DescriptionNode | undefined; - if (argument instanceof DescriptionNode) { - descriptionNode = argument; - } else { - const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewManagers, folderManager); - if (!reviewManager) { - return; - } + async function checkoutChatSessionPullRequest(argument: CrossChatSessionWithPR) { + const pr = 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; + } - descriptionNode = reviewManager.changesInPrDataProvider.getDescriptionNode(folderManager); - } + const folderManager = reposManager.getManagerForRepository(pr.githubRepository.remote.owner, pr.githubRepository.remote.repositoryName); + if (!folderManager) { + Logger.warn(`No folder manager found for pull request ${pr.number}`, logId); + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to find repository for pull request #{0}', pr.number.toString())); + } - await openDescription(context, telemetry, pullRequestModel, descriptionNode, folderManager, tree.notificationProvider); - }, - ), + return reviewsManager.switchToPr(folderManager, pr, folderManager.repository, false); + } + + context.subscriptions.push( + vscode.commands.registerCommand( + 'pr.checkoutChatSessionPullRequest', + checkoutChatSessionPullRequest + ) + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'pr.openDescription', + openDescriptionCommand + ) + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.openDescription', + openDescriptionCommand + ) ); context.subscriptions.push( @@ -703,8 +1059,16 @@ export function registerCommands( }), ); + context.subscriptions.push(vscode.commands.registerCommand('pr.focusDescriptionInput', + async () => { + if (PullRequestOverviewPanel.currentPanel) { + PullRequestOverviewPanel.scrollToReview(); + } + } + )); + context.subscriptions.push( - vscode.commands.registerCommand('pr.openDescriptionToTheSide', async (descriptionNode: DescriptionNode) => { + vscode.commands.registerCommand('pr.openDescriptionToTheSide', async (descriptionNode: RepositoryChangesNode) => { const folderManager = reposManager.getManagerForIssueModel(descriptionNode.pullRequestModel); if (!folderManager) { return; @@ -712,8 +1076,13 @@ export function registerCommands( const pr = descriptionNode.pullRequestModel; const pullRequest = ensurePR(folderManager, pr); descriptionNode.reveal(descriptionNode, { select: true, focus: true }); + const identity = { + owner: pullRequest.remote.owner, + repo: pullRequest.remote.repositoryName, + number: pullRequest.number + }; // Create and show a new webview - PullRequestOverviewPanel.createOrShow(context.extensionUri, folderManager, pullRequest, true); + PullRequestOverviewPanel.createOrShow(telemetry, context.extensionUri, folderManager, identity, pullRequest, true); /* __GDPR__ "pr.openDescriptionToTheSide" : {} @@ -723,13 +1092,13 @@ export function registerCommands( ); context.subscriptions.push( - vscode.commands.registerCommand('pr.showDiffSinceLastReview', async (descriptionNode: DescriptionNode) => { + vscode.commands.registerCommand('pr.showDiffSinceLastReview', async (descriptionNode: RepositoryChangesNode) => { descriptionNode.pullRequestModel.showChangesSinceReview = true; }), ); context.subscriptions.push( - vscode.commands.registerCommand('pr.showDiffAll', async (descriptionNode: DescriptionNode) => { + vscode.commands.registerCommand('pr.showDiffAll', async (descriptionNode: RepositoryChangesNode) => { descriptionNode.pullRequestModel.showChangesSinceReview = false; }), ); @@ -740,6 +1109,12 @@ export function registerCommands( }), ); + context.subscriptions.push( + vscode.commands.registerCommand('pr.signinNoEnterprise', async () => { + await reposManager.authenticate(false); + }), + ); + context.subscriptions.push( vscode.commands.registerCommand('pr.signinenterprise', async () => { await reposManager.authenticate(true); @@ -811,34 +1186,79 @@ export function registerCommands( return { thread, text }; } - context.subscriptions.push( - vscode.commands.registerCommand('pr.resolveReviewThread', async (commentLike: CommentReply | GHPRCommentThread | GHPRComment) => { + const resolve = async (commentLike: CommentReply | GHPRCommentThread | GHPRComment | undefined, resolve: boolean, focusReply?: boolean) => { + if (resolve) { /* __GDPR__ "pr.resolveReviewThread" : {} */ telemetry.sendTelemetryEvent('pr.resolveReviewThread'); - const { thread, text } = threadAndText(commentLike); - const handler = resolveCommentHandler(thread); - - if (handler) { - await handler.resolveReviewThread(thread, text); - } - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('pr.unresolveReviewThread', async (commentLike: CommentReply | GHPRCommentThread | GHPRComment) => { + } else { /* __GDPR__ "pr.unresolveReviewThread" : {} */ telemetry.sendTelemetryEvent('pr.unresolveReviewThread'); - const { thread, text } = threadAndText(commentLike); + } - const handler = resolveCommentHandler(thread); + if (!commentLike) { + const activeHandler = findActiveHandler(); + if (!activeHandler) { + vscode.window.showErrorMessage(vscode.l10n.t('No active comment thread found')); + return; + } + commentLike = activeHandler.commentController.activeCommentThread as vscode.CommentThread2 as GHPRCommentThread; + } - if (handler) { + const { thread, text } = threadAndText(commentLike); + + const handler = resolveCommentHandler(thread); + + if (handler) { + if (resolve) { + await handler.resolveReviewThread(thread, text); + } else { await handler.unresolveReviewThread(thread, text); + if (focusReply) { + thread.reveal(undefined, { focus: vscode.CommentThreadFocus.Reply }); + } } + } + }; + + context.subscriptions.push( + vscode.commands.registerCommand('pr.resolveReviewThread', async (commentLike: CommentReply | GHPRCommentThread | GHPRComment) => resolve(commentLike, true)) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.unresolveReviewThread', (commentLike: CommentReply | GHPRCommentThread | GHPRComment) => resolve(commentLike, false, false)) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.unresolveReviewThreadFromView', (commentLike: CommentReply | GHPRCommentThread | GHPRComment) => resolve(commentLike, false, true)) + ); + + const localUriFromReviewUri = (reviewUri: vscode.Uri) => { + const { path, rootPath } = fromReviewUri(reviewUri.query); + const workspaceFolder = vscode.workspace.workspaceFolders![0]; + return vscode.Uri.joinPath(vscode.Uri.file(rootPath), path).with({ scheme: workspaceFolder.uri.scheme, authority: workspaceFolder.uri.authority }); + }; + + context.subscriptions.push( + vscode.commands.registerCommand('pr.diffOutdatedCommentWithHead', async (commentThread: GHPRCommentThread) => { + /* __GDPR__ + "pr.diffOutdatedCommentWithHead" : {} + */ + telemetry.sendTelemetryEvent('pr.diffOutdatedCommentWithHead'); + const options: vscode.TextDocumentShowOptions = {}; + options.selection = commentThread.range; + const fileName = pathLib.basename(commentThread.uri.fsPath); + const { commit } = fromReviewUri(commentThread.uri.query); + + vscode.commands.executeCommand('vscode.diff', + commentThread.uri, + localUriFromReviewUri(commentThread.uri), + `${fileName} from ${(commit || '').substr(0, 8)} diffed with HEAD`, + options, + ); }), ); @@ -871,19 +1291,31 @@ export function registerCommands( ); context.subscriptions.push( - vscode.commands.registerCommand('pr.makeSuggestion', async (reply: CommentReply | GHPRComment) => { - const thread = reply instanceof GHPRComment ? reply.parent : reply.thread; + vscode.commands.registerCommand('pr.makeSuggestion', async (reply: CommentReply | GHPRComment | undefined) => { + let potentialThread: GHPRCommentThread | undefined; + if (reply === undefined) { + potentialThread = findActiveHandler()?.commentController.activeCommentThread as vscode.CommentThread2 as GHPRCommentThread | undefined; + } else { + potentialThread = reply instanceof GHPRComment ? reply.parent : reply?.thread; + } + + if (!potentialThread?.range) { + return; + } + const thread: GHPRCommentThread & { range: vscode.Range } = potentialThread as GHPRCommentThread & { range: vscode.Range }; const commentEditor = vscode.window.activeTextEditor?.document.uri.scheme === Schemes.Comment ? vscode.window.activeTextEditor - : vscode.window.visibleTextEditors.find(visible => visible.document.uri.scheme === Schemes.Comment); + : vscode.window.visibleTextEditors.find(visible => (visible.document.uri.scheme === Schemes.Comment) && (visible.document.uri.query === '')); if (!commentEditor) { - Logger.error('No comment editor visible for making a suggestion.'); + Logger.error('No comment editor visible for making a suggestion.', logId); vscode.window.showErrorMessage(vscode.l10n.t('No available comment editor to make a suggestion in.')); return; } const editor = vscode.window.visibleTextEditors.find(editor => editor.document.uri.toString() === thread.uri.toString()); const contents = editor?.document.getText(new vscode.Range(thread.range.start.line, 0, thread.range.end.line, editor.document.lineAt(thread.range.end.line).text.length)); + const position = commentEditor.document.lineAt(commentEditor.selection.end.line).range.end; return commentEditor.edit((editBuilder) => { - editBuilder.insert(commentEditor.selection.end, `\`\`\`suggestion + editBuilder.insert(position, ` +\`\`\`suggestion ${contents} \`\`\``); }); @@ -906,7 +1338,10 @@ ${contents} "pr.editQuery" : {} */ telemetry.sendTelemetryEvent('pr.editQuery'); - return query.editQuery(); + if (query.label === undefined) { + return; + } + return editQuery(PR_SETTINGS_NAMESPACE, query.label); }), ); @@ -941,7 +1376,7 @@ ${contents} */ telemetry.sendTelemetryEvent('pr.deleteComment'); const deleteOption = vscode.l10n.t('Delete'); - const shouldDelete = await vscode.window.showWarningMessage(vscode.l10n.t('Delete comment?'), { modal: true }, deleteOption); + const shouldDelete = await vscode.window.showWarningMessage(vscode.l10n.t('Are you sure you want to delete this comment?'), { modal: true }, deleteOption); if (shouldDelete === deleteOption) { const handler = resolveCommentHandler(comment.parent); @@ -961,34 +1396,110 @@ ${contents} ); context.subscriptions.push( - vscode.commands.registerCommand('review.openLocalFile', (value: vscode.Uri) => { - const { path, rootPath } = fromReviewUri(value.query); - const localUri = vscode.Uri.joinPath(vscode.Uri.file(rootPath), path); + vscode.commands.registerCommand('review.openLocalFile', (_value: vscode.Uri) => { + const value = _value ?? vscode.window.activeTextEditor?.document.uri; + if (!value) { + return; + } + const localUri = localUriFromReviewUri(value); const editor = vscode.window.visibleTextEditors.find(editor => editor.document.uri.toString() === value.toString()); const command = openFileCommand(localUri, editor ? { selection: editor.selection } : undefined); vscode.commands.executeCommand(command.command, ...(command.arguments ?? [])); }), ); + interface SCMResourceStates { + resourceStates: { resourceUri: vscode.Uri }[]; + } + interface SCMResourceUri { + resourceUri: vscode.Uri; + } + context.subscriptions.push(vscode.commands.registerCommand('review.createSuggestionsFromChanges', async (value: SCMResourceStates | SCMResourceUri, ...additionalSelected: SCMResourceUri[]) => { + let resources: vscode.Uri[]; + const asResourceStates = value as Partial; + if (asResourceStates.resourceStates) { + resources = asResourceStates.resourceStates.map(resource => resource.resourceUri); + } else { + const asResourceUri = value as SCMResourceUri; + resources = [asResourceUri.resourceUri]; + if (additionalSelected) { + resources.push(...additionalSelected.map(resource => resource.resourceUri)); + } + } + if (resources.length === 0) { + return; + } + const folderManager = reposManager.getManagerForFile(resources[0]); + if (!folderManager || !folderManager.activePullRequest) { + return; + } + const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager); + return reviewManager?.createSuggestionsFromChanges(resources); + })); + + context.subscriptions.push(vscode.commands.registerDiffInformationCommand('review.createSuggestionFromChange', async (diffLines: vscode.LineChange[]) => { + const tab = vscode.window.tabGroups.activeTabGroup.activeTab; + const input = tab?.input; + if (!(input instanceof vscode.TabInputTextDiff)) { + return vscode.window.showErrorMessage(vscode.l10n.t('Current editor isn\'t a diff editor.')); + } + + if (input.original.scheme !== Schemes.Git) { + return vscode.window.showErrorMessage(vscode.l10n.t('Converting changes to suggestions can only be done from a git diff, not a pull request diff'), { modal: true }); + } + + const editor = vscode.window.visibleTextEditors.find(editor => editor.document.uri.toString() === input.modified.toString()); + if (!editor) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unexpectedly unable to find the current modified editor.')); + } + + const folderManager = reposManager.getManagerForFile(input.modified); + if (!folderManager || !folderManager.activePullRequest) { + return; + } + const editorSelection = editor.selection; + const selectedLines = diffLines.filter(line => { + return !!editorSelection.intersection(new vscode.Selection(line.modifiedStartLineNumber - 1, 0, line.modifiedEndLineNumber - 1, 100)); + }); + + if (selectedLines.length === 0) { + return vscode.window.showErrorMessage(vscode.l10n.t('No modified lines selected.')); + } + const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager); + return reviewManager?.createSuggestionFromChange(editor.document, selectedLines); + + })); + context.subscriptions.push( vscode.commands.registerCommand('pr.refreshChanges', _ => { - reviewManagers.forEach(reviewManager => { - reviewManager.updateComments(); - PullRequestOverviewPanel.refresh(); - reviewManager.changesInPrDataProvider.refresh(); + reviewsManager.reviewManagers.forEach(reviewManager => { + vscode.window.withProgress({ location: { viewId: 'prStatus:github' } }, async () => { + await Promise.all([ + reviewManager.repository.pull(false), + reviewManager.updateComments() + ]); + }); }); }), ); context.subscriptions.push( vscode.commands.registerCommand('pr.setFileListLayoutAsTree', _ => { - vscode.workspace.getConfiguration('githubPullRequests').update('fileListLayout', 'tree', true); + vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(FILE_LIST_LAYOUT, 'tree', true); }), ); context.subscriptions.push( vscode.commands.registerCommand('pr.setFileListLayoutAsFlat', _ => { - vscode.workspace.getConfiguration('githubPullRequests').update('fileListLayout', 'flat', true); + vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(FILE_LIST_LAYOUT, 'flat', true); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.toggleHideViewedFiles', _ => { + const config = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE); + const currentValue = config.get(HIDE_VIEWED_FILES, false); + config.update(HIDE_VIEWED_FILES, !currentValue, vscode.ConfigurationTarget.Global); }), ); @@ -996,14 +1507,27 @@ ${contents} vscode.commands.registerCommand('pr.refreshPullRequest', (prNode: PRNode) => { const folderManager = reposManager.getManagerForIssueModel(prNode.pullRequestModel); if (folderManager && prNode.pullRequestModel.equals(folderManager?.activePullRequest)) { - ReviewManager.getReviewManagerForFolderManager(reviewManagers, folderManager)?.updateComments(); + ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager)?.updateComments(); } PullRequestOverviewPanel.refresh(); - tree.refresh(prNode); }), ); + const findPrFromUri = (manager: FolderRepositoryManager | undefined, treeNode: vscode.Uri): PullRequestModel | undefined => { + if (treeNode.scheme === Schemes.Pr) { + const prQuery = fromPRUri(treeNode); + if (prQuery) { + for (const githubRepos of (manager?.gitHubRepositories ?? [])) { + const prNumber = Number(prQuery.prNumber); + return githubRepos.getExistingPullRequestModel(prNumber); + } + } + } else { + return manager?.activePullRequest; + } + }; + context.subscriptions.push( vscode.commands.registerCommand('pr.markFileAsViewed', async (treeNode: FileChangeNode | vscode.Uri | undefined) => { try { @@ -1013,7 +1537,7 @@ ${contents} } if (treeNode instanceof FileChangeNode) { - await treeNode.markFileAsViewed(); + await treeNode.markFileAsViewed(false); } else if (treeNode) { // When the argument is a uri it came from the editor menu and we should also close the file // Do the close first to improve perceived performance of marking as viewed. @@ -1029,8 +1553,10 @@ ${contents} vscode.window.tabGroups.close(tab); } } + const manager = reposManager.getManagerForFile(treeNode); - await manager?.activePullRequest?.markFileAsViewed(treeNode.path); + const pullRequest = findPrFromUri(manager, treeNode); + await pullRequest?.markFiles([treeNode.path], true, 'viewed'); manager?.setFileViewedContext(); } } catch (e) { @@ -1048,10 +1574,11 @@ ${contents} } if (treeNode instanceof FileChangeNode) { - treeNode.unmarkFileAsViewed(); + treeNode.unmarkFileAsViewed(false); } else if (treeNode) { const manager = reposManager.getManagerForFile(treeNode); - await manager?.activePullRequest?.unmarkFileAsViewed(treeNode.path); + const pullRequest = findPrFromUri(manager, treeNode); + await pullRequest?.markFiles([treeNode.path], true, 'unviewed'); manager?.setFileViewedContext(); } } catch (e) { @@ -1086,52 +1613,170 @@ ${contents} })); context.subscriptions.push( - vscode.commands.registerCommand('pr.copyVscodeDevPrLink', async () => { - const activePullRequests: PullRequestModel[] = reposManager.folderManagers - .map(folderManager => folderManager.activePullRequest!) - .filter(activePR => !!activePR); - const pr = await chooseItem( - activePullRequests, - itemValue => `${itemValue.number}: ${itemValue.title}`, - { placeHolder: vscode.l10n.t('Pull request to create a link for') }, - ); + vscode.commands.registerCommand('pr.copyVscodeDevPrLink', async (params: OverviewContext | undefined) => { + let pr: PullRequestModel | undefined; + if (params) { + pr = await reposManager.getManagerForRepository(params.owner, params.repo)?.resolvePullRequest(params.owner, params.repo, params.number, true); + } else { + const activePullRequests: PullRequestModel[] = reposManager.folderManagers + .map(folderManager => folderManager.activePullRequest!) + .filter(activePR => !!activePR); + pr = await chooseItem( + activePullRequests, + itemValue => ({ label: `${itemValue.number}: ${itemValue.title}` }), + { placeHolder: vscode.l10n.t('Pull request to create a link for') }, + ); + } if (pr) { return vscode.env.clipboard.writeText(vscodeDevPrLink(pr)); } })); + context.subscriptions.push( + vscode.commands.registerCommand('pr.copyPrLink', async (params: OverviewContext | undefined) => { + let item: PullRequestModel | IssueModel | undefined; + if (params) { + const folderManager = reposManager.getManagerForRepository(params.owner, params.repo); + item = await folderManager?.resolvePullRequest(params.owner, params.repo, params.number, true); + if (!item) { + item = await folderManager?.resolveIssue(params.owner, params.repo, params.number); + } + } + if (item) { + return vscode.env.clipboard.writeText(item.html_url); + } + })); + + function validateAndParseInput(input: string, expectedOwner: string, expectedRepo: string): { isValid: true; prNumber: number; errorMessage?: string } | { isValid: false; prNumber?: number; errorMessage: string } { + const prNumberMatcher = /^#?(\d*)$/; + const numberMatches = input.match(prNumberMatcher); + if (numberMatches && (numberMatches.length === 2) && !Number.isNaN(Number(numberMatches[1]))) { + const num = Number(numberMatches[1]); + if (num > 0) { + return { isValid: true, prNumber: num }; + } + } + + const urlMatches = input.match(ISSUE_OR_URL_EXPRESSION); + const parsed = parseIssueExpressionOutput(urlMatches); + if (parsed && parsed.issueNumber && parsed.issueNumber > 0) { + // Check if the repository owner and name match + if (parsed.owner && parsed.name) { + if (parsed.owner !== expectedOwner || parsed.name !== expectedRepo) { + return { isValid: false, errorMessage: vscode.l10n.t('Repository in URL does not match the selected repository') }; + } + } + return { isValid: true, prNumber: parsed.issueNumber }; + } + + return { isValid: false, errorMessage: vscode.l10n.t('Value must be a pull request number or GitHub URL') }; + } + context.subscriptions.push( vscode.commands.registerCommand('pr.checkoutByNumber', async () => { const githubRepositories: { manager: FolderRepositoryManager, repo: GitHubRepository }[] = []; - reposManager.folderManagers.forEach(manager => { - githubRepositories.push(...(manager.gitHubRepositories.map(repo => { return { manager, repo }; }))); - }); + for (const manager of reposManager.folderManagers) { + const remotes = await manager.getActiveGitHubRemotes(await manager.getGitHubRemotes()); + const activeGitHubRepos = manager.gitHubRepositories.filter(repo => remotes.find(remote => remote.remoteName === repo.remote.remoteName)); + githubRepositories.push(...(activeGitHubRepos.map(repo => { return { manager, repo }; }))); + } 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) { return; } - const prNumberMatcher = /^#?(\d*)$/; - const prNumber = await vscode.window.showInputBox({ - ignoreFocusOut: true, prompt: vscode.l10n.t('Enter the pull request number'), - validateInput: (input: string) => { - const matches = input.match(prNumberMatcher); - if (!matches || (matches.length !== 2) || Number.isNaN(Number(matches[1]))) { - return vscode.l10n.t('Value must be a number'); + + // Create QuickPick to show all PRs + const quickPick = vscode.window.createQuickPick(); + quickPick.placeholder = vscode.l10n.t('Enter a pull request number/URL or select from the list'); + quickPick.matchOnDescription = true; + quickPick.matchOnDetail = true; + quickPick.show(); + + let acceptDisposable: vscode.Disposable | undefined; + let hideDisposable: vscode.Disposable | undefined; + + try { + const selectedPromise = new Promise<{ selectedItem: (vscode.QuickPickItem & { prNumber?: number }) | undefined, selectedString: string | undefined }>((resolve) => { + acceptDisposable = quickPick.onDidAccept(() => { + let selectedString: string | undefined; + let selectedItem: (vscode.QuickPickItem & { prNumber?: number }) | undefined; + + if (quickPick.value) { + selectedString = quickPick.value; + } + + if (quickPick.selectedItems.length > 0) { + selectedItem = quickPick.selectedItems[0]; + } + + resolve({ selectedItem, selectedString }); + }); + hideDisposable = quickPick.onDidHide(() => resolve({ selectedItem: undefined, selectedString: undefined })); + }); + + const prs = await githubRepo.repo.getPullRequestNumbers(); + if (!prs) { + return vscode.window.showErrorMessage(vscode.l10n.t('Failed to fetch pull requests')); + } + // Sort PRs by number in descending order (most recent first) + const sortedPRs = prs.sort((a, b) => b.number - a.number); + const prItems: (vscode.QuickPickItem & { prNumber: number })[] = sortedPRs.map(pr => ({ + label: `#${pr.number} ${pr.title}`, + description: `by @${pr.author.login}`, + prNumber: pr.number + })); + + quickPick.items = prItems; + const selected = await selectedPromise; + quickPick.busy = true; + + if (!selected.selectedItem && !selected.selectedString) { + return; + } + let prModel: PullRequestModel | undefined; + + // Check if user selected from the list or typed a custom value + if (selected.selectedString) { + // User typed a PR number or URL + const parseResult = validateAndParseInput(selected.selectedString, githubRepo.repo.remote.owner, githubRepo.repo.remote.repositoryName); + if (!parseResult.isValid && !selected.selectedItem) { + return vscode.window.showErrorMessage(parseResult.errorMessage || vscode.l10n.t('Invalid pull request number or URL')); + } + + if (parseResult.prNumber !== undefined) { + // The user may have just entered part of a number and meant to select it from the list + const selectedItemNumber = selected.selectedItem?.prNumber; + if (selectedItemNumber !== undefined) { + const parsedDigits = parseResult.prNumber.toString(); + const selectedDigits = selectedItemNumber.toString(); + if (selectedDigits.length > parsedDigits.length && selectedDigits.startsWith(parsedDigits)) { + parseResult.prNumber = selectedItemNumber; + } + } + prModel = await githubRepo.manager.fetchById(githubRepo.repo, parseResult.prNumber); } - return undefined; } - }); - if ((prNumber === undefined) || prNumber === '#') { - return; - } - const prModel = await githubRepo.manager.fetchById(githubRepo.repo, Number(prNumber.match(prNumberMatcher)![1])); - if (prModel) { - return ReviewManager.getReviewManagerForFolderManager(reviewManagers, githubRepo.manager)?.switch(prModel); + if (selected.selectedItem?.prNumber && !prModel) { + // User selected from the list + prModel = await githubRepo.manager.fetchById(githubRepo.repo, selected.selectedItem.prNumber); + } + + if (prModel) { + return ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, githubRepo.manager)?.switch(prModel); + } + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Failed to fetch pull requests: {0}', formatError(e))); + } finally { + // Clean up event listeners and QuickPick + acceptDisposable?.dispose(); + hideDisposable?.dispose(); + quickPick.hide(); + quickPick.dispose(); } })); @@ -1142,7 +1787,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?') } ); } @@ -1153,6 +1798,7 @@ ${contents} vscode.env.openExternal(getPullsUrl(githubRepo)); } })); + context.subscriptions.push( vscode.commands.registerCommand('issues.openIssuesWebsite', async () => { const githubRepo = await chooseRepoToOpen(); @@ -1174,8 +1820,84 @@ ${contents} handler.applySuggestion(comment); } })); + context.subscriptions.push( + vscode.commands.registerCommand('pr.applySuggestionWithCopilot', async (comment: GHPRComment | GHPRCommentThread) => { + /* __GDPR__ + "pr.applySuggestionWithCopilot" : {} + */ + telemetry.sendTelemetryEvent('pr.applySuggestionWithCopilot'); + + const isThread = GHPRCommentThread.is(comment); + const commentThread = isThread ? comment : comment.parent; + const commentBody = isThread ? comment.comments[0].body : comment.body; + commentThread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed; + const message = commentBody instanceof vscode.MarkdownString ? commentBody.value : commentBody; + + if (isThread) { + // For threads, open the Chat view instead of inline chat + await vscode.commands.executeCommand(commands.NEW_CHAT, { inputValue: message, isPartialQuery: true, agentMode: true }); + await vscode.commands.executeCommand(commands.OPEN_CHAT); + } else { + // For single comments, use inline chat + await vscode.commands.executeCommand('vscode.editorChat.start', { + initialRange: commentThread.range, + message: message, + autoSend: true, + }); + } + }) + ); + context.subscriptions.push( + vscode.commands.registerCommand('pr.addFileComment', async () => { + return vscode.commands.executeCommand('workbench.action.addComment', { fileComment: true }); + })); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.toggleEditorCommentingOn', async () => { + commands.executeCommand('workbench.action.toggleCommenting'); + })); + context.subscriptions.push( + vscode.commands.registerCommand('pr.toggleEditorCommentingOff', async () => { + commands.executeCommand('workbench.action.toggleCommenting'); + })); + + context.subscriptions.push( + vscode.commands.registerCommand('review.diffWithPrHead', async (fileChangeNode: GitFileChangeNode) => { + const fileName = fileChangeNode.fileName; + let parentURI = toPRUri( + fileChangeNode.resourceUri, + fileChangeNode.pullRequest, + fileChangeNode.pullRequest.base.sha, + fileChangeNode.pullRequest.head.sha, + fileChangeNode.fileName, + true, + fileChangeNode.status); + let headURI = toPRUri( + fileChangeNode.resourceUri, + fileChangeNode.pullRequest, + fileChangeNode.pullRequest.base.sha, + fileChangeNode.pullRequest.head.sha, + fileChangeNode.fileName, + false, + fileChangeNode.status); + return vscode.commands.executeCommand('vscode.diff', parentURI, headURI, `${fileName} (Pull Request Compare Base with Head)`); + })); + + context.subscriptions.push( + vscode.commands.registerCommand('review.diffLocalWithPrHead', async (fileChangeNode: GitFileChangeNode) => { + const fileName = fileChangeNode.fileName; + let headURI = toPRUri( + fileChangeNode.resourceUri, + fileChangeNode.pullRequest, + fileChangeNode.pullRequest.base.sha, + fileChangeNode.pullRequest.head.sha, + fileChangeNode.fileName, + false, + fileChangeNode.status); + return vscode.commands.executeCommand('vscode.diff', headURI, fileChangeNode.resourceUri, `${fileName} (Pull Request Compare Head with Local)`); + })); - function goToNextPrevDiff(diffs: vscode.LineChange[], next: boolean) { + async function goToNextPrevDiff(diffs: vscode.LineChange[], next: boolean) { const tab = vscode.window.tabGroups.activeTabGroup.activeTab; const input = tab?.input; if (!(input instanceof vscode.TabInputTextDiff)) { @@ -1188,32 +1910,40 @@ ${contents} } const editorUri = editor.document.uri; - if (input.original.scheme !== Schemes.Review) { + if ((input.original.scheme !== Schemes.Review) && (input.original.scheme !== Schemes.Pr)) { return vscode.window.showErrorMessage(vscode.l10n.t('Current file isn\'t a pull request diff.')); } // Find the next diff in the current file to scroll to - const visibleRange = editor.visibleRanges[0]; + const cursorPosition = editor.selection.active; const iterateThroughDiffs = next ? diffs : diffs.reverse(); for (const diff of iterateThroughDiffs) { const practicalModifiedEndLineNumber = (diff.modifiedEndLineNumber > diff.modifiedStartLineNumber) ? diff.modifiedEndLineNumber : diff.modifiedStartLineNumber as number + 1; const diffRange = new vscode.Range(diff.modifiedStartLineNumber ? diff.modifiedStartLineNumber - 1 : diff.modifiedStartLineNumber, 0, practicalModifiedEndLineNumber, 0); - if (next && (visibleRange.end.line < practicalModifiedEndLineNumber) && (visibleRange.end.line !== (editor.document.lineCount - 1))) { + + // cursorPosition.line is 0-based, diff.modifiedStartLineNumber is 1-based + if (next && cursorPosition.line + 1 < diff.modifiedStartLineNumber) { editor.revealRange(diffRange); + editor.selection = new vscode.Selection(diffRange.start, diffRange.start); return; - } else if (!next && (visibleRange.start.line > diff.modifiedStartLineNumber) && (visibleRange.start.line !== 0)) { + } else if (!next && cursorPosition.line + 1 > diff.modifiedStartLineNumber) { editor.revealRange(diffRange); + editor.selection = new vscode.Selection(diffRange.start, diffRange.start); return; } } + if (input.original.scheme === Schemes.Pr) { + return vscode.window.showInformationMessage(vscode.l10n.t('No more diffs in this file. Check out the pull request to use this command across files.')); + } + // There is no new range to reveal, time to go to the next file. const folderManager = reposManager.getManagerForFile(editorUri); if (!folderManager) { return vscode.window.showErrorMessage(vscode.l10n.t('Unable to find a repository for pull request.')); } - const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewManagers, folderManager); + const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager); if (!reviewManager) { return vscode.window.showErrorMessage(vscode.l10n.t('Cannot find active pull request.')); } @@ -1223,12 +1953,26 @@ ${contents} } for (let i = 0; i < reviewManager.reviewModel.localFileChanges.length; i++) { - const index = next ? i : reviewManager.reviewModel.localFileChanges.length - 1; + const index = next ? i : reviewManager.reviewModel.localFileChanges.length - 1 - i; const localFileChange = reviewManager.reviewModel.localFileChanges[index]; if (localFileChange.changeModel.filePath.toString() === editorUri.toString()) { const nextIndex = next ? index + 1 : index - 1; if (reviewManager.reviewModel.localFileChanges.length > nextIndex) { - return reviewManager.reviewModel.localFileChanges[nextIndex].openDiff(folderManager); + await reviewManager.reviewModel.localFileChanges[nextIndex].openDiff(folderManager); + // if going backwards, we now need to go to the last diff in the file + if (!next) { + const editor = vscode.window.visibleTextEditors.find(editor => editor.document.uri.toString() === reviewManager.reviewModel.localFileChanges[nextIndex].changeModel.filePath.toString()); + if (editor) { + const diffs = await reviewManager.reviewModel.localFileChanges[nextIndex].changeModel.diffHunks(); + const diff = diffs[diffs.length - 1]; + const diffNewEndLine = diff.newLineNumber + diff.newLength; + const practicalModifiedEndLineNumber = (diffNewEndLine > diff.newLineNumber) ? diffNewEndLine : diff.newLineNumber as number + 1; + const diffRange = new vscode.Range(diff.newLineNumber ? diff.newLineNumber - 1 : diff.newLineNumber, 0, practicalModifiedEndLineNumber, 0); + editor.revealRange(diffRange); + editor.selection = new vscode.Selection(diffRange.start, diffRange.start); + } + } + return; } } } @@ -1249,4 +1993,37 @@ ${contents} vscode.commands.registerDiffInformationCommand('pr.goToPreviousDiffInPr', async (diffs: vscode.LineChange[]) => { goToNextPrevDiff(diffs, false); })); + + context.subscriptions.push(vscode.commands.registerCommand('pr.refreshComments', async () => { + for (const folderManager of reposManager.folderManagers) { + for (const githubRepository of folderManager.gitHubRepositories) { + for (const pullRequest of githubRepository.pullRequestModels) { + if (pullRequest.isResolved() && pullRequest.reviewThreadsCacheReady) { + pullRequest.initializeReviewThreadCache(); + } + } + } + } + })); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.closeRelatedEditors', closeAllPrAndReviewEditors) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('review.copyPrLink', async () => { + const activePullRequests: PullRequestModel[] = reposManager.folderManagers + .map(folderManager => folderManager.activePullRequest!) + .filter(activePR => !!activePR); + + const pr = await chooseItem( + activePullRequests, + itemValue => ({ label: `${itemValue.number}: ${itemValue.title}` }), + { placeHolder: vscode.l10n.t('Pull request to create a link for') }, + ); + if (pr) { + return vscode.env.clipboard.writeText(pr.html_url); + } + }) + ); } diff --git a/src/commentHandlerResolver.ts b/src/commentHandlerResolver.ts index 4018ca8be2..8ce5803c09 100644 --- a/src/commentHandlerResolver.ts +++ b/src/commentHandlerResolver.ts @@ -5,11 +5,12 @@ 'use strict'; import * as vscode from 'vscode'; +import { Repository } from './api/api'; import Logger from './common/logger'; import { GHPRComment, GHPRCommentThread, TemporaryComment } from './github/prComment'; export interface CommentHandler { - commentController?: vscode.CommentController; + commentController: vscode.CommentController; hasCommentThread(thread: GHPRCommentThread): boolean; createOrReplyComment(thread: GHPRCommentThread, input: string, isSingleComment: boolean): Promise; @@ -34,10 +35,10 @@ export namespace CommentReply { } } -const commentHandlers = new Map(); +const commentHandlers = new Map(); -export function registerCommentHandler(key: string, commentHandler: CommentHandler) { - commentHandlers.set(key, commentHandler); +export function registerCommentHandler(key: string, commentHandler: CommentHandler, repository: Repository) { + commentHandlers.set(key, { handler: commentHandler, repoRootUri: repository.rootUri.toString() }); } export function unregisterCommentHandler(key: string) { @@ -45,13 +46,27 @@ export function unregisterCommentHandler(key: string) { } export function resolveCommentHandler(commentThread: GHPRCommentThread): CommentHandler | undefined { + const possibleHandlers: { handler: CommentHandler, repoRootUri: string }[] = []; for (const commentHandler of commentHandlers.values()) { - if (commentHandler.hasCommentThread(commentThread)) { - return commentHandler; + if (commentHandler.handler.hasCommentThread(commentThread)) { + possibleHandlers.push(commentHandler); } } - + if (possibleHandlers.length > 0) { + possibleHandlers.sort((a, b) => { + return b.repoRootUri.length - a.repoRootUri.length; + }); + return possibleHandlers[0].handler; + } Logger.warn(`Unable to find handler for comment thread ${commentThread.gitHubThreadId}`); return; } + +export function findActiveHandler() { + for (const commentHandler of commentHandlers.values()) { + if (commentHandler.handler.commentController.activeCommentThread) { + return commentHandler.handler; + } + } +} diff --git a/src/common/async.ts b/src/common/async.ts index 87e1ddd024..99c385dd4f 100644 --- a/src/common/async.ts +++ b/src/common/async.ts @@ -36,11 +36,13 @@ export function throttle(fn: () => Promise): () => Promise { return trigger; } -export function debounce(fn: () => any, delay: number): () => void { - let timer: any; +export function debounce any>(fn: T, delay: number): (...args: Parameters) => void { + let timer: NodeJS.Timeout | undefined; - return () => { - clearTimeout(timer); - timer = setTimeout(() => fn(), delay); + return (...args: Parameters) => { + if (timer) { + clearTimeout(timer); + } + timer = setTimeout(() => fn(...args), delay); }; } diff --git a/src/common/authentication.ts b/src/common/authentication.ts index 65cbcb269f..92a81972d9 100644 --- a/src/common/authentication.ts +++ b/src/common/authentication.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; + export enum GitHubServerType { None, GitHubDotCom, @@ -11,17 +13,15 @@ export enum GitHubServerType { export enum AuthProvider { github = 'github', - 'github-enterprise' = 'github-enterprise' + githubEnterprise = 'github-enterprise' } export class AuthenticationError extends Error { - name: string; - stack?: string; - constructor(public message: string) { - super(message); + constructor() { + super(vscode.l10n.t('Not authenticated')); } } export function isSamlError(e: { message?: string }): boolean { - return !!e.message?.startsWith('Resource protected by organization SAML enforcement.'); + return !!e.message?.includes('Resource protected by organization SAML enforcement.'); } diff --git a/src/common/comment.ts b/src/common/comment.ts index f1bb8db567..718c64d747 100644 --- a/src/common/comment.ts +++ b/src/common/comment.ts @@ -4,8 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { IAccount } from '../github/interface'; +import { COPILOT_LOGINS } from './copilot'; import { DiffHunk } from './diffHunk'; +import { IAccount, Reaction } from '../github/interface'; export enum DiffSide { LEFT = 'LEFT', @@ -18,11 +19,9 @@ export enum ViewedState { UNVIEWED = 'UNVIEWED' } -export interface Reaction { - label: string; - count: number; - icon?: vscode.Uri; - viewerHasReacted: boolean; +export enum SubjectType { + LINE = 'LINE', + FILE = 'FILE' } export interface IReviewThread { @@ -39,6 +38,7 @@ export interface IReviewThread { originalEndLine: number; isOutdated: boolean; comments: IComment[]; + subjectType: SubjectType; } export interface IComment { @@ -57,6 +57,7 @@ export interface IComment { originalCommitId?: string; user?: IAccount; body: string; + specialDisplayBodyPostfix?: string; createdAt: string; htmlUrl: string; isDraft?: boolean; @@ -64,4 +65,14 @@ export interface IComment { graphNodeId: string; reactions?: Reaction[]; isResolved?: boolean; + isOutdated?: boolean; } + +const COPILOT_AUTHOR = { + name: 'Copilot', // TODO: The copilot reviewer is a Bot, but per the graphQL schema, Bots don't have a name, just a login. We have it hardcoded here for now. + postComment: vscode.l10n.t('Copilot is powered by AI, so mistakes are possible. Review output carefully before use.'), + url: 'https://github.com/apps/copilot-swe-agent' +}; + +export const COPILOT_ACCOUNTS: { [key: string]: { postComment: string, name: string, url: string } } = + Object.fromEntries(COPILOT_LOGINS.map(login => [login, COPILOT_AUTHOR])); \ No newline at end of file diff --git a/src/common/config.ts b/src/common/config.ts new file mode 100644 index 0000000000..16d74de481 --- /dev/null +++ b/src/common/config.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import vscode from 'vscode'; +import { CODING_AGENT, CODING_AGENT_AUTO_COMMIT_AND_PUSH, CODING_AGENT_ENABLED, CODING_AGENT_PROMPT_FOR_CONFIRMATION } from './settingKeys'; + +/** + * Handles configuration settings for the Copilot Remote Agent + */ +export namespace CopilotRemoteAgentConfig { + function config() { + return vscode.workspace.getConfiguration(CODING_AGENT); + } + + export function getEnabled(): boolean { + return config().get(CODING_AGENT_ENABLED, false); + } + + export function getPromptForConfirmation(): boolean { + return config().get(CODING_AGENT_PROMPT_FOR_CONFIRMATION, true); + + } + + export function getAutoCommitAndPushEnabled(): boolean { + return config().get(CODING_AGENT_AUTO_COMMIT_AND_PUSH, false); + } + + export async function disablePromptForConfirmation(): Promise { + await config().update(CODING_AGENT_PROMPT_FOR_CONFIRMATION, false, vscode.ConfigurationTarget.Global); + } +} diff --git a/src/common/copilot.ts b/src/common/copilot.ts new file mode 100644 index 0000000000..0062faf66f --- /dev/null +++ b/src/common/copilot.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 { EventType, TimelineEvent } from './timelineEvent'; +import { AccountType, IAccount } from '../github/interface'; + +export const COPILOT_SWE_AGENT = 'copilot-swe-agent'; +export const COPILOT_CLOUD_AGENT = 'copilot-cloud-agent'; +export const COPILOT_REVIEWER = 'copilot-pull-request-reviewer'; +export const COPILOT_REVIEWER_ID = 'BOT_kgDOCnlnWA'; + +export const COPILOT_LOGINS = [ + COPILOT_REVIEWER, + COPILOT_SWE_AGENT, + 'Copilot' +]; + +export const COPILOT_REVIEWER_ACCOUNT: IAccount = { + login: COPILOT_REVIEWER, + id: COPILOT_REVIEWER_ID, + url: '', + avatarUrl: '', + name: 'Copilot', + accountType: AccountType.Bot +}; + +export enum CopilotPRStatus { + None = 0, + Started = 1, + Completed = 2, + Failed = 3, +} + +export function copilotEventToStatus(event: TimelineEvent | undefined): CopilotPRStatus { + if (!event) { + return CopilotPRStatus.None; + } + + switch (event.event) { + case EventType.CopilotStarted: + return CopilotPRStatus.Started; + case EventType.CopilotFinished: + return CopilotPRStatus.Completed; + case EventType.CopilotFinishedError: + return CopilotPRStatus.Failed; + default: + return CopilotPRStatus.None; + } +} + +export function mostRecentCopilotEvent(events: TimelineEvent[]): TimelineEvent | undefined { + for (let i = events.length - 1; i >= 0; i--) { + const status = copilotEventToStatus(events[i]); + if (status !== CopilotPRStatus.None) { + return events[i]; + } + } + return undefined; +} \ No newline at end of file diff --git a/src/common/diffHunk.ts b/src/common/diffHunk.ts index 8ff514624d..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, ) { } } @@ -97,7 +93,7 @@ export function* LineReader(text: string): IterableIterator { } export function* parseDiffHunk(diffHunkPatch: string): IterableIterator { - const lineReader = LineReader(diffHunkPatch); + const lineReader: Iterator = LineReader(diffHunkPatch); let itr = lineReader.next(); let diffHunk: DiffHunk | undefined = undefined; @@ -179,26 +175,86 @@ export function parsePatch(patch: string): DiffHunk[] { let diffHunkIter = diffHunkReader.next(); const diffHunks: DiffHunk[] = []; - const right: string[] = []; while (!diffHunkIter.done) { const diffHunk = diffHunkIter.value; diffHunks.push(diffHunk); + diffHunkIter = diffHunkReader.next(); + } - for (let j = 0; j < diffHunk.diffLines.length; j++) { - const diffLine = diffHunk.diffLines[j]; - if (diffLine.type === DiffChangeType.Delete || diffLine.type === DiffChangeType.Control) { - } else if (diffLine.type === DiffChangeType.Add) { - right.push(diffLine.text); - } else { - const codeInFirstLine = diffLine.text; - right.push(codeInFirstLine); + return diffHunks; +} + +/** + * Split a hunk into smaller hunks based on the context lines. Position in hunk and control lines are not preserved. + */ +export function splitIntoSmallerHunks(hunk: DiffHunk): DiffHunk[] { + const splitHunks: DiffHunk[] = []; + const newHunk = (fromLine: DiffLine) => { + return { + diffLines: [], + newLength: 0, + oldLength: 0, + oldLineNumber: fromLine.oldLineNumber, + newLineNumber: fromLine.newLineNumber, + positionInHunk: 0 + }; + }; + + // Split hunk into smaller hunks on context lines. + // Context lines will be duplicated across the new smaller hunks + let currentHunk: DiffHunk | undefined; + let nextHunk: DiffHunk | undefined; + + const addLineToHunk = (hunk: DiffHunk, line: DiffLine) => { + hunk.diffLines.push(line); + if (line.type === DiffChangeType.Delete) { + hunk.oldLength++; + } else if (line.type === DiffChangeType.Add) { + hunk.newLength++; + } else if (line.type === DiffChangeType.Context) { + hunk.oldLength++; + hunk.newLength++; + } + }; + const hunkHasChanges = (hunk: DiffHunk) => { + return hunk.diffLines.some(line => line.type !== DiffChangeType.Context); + }; + const hunkHasSandwichedChanges = (hunk: DiffHunk) => { + return hunkHasChanges(hunk) && hunk.diffLines[hunk.diffLines.length - 1].type === DiffChangeType.Context; + }; + + for (const line of hunk.diffLines) { + if (line.type === DiffChangeType.Context) { + if (!currentHunk) { + currentHunk = newHunk(line); + } + addLineToHunk(currentHunk, line); + if (hunkHasSandwichedChanges(currentHunk)) { + if (!nextHunk) { + nextHunk = newHunk(line); + } + addLineToHunk(nextHunk, line); + } + } else if (currentHunk || ((hunk.oldLineNumber === 1) && ((line.type === DiffChangeType.Delete) || (line.type === DiffChangeType.Add)))) { + if (!currentHunk) { + currentHunk = newHunk(line); + } + if (hunkHasSandwichedChanges(currentHunk)) { + splitHunks.push(currentHunk); + currentHunk = nextHunk!; + nextHunk = undefined; + } + if ((line.type === DiffChangeType.Delete) || (line.type === DiffChangeType.Add)) { + addLineToHunk(currentHunk, line); } } + } - diffHunkIter = diffHunkReader.next(); + if (currentHunk) { + splitHunks.push(currentHunk); } - return diffHunks; + return splitHunks; } export function getModifiedContentFromDiffHunk(originalContent: string, patch: string) { @@ -209,6 +265,7 @@ export function getModifiedContentFromDiffHunk(originalContent: string, patch: s const right: string[] = []; let lastCommonLine = 0; + let lastDiffLineEndsWithNewline = true; while (!diffHunkIter.done) { const diffHunk: DiffHunk = diffHunkIter.value; diffHunks.push(diffHunk); @@ -233,11 +290,24 @@ export function getModifiedContentFromDiffHunk(originalContent: string, patch: s } diffHunkIter = diffHunkReader.next(); + if (diffHunkIter.done) { + // Find last line that wasn't a delete + for (let k = diffHunk.diffLines.length - 1; k >= 0; k--) { + if (diffHunk.diffLines[k].type !== DiffChangeType.Delete) { + lastDiffLineEndsWithNewline = diffHunk.diffLines[k].endwithLineBreak; + break; + } + } + } } - if (lastCommonLine < left.length) { - for (let j = lastCommonLine + 1; j <= left.length; j++) { - right.push(left[j - 1]); + if (lastDiffLineEndsWithNewline) { // if this is false, then the patch has shortened the file + if (lastCommonLine < left.length) { + for (let j = lastCommonLine + 1; j <= left.length; j++) { + right.push(left[j - 1]); + } + } else { + right.push(''); } } @@ -269,7 +339,7 @@ export async function parseDiff( const review = reviews[i]; const gitChangeType = getGitChangeType(review.status); - if (!review.patch && + if ((!review.patch && (gitChangeType !== GitChangeType.RENAME) && (gitChangeType !== GitChangeType.MODIFY)) && // We don't need to make a SlimFileChange for empty file adds. !((gitChangeType === GitChangeType.ADD) && (review.additions === 0))) { fileChanges.push( @@ -284,16 +354,16 @@ export async function parseDiff( continue; } - const diffHunks = review.patch ? parsePatch(review.patch) : []; + const diffHunks = review.patch ? parsePatch(review.patch) : undefined; fileChanges.push( new InMemFileChange( parentCommit, gitChangeType, review.filename, review.previous_filename, - review.patch, + review.patch ?? '', diffHunks, - review.blob_url, + review.blob_url ), ); } diff --git a/src/common/diffPositionMapping.ts b/src/common/diffPositionMapping.ts index a8d4f69804..ddb8657d68 100644 --- a/src/common/diffPositionMapping.ts +++ b/src/common/diffPositionMapping.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { DiffHunk, DiffLine, parseDiffHunk } from './diffHunk'; +import { DiffChangeType, DiffHunk, DiffLine, parseDiffHunk } from './diffHunk'; /** * Line position in a git diff is 1 based, except for the case when the original or changed file have @@ -31,7 +31,7 @@ export function getDiffLineByPosition(diffHunks: DiffHunk[], diffLineNumber: num return undefined; } -export function mapOldPositionToNew(patch: string, line: number): number { +export function mapOldPositionToNew(patch: string, line: number, documentLineCount?: number): number { const diffReader = parseDiffHunk(patch); let diffIter = diffReader.next(); @@ -43,9 +43,23 @@ export function mapOldPositionToNew(patch: string, line: number): number { // No-op } else if (diffHunk.oldLineNumber + diffHunk.oldLength - 1 < line) { delta += diffHunk.newLength - diffHunk.oldLength; - } else { + } else if (documentLineCount === diffHunk.newLength) { + // The diff doesn't give us enough information to do a good calculation as entire document was added or removed delta += diffHunk.newLength - diffHunk.oldLength; return line + delta; + } else { + // Part of the hunk is before line, part is after. + for (const diffLine of diffHunk.diffLines) { + if (diffLine.oldLineNumber > line) { + return line + delta; + } + if (diffLine.type === DiffChangeType.Add) { + delta++; + } else if (diffLine.type === DiffChangeType.Delete) { + delta--; + } + } + return line + delta; } diffIter = diffReader.next(); @@ -67,7 +81,17 @@ export function mapNewPositionToOld(patch: string, line: number): number { } else if (diffHunk.newLineNumber + diffHunk.newLength - 1 < line) { delta += diffHunk.oldLength - diffHunk.newLength; } else { - delta += diffHunk.oldLength - diffHunk.newLength; + // Part of the hunk is before line, part is after. + for (const diffLine of diffHunk.diffLines) { + if (diffLine.type === DiffChangeType.Add) { + delta--; + } else if (diffLine.type === DiffChangeType.Delete) { + delta++; + } + if (diffLine.newLineNumber > line) { + return line + delta; + } + } return line + delta; } diff --git a/src/common/emoji.ts b/src/common/emoji.ts new file mode 100644 index 0000000000..f94b7a612f --- /dev/null +++ b/src/common/emoji.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Copied from https://github.com/microsoft/vscode/blob/af33df91a45498435bc47f16444d91db4582ce48/extensions/git/src/emoji.ts + +'use strict'; +import { ExtensionContext, Uri, workspace } from 'vscode'; + +const emojiRegex = /:([-+_a-z0-9]+):/g; + +let emojiMap: Record | undefined; +let emojiMapPromise: Promise | undefined; + +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.joinPath(context.extensionUri, 'resources', 'emojis.json'); + emojiMap = JSON.parse(new TextDecoder('utf8').decode(await workspace.fs.readFile(uri))); +} + +export function emojify(message: string) { + if (emojiMap === undefined) { + return message; + } + + return message.replace(emojiRegex, (s, code) => { + return emojiMap?.[code] || s; + }); +} diff --git a/src/common/executeCommands.ts b/src/common/executeCommands.ts index 9e1290e190..945abba102 100644 --- a/src/common/executeCommands.ts +++ b/src/common/executeCommands.ts @@ -6,18 +6,30 @@ import * as vscode from 'vscode'; export namespace contexts { - export const VIEWED_FILES = 'github:viewedFiles'; - export const UNVIEWED_FILES = 'github:unviewedFiles'; - export const IN_REVIEW_MODE = 'github:inReviewMode'; - export const REPOS_NOT_IN_REVIEW_MODE = 'github:reposNotInReviewMode'; - export const REPOS_IN_REVIEW_MODE = 'github:reposInReviewMode'; - export const ACTIVE_PR_COUNT = 'github:activePRCount'; + export const VIEWED_FILES = 'github:viewedFiles'; // Array of file paths for viewed files + export const UNVIEWED_FILES = 'github:unviewedFiles'; // Array of file paths for unviewed files + export const IN_REVIEW_MODE = 'github:inReviewMode'; // Boolean indicating if the extension is currently in "review mode" (has a non-ignored PR checked out) + export const REPOS_NOT_IN_REVIEW_MODE = 'github:reposNotInReviewMode'; // Array of URIs for repos that are not in review mode + export const REPOS_IN_REVIEW_MODE = 'github:reposInReviewMode'; // Array of URIs for repos that are in review mode + export const ACTIVE_PR_COUNT = 'github:activePRCount'; // Number of PRs that are currently checked out export const LOADING_PRS_TREE = 'github:loadingPrsTree'; export const LOADING_ISSUES_TREE = 'github:loadingIssuesTree'; export const CREATE_PR_PERMISSIONS = 'github:createPrPermissions'; + export const RESOLVING_CONFLICTS = 'github:resolvingConflicts'; + export const PULL_REQUEST_DESCRIPTION_VISIBLE = 'github:pullRequestDescriptionVisible'; // Boolean indicating if the pull request description is visible + export const ACTIVE_COMMENT_HAS_SUGGESTION = 'github:activeCommentHasSuggestion'; // Boolean indicating if the active comment has a suggestion + export const CREATING = 'pr:creating'; + export const NOTIFICATION_COUNT = 'github:notificationCount'; // Number of notifications in the notifications view + export const ACTIVATED = 'github.vscode-pull-request-github.activated'; // Boolean indicating if the extension has been activated } export namespace commands { + export const OPEN_CHAT = 'workbench.action.chat.open'; + export const NEW_CHAT = 'workbench.action.chat.newChat'; + export const CHAT_SETUP_ACTION_ID = 'workbench.action.chat.triggerSetup'; + + export const QUICK_CHAT_OPEN = 'workbench.action.quickchat.toggle'; + export function executeCommand(command: string, arg1?: any, arg2?: any) { return vscode.commands.executeCommand(command, arg1, arg2); } @@ -29,4 +41,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/file.ts b/src/common/file.ts index c352a24137..13d201cfcd 100644 --- a/src/common/file.ts +++ b/src/common/file.ts @@ -30,9 +30,9 @@ export class InMemFileChange implements SimpleFileChange { public readonly fileName: string, public readonly previousFileName: string | undefined, public readonly patch: string, - public readonly diffHunks: DiffHunk[], + public readonly diffHunks: DiffHunk[] | undefined, public readonly blobUrl: string, - ) {} + ) { } } export class SlimFileChange implements SimpleFileChange { @@ -42,5 +42,5 @@ export class SlimFileChange implements SimpleFileChange { public readonly status: GitChangeType, public readonly fileName: string, public readonly previousFileName: string | undefined, - ) {} + ) { } } diff --git a/src/common/gitUtils.ts b/src/common/gitUtils.ts new file mode 100644 index 0000000000..dc4539e1ec --- /dev/null +++ b/src/common/gitUtils.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Repository } from '../api/api'; +import { GitApiImpl } from '../api/api1'; + +/** + * Determines if a repository is a submodule by checking if its path + * appears in any other repository's submodules list. + */ +export function isSubmodule(repo: Repository, git: GitApiImpl): boolean { + const repoPath = repo.rootUri.fsPath; + + // Check all other repositories to see if this repo is listed as a submodule + for (const otherRepo of git.repositories) { + if (otherRepo.rootUri.toString() === repo.rootUri.toString()) { + continue; // Skip self + } + + // Check if this repo's path appears in the other repo's submodules + for (const submodule of otherRepo.state.submodules) { + // The submodule path is relative to the parent repo, so we need to resolve it + const submodulePath = vscode.Uri.joinPath(otherRepo.rootUri, submodule.path).fsPath; + if (submodulePath === repoPath) { + return true; + } + } + } + + return false; +} \ No newline at end of file diff --git a/src/common/githubRef.ts b/src/common/githubRef.ts index 89ad60b069..a3803173ab 100644 --- a/src/common/githubRef.ts +++ b/src/common/githubRef.ts @@ -4,11 +4,30 @@ *--------------------------------------------------------------------------------------------*/ import { Protocol } from './protocol'; +import { parseRemote } from './remote'; +import { Remote, Repository } from '../api/api'; export class GitHubRef { public repositoryCloneUrl: Protocol; constructor(public ref: string, public label: string, public sha: string, repositoryCloneUrl: string, - public readonly owner: string, public readonly name: string) { + public readonly owner: string, public readonly name: string, public readonly isInOrganization: boolean) { this.repositoryCloneUrl = new Protocol(repositoryCloneUrl); } } + +export function findLocalRepoRemoteFromGitHubRef(repository: Repository, gitHubRef: GitHubRef): Remote | undefined { + const targetRepo = gitHubRef.repositoryCloneUrl.repositoryName.toLowerCase(); + const targetOwner = gitHubRef.owner.toLowerCase(); + for (const remote of repository.state.remotes) { + const url = remote.fetchUrl ?? remote.pushUrl; + if (!url) { + continue; + } + const parsedRemote = parseRemote(remote.name, url); + const parsedOwner = parsedRemote?.owner.toLowerCase(); + const parsedRepo = parsedRemote?.repositoryName.toLowerCase(); + if (parsedOwner === targetOwner && parsedRepo === targetRepo) { + return remote; + } + } +} diff --git a/src/common/lifecycle.ts b/src/common/lifecycle.ts new file mode 100644 index 0000000000..9e3e218d5c --- /dev/null +++ b/src/common/lifecycle.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; + +export function toDisposable(d: () => void): vscode.Disposable { + return { dispose: d }; +} + +export function combinedDisposable(disposables: vscode.Disposable[]): vscode.Disposable { + return toDisposable(() => disposeAll(disposables)); +} + +export function disposeAll(disposables: vscode.Disposable[]) { + while (disposables.length) { + const item = disposables.pop(); + item?.dispose(); + } +} + +export function addDisposable(a: T, disposables: vscode.Disposable[]): T { + disposables.push(a); + return a; +} + +export abstract class Disposable { + protected _isDisposed = false; + + private _disposables: vscode.Disposable[] = []; + + public dispose(): any { + if (this._isDisposed) { + return; + } + this._isDisposed = true; + disposeAll(this._disposables); + this._disposables = []; + } + + protected _register(value: T): T { + if (this._isDisposed) { + value.dispose(); + } else { + this._disposables.push(value); + } + return value; + } + + protected get isDisposed() { + return this._isDisposed; + } +} diff --git a/src/common/logger.ts b/src/common/logger.ts index 7abc27c0d1..024cd584d2 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -3,59 +3,71 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { Disposable } from './lifecycle'; export const PR_TREE = 'PullRequestTree'; -class Log { - private _outputChannel: vscode.LogOutputChannel; - private _disposable: vscode.Disposable; - private _activePerfMarkers: Map = new Map(); +interface Stringish { + toString: () => string; +} + +class Log extends Disposable { + private readonly _outputChannel: vscode.LogOutputChannel; + private readonly _activePerfMarkers: Map = new Map(); constructor() { - this._outputChannel = vscode.window.createOutputChannel('GitHub Pull Request', { log: true }); + super(); + this._outputChannel = this._register(vscode.window.createOutputChannel('GitHub Pull Request', { log: true })); } public startPerfMarker(marker: string) { const startTime = performance.now(); - this._outputChannel.appendLine(`PERF_MARKER> Start ${marker}`); + this._outputChannel.appendLine(`[PERF_MARKER] Start ${marker}`); this._activePerfMarkers.set(marker, startTime); } public endPerfMarker(marker: string) { const endTime = performance.now(); - this._outputChannel.appendLine(`PERF_MARKER> End ${marker}: ${endTime - this._activePerfMarkers.get(marker)!} ms`); + this._outputChannel.appendLine(`[PERF_MARKER] End ${marker}: ${endTime - this._activePerfMarkers.get(marker)!} ms`); this._activePerfMarkers.delete(marker); } - private logString(message: string, component?: string) { - return component ? `${component}> ${message}` : message; + private logString(message: string | Error | Stringish | Object, component?: string): string { + let logMessage: string; + if (typeof message !== 'string') { + const asString = message as Partial; + if (message instanceof Error) { + logMessage = message.message; + } else if (asString.toString) { + logMessage = asString.toString(); + } else { + logMessage = JSON.stringify(message); + } + } else { + logMessage = message; + } + return component ? `[${component}] ${logMessage}` : logMessage; } - public trace(message: string, component: string) { + public trace(message: string | Error | Stringish | Object, component: string) { this._outputChannel.trace(this.logString(message, component)); } - public debug(message: string, component: string) { + public debug(message: string | Error | Stringish | Object, component: string) { this._outputChannel.debug(this.logString(message, component)); } - public appendLine(message: string, component?: string) { + public appendLine(message: string | Error | Stringish | Object, component: string) { this._outputChannel.info(this.logString(message, component)); } - public warn(message: string, component?: string) { + public warn(message: string | Error | Stringish | Object, component?: string) { this._outputChannel.warn(this.logString(message, component)); } - public error(message: string, component?: string) { + public error(message: string | Error | Stringish | Object, component: string) { this._outputChannel.error(this.logString(message, component)); } - - public dispose() { - if (this._disposable) { - this._disposable.dispose(); - } - } } const Logger = new Log(); diff --git a/src/common/protocol.ts b/src/common/protocol.ts index a3b074f91f..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 { @@ -17,6 +17,7 @@ export enum ProtocolType { } export class Protocol { + private static readonly ID = 'Protocol'; public type: ProtocolType = ProtocolType.OTHER; public host: string = ''; @@ -31,6 +32,7 @@ export class Protocol { public readonly url: vscode.Uri; constructor(uriString: string) { if (this.parseSshProtocol(uriString)) { + this.url = vscode.Uri.from({ scheme: 'ssh', authority: this.host, path: `/${this.nameWithOwner}` }); return; } @@ -44,7 +46,7 @@ export class Protocol { this.owner = this.getOwnerName(this.url.path) || ''; } } catch (e) { - Logger.error(`Failed to parse '${uriString}'`); + Logger.error(`Failed to parse '${uriString}'`, Protocol.ID); vscode.window.showWarningMessage( vscode.l10n.t('Unable to parse remote \'{0}\'. Please check that it is correctly formatted.', uriString) ); diff --git a/src/common/remote.ts b/src/common/remote.ts index 4f3692fc3b..c5283ca606 100644 --- a/src/common/remote.ts +++ b/src/common/remote.ts @@ -3,10 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Repository } from '../api/api'; -import { getEnterpriseUri } from '../github/utils'; import { AuthProvider, GitHubServerType } from './authentication'; +import Logger from './logger'; import { Protocol } from './protocol'; +import { Repository } from '../api/api'; +import { getEnterpriseUri, isEnterprise } from '../github/utils'; export class Remote { public get host(): string { @@ -25,7 +26,11 @@ export class Remote { } public get authProviderId(): AuthProvider { - return this.host === getEnterpriseUri()?.authority ? AuthProvider['github-enterprise'] : AuthProvider.github; + return this.host === getEnterpriseUri()?.authority ? AuthProvider.githubEnterprise : AuthProvider.github; + } + + public get isEnterprise(): boolean { + return isEnterprise(this.authProviderId); } constructor( @@ -38,13 +43,13 @@ export class Remote { if (this.remoteName !== remote.remoteName) { return false; } - if (this.host !== remote.host) { + if (!this.host.includes(remote.host) && !remote.host.includes(this.host)) { return false; } - if (this.owner !== remote.owner) { + if (this.owner.toLocaleLowerCase() !== remote.owner.toLocaleLowerCase()) { return false; } - if (this.repositoryName !== remote.repositoryName) { + if (this.repositoryName.toLocaleLowerCase() !== remote.repositoryName.toLocaleLowerCase()) { return false; } @@ -70,6 +75,54 @@ export function parseRemote(remoteName: string, url: string, originalProtocol?: return null; } +/** + * Resolves git URL aliases by applying insteadOf substitutions from git config. + * For example, if git config has: + * [url "git@github.com:"] + * insteadOf = "gh:" + * Then "gh:user/repo" will be resolved to "git@github.com:user/repo" + * + * @param url The URL to resolve + * @param repository The repository to get config from + * @returns The resolved URL, or the original URL if no substitution found + */ +async function resolveGitUrl(url: string, repository: Repository): Promise { + try { + // Get all git config entries + const configs = await repository.getConfigs(); + + // Find all url.*.insteadOf entries + const urlSubstitutions: { prefix: string; replacement: string }[] = []; + + for (const config of configs) { + // Match patterns like "url.https://github.com/.insteadOf" or "url.git@github.com:.insteadOf" + const match = config.key.match(/^url\.(.+)\.insteadof$/i); + if (match) { + const replacement = match[1]; + const prefix = config.value; + urlSubstitutions.push({ prefix, replacement }); + } + } + + // Sort by prefix length (longest first) to handle overlapping prefixes correctly + urlSubstitutions.sort((a, b) => b.prefix.length - a.prefix.length); + + // Apply the first matching substitution + for (const { prefix, replacement } of urlSubstitutions) { + if (url.startsWith(prefix)) { + const resolvedUrl = replacement + url.substring(prefix.length); + Logger.appendLine(`Resolved git URL alias: "${url}" -> "${resolvedUrl}"`, 'Remote'); + return resolvedUrl; + } + } + } catch (error) { + Logger.error(`Failed to resolve git URL aliases for "${url}": ${error}`, 'Remote'); + } + + // No substitution found or error occurred, return original URL + return url; +} + export function parseRepositoryRemotes(repository: Repository): Remote[] { const remotes: Remote[] = []; for (const r of repository.state.remotes) { @@ -90,6 +143,38 @@ export function parseRepositoryRemotes(repository: Repository): Remote[] { return remotes; } +/** + * Asynchronously parses repository remotes with git URL alias resolution. + * This version resolves git URL aliases (e.g., "gh:" -> "git@github.com:") before parsing. + * Use this version when you need accurate remote parsing with alias resolution. + * + * @param repository The repository to parse remotes from + * @returns Promise resolving to array of Remote objects + */ +export async function parseRepositoryRemotesAsync(repository: Repository): Promise { + const remotes: Remote[] = []; + for (const r of repository.state.remotes) { + const urls: string[] = []; + if (r.fetchUrl) { + // Resolve git URL aliases before parsing + const resolvedUrl = await resolveGitUrl(r.fetchUrl, repository); + urls.push(resolvedUrl); + } + if (r.pushUrl && r.pushUrl !== r.fetchUrl) { + // Resolve git URL aliases before parsing + const resolvedUrl = await resolveGitUrl(r.pushUrl, repository); + urls.push(resolvedUrl); + } + urls.forEach(url => { + const remote = parseRemote(r.name, url); + if (remote) { + remotes.push(remote); + } + }); + } + return remotes; +} + export class GitHubRemote extends Remote { static remoteAsGitHub(remote: Remote, githubServerType: GitHubServerType): GitHubRemote { return new GitHubRemote(remote.remoteName, remote.url, remote.gitProtocol, githubServerType); diff --git a/src/common/resources.ts b/src/common/resources.ts deleted file mode 100644 index cff11666af..0000000000 --- a/src/common/resources.ts +++ /dev/null @@ -1,26 +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: any; - - 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 60132b8780..2464f03f11 100644 --- a/src/common/settingKeys.ts +++ b/src/common/settingKeys.ts @@ -6,21 +6,103 @@ 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'; +export const FILE_AUTO_REVEAL = 'fileAutoReveal'; export const ASSIGN_TO = 'assignCreated'; export const PUSH_BRANCH = 'pushBranch'; export const IGNORE_PR_BRANCHES = 'ignoredPullRequestBranches'; +export const IGNORE_SUBMODULES = 'ignoreSubmodules'; +export const NEVER_IGNORE_DEFAULT_BRANCH = 'neverIgnoreDefaultBranch'; 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 CHECKOUT_PULL_REQUEST_BASE_BRANCH = 'checkoutPullRequestBaseBranch'; +export const CHECKOUT_DEFAULT_BRANCH = 'checkoutDefaultBranch'; export const QUERIES = 'queries'; +export const PULL_REQUEST_LABELS = 'labelCreated'; export const FOCUSED_MODE = 'focusedMode'; export const CREATE_DRAFT = 'createDraft'; -export const QUICK_DIFF = 'experimental.quickDiff'; +export const QUICK_DIFF = 'quickDiff'; +export const SET_AUTO_MERGE = 'setAutoMerge'; +export const SHOW_PULL_REQUEST_NUMBER_IN_TREE = 'showPullRequestNumberInTree'; +export const DEFAULT_MERGE_METHOD = 'defaultMergeMethod'; +export const DEFAULT_DELETION_METHOD = 'defaultDeletionMethod'; +export const SELECT_LOCAL_BRANCH = 'selectLocalBranch'; +export const SELECT_REMOTE = 'selectRemote'; +export const DELETE_BRANCH_AFTER_MERGE = 'deleteBranchAfterMerge'; +export const REMOTES = 'remotes'; +export const PULL_PR_BRANCH_BEFORE_CHECKOUT = 'pullPullRequestBranchBeforeCheckout'; +export type PullPRBranchVariants = 'never' | 'pull' | 'pullAndMergeBase' | 'pullAndUpdateBase' | true | false; +export const UPSTREAM_REMOTE = 'upstreamRemote'; +export const DEFAULT_CREATE_OPTION = 'defaultCreateOption'; +export const CREATE_BASE_BRANCH = 'createDefaultBaseBranch'; + +export const ISSUES_SETTINGS_NAMESPACE = 'githubIssues'; +export const ASSIGN_WHEN_WORKING = 'assignWhenWorking'; +export const ISSUE_COMPLETIONS = 'issueCompletions'; +export const USER_COMPLETIONS = 'userCompletions'; +export const ENABLED = 'enabled'; +export const IGNORE_USER_COMPLETION_TRIGGER = 'ignoreUserCompletionTrigger'; +export const CREATE_INSERT_FORMAT = 'createInsertFormat'; +export const ISSUE_BRANCH_TITLE = 'issueBranchTitle'; +export const USE_BRANCH_FOR_ISSUES = 'useBranchForIssues'; +export const WORKING_BASE_BRANCH = 'workingBaseBranch'; +export const WORKING_ISSUE_FORMAT_SCM = 'workingIssueFormatScm'; +export const IGNORE_COMPLETION_TRIGGER = 'ignoreCompletionTrigger'; +export const ISSUE_COMPLETION_FORMAT_SCM = 'issueCompletionFormatScm'; +export const CREATE_ISSUE_TRIGGERS = 'createIssueTriggers'; +export const DEFAULT = 'default'; +export const IGNORE_MILESTONES = 'ignoreMilestones'; +export const ALLOW_FETCH = 'allowFetch'; +export const ALWAYS_PROMPT_FOR_NEW_ISSUE_REPO = 'alwaysPromptForNewIssueRepo'; +export const ISSUE_AVATAR_DISPLAY = 'issueAvatarDisplay'; +export const EXPERIMENTAL_CHAT = 'experimental.chat'; +export const EXPERIMENTAL_USE_QUICK_CHAT = 'experimental.useQuickChat'; +export const EXPERIMENTAL_NOTIFICATIONS_PAGE_SIZE = 'experimental.notificationsViewPageSize'; +export const EXPERIMENTAL_NOTIFICATIONS_SCORE = 'experimental.notificationsScore'; +export const WEBVIEW_REFRESH_INTERVAL = 'webviewRefreshInterval'; +export const DEV_MODE = 'devMode'; // git export const GIT = 'git'; +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'; +export const URI = 'uri'; + +// Editor +export const EDITOR = 'editor'; +export const WORD_WRAP = 'wordWrap'; + +// Comments +export const COMMENTS = 'comments'; +export const OPEN_VIEW = 'openView'; + +// Workbench +export const WORKBENCH = 'workbench'; +export const COLOR_THEME = 'colorTheme'; +export const LIST_HORIZONTAL_SCROLLING = 'list.horizontalScrolling'; + +// Chat +export const CHAT_SETTINGS_NAMESPACE = 'chat'; +export const DISABLE_AI_FEATURES = 'disableAIFeatures'; + +// Coding Agent + +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'; diff --git a/src/common/settingsUtils.ts b/src/common/settingsUtils.ts new file mode 100644 index 0000000000..3522ce3e98 --- /dev/null +++ b/src/common/settingsUtils.ts @@ -0,0 +1,178 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; +import * as vscode from 'vscode'; +import { commands } from './executeCommands'; +import { CHAT_SETTINGS_NAMESPACE, DISABLE_AI_FEATURES, PR_SETTINGS_NAMESPACE, QUERIES, USE_REVIEW_MODE } from './settingKeys'; + +export function getReviewMode(): { merged: boolean, closed: boolean } { + const desktopDefaults = { merged: false, closed: false }; + const config = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE) + .get<{ merged: boolean, closed: boolean } | 'auto'>(USE_REVIEW_MODE, desktopDefaults); + if (config !== 'auto') { + return config; + } + if (vscode.env.appHost === 'vscode.dev' || vscode.env.appHost === 'github.dev') { + return { merged: true, closed: true }; + } + return desktopDefaults; +} + +export function initBasedOnSettingChange(namespace: string, key: string, isEnabled: () => boolean, initializer: () => void, disposables: vscode.Disposable[]): void { + const eventDisposable = vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration(`${namespace}.${key}`)) { + if (isEnabled()) { + initializer(); + eventDisposable.dispose(); + } + } + }); + disposables.push(eventDisposable); +} + +interface QueryInspect { + key: string; + defaultValue?: { label: string; query: string }[]; + globalValue?: { label: string; query: string }[]; + workspaceValue?: { label: string; query: string }[]; + workspaceFolderValue?: { label: string; query: string }[]; + defaultLanguageValue?: { label: string; query: string }[]; + globalLanguageValue?: { label: string; query: string }[]; + workspaceLanguageValue?: { label: string; query: string }[]; + workspaceFolderLanguageValue?: { label: string; query: string }[]; + languageIds?: string[] +} + +export function editQuery(namespace: string, queryName: string) { + const config = vscode.workspace.getConfiguration(namespace); + const inspect = config.inspect<{ label: string; query: string }[]>(QUERIES); + const queryValue = config.get<{ label: string; query: string }[]>(QUERIES)?.find((query) => query.label === queryName)?.query; + + const inputBox = vscode.window.createQuickPick(); + inputBox.title = vscode.l10n.t('Edit Query "{0}"', queryName ?? ''); + inputBox.value = queryValue ?? ''; + const items: vscode.QuickPickItem[] = [ + { iconPath: new vscode.ThemeIcon('pencil'), label: vscode.l10n.t('Save edits'), alwaysShow: true }, + { iconPath: new vscode.ThemeIcon('add'), label: vscode.l10n.t('Add new query'), alwaysShow: true }, + { iconPath: new vscode.ThemeIcon('settings'), label: vscode.l10n.t('Edit in settings.json'), alwaysShow: true } + ]; + const aiDisabled = vscode.workspace.getConfiguration(CHAT_SETTINGS_NAMESPACE).get(DISABLE_AI_FEATURES, false); + const editWithAIItem = { iconPath: new vscode.ThemeIcon('sparkle'), label: vscode.l10n.t('Edit with AI'), alwaysShow: true }; + if (!aiDisabled) { + items.push(editWithAIItem); + } + inputBox.items = items; + inputBox.activeItems = []; + inputBox.selectedItems = []; + inputBox.onDidAccept(async () => { + inputBox.busy = true; + if (inputBox.selectedItems[0] === inputBox.items[0]) { + const newQuery = inputBox.value; + if (newQuery !== queryValue) { + let newValue: { label: string; query: string }[]; + let target: vscode.ConfigurationTarget; + if (inspect?.workspaceFolderValue) { + target = vscode.ConfigurationTarget.WorkspaceFolder; + newValue = inspect.workspaceFolderValue; + } else if (inspect?.workspaceValue) { + target = vscode.ConfigurationTarget.Workspace; + newValue = inspect.workspaceValue; + } else { + target = vscode.ConfigurationTarget.Global; + newValue = config.get<{ label: string; query: string }[]>(QUERIES) ?? []; + } + newValue.find((query) => query.label === queryName)!.query = newQuery; + await config.update(QUERIES, newValue, target); + } + inputBox.dispose(); + } else if (inputBox.selectedItems[0] === inputBox.items[1]) { + addNewQuery(config, inspect, inputBox.value); + inputBox.dispose(); + } else if (inputBox.selectedItems[0] === inputBox.items[2]) { + openSettingsAtQuery(config, inspect, queryName); + inputBox.dispose(); + } else if (inputBox.selectedItems[0] === editWithAIItem) { + inputBox.ignoreFocusOut = true; + await openCopilotForQuery(inputBox.value); + inputBox.busy = false; + } + }); + inputBox.onDidHide(() => inputBox.dispose()); + inputBox.show(); +} + +function addNewQuery(config: vscode.WorkspaceConfiguration, inspect: QueryInspect | undefined, startingValue: string) { + const inputBox = vscode.window.createInputBox(); + inputBox.title = vscode.l10n.t('Enter the title of the new query'); + inputBox.placeholder = vscode.l10n.t('Title'); + inputBox.step = 1; + inputBox.totalSteps = 2; + inputBox.show(); + let title: string | undefined; + inputBox.onDidAccept(async () => { + inputBox.validationMessage = ''; + if (inputBox.step === 1) { + if (!inputBox.value) { + inputBox.validationMessage = vscode.l10n.t('Title is required'); + return; + } + + title = inputBox.value; + inputBox.value = startingValue; + inputBox.title = vscode.l10n.t('Enter the GitHub search query'); + inputBox.step++; + } else { + if (!inputBox.value) { + inputBox.validationMessage = vscode.l10n.t('Query is required'); + return; + } + inputBox.busy = true; + if (inputBox.value && title) { + if (inspect?.workspaceValue) { + inspect.workspaceValue.push({ label: title, query: inputBox.value }); + await config.update(QUERIES, inspect.workspaceValue, vscode.ConfigurationTarget.Workspace); + } else { + const value = config.get<{ label: string; query: string }[]>(QUERIES); + value?.push({ label: title, query: inputBox.value }); + await config.update(QUERIES, value, vscode.ConfigurationTarget.Global); + } + } + inputBox.dispose(); + } + }); + inputBox.onDidHide(() => inputBox.dispose()); +} + +async function openSettingsAtQuery(config: vscode.WorkspaceConfiguration, inspect: QueryInspect | undefined, queryName: string) { + let command: string; + if (inspect?.workspaceValue) { + command = 'workbench.action.openWorkspaceSettingsFile'; + } else { + const value = config.get<{ label: string; query: string }[]>(QUERIES); + if (inspect?.defaultValue && JSON.stringify(inspect?.defaultValue) === JSON.stringify(value)) { + await config.update(QUERIES, inspect.defaultValue, vscode.ConfigurationTarget.Global); + } + command = 'workbench.action.openSettingsJson'; + } + await vscode.commands.executeCommand(command); + const editor = vscode.window.activeTextEditor; + if (editor) { + const text = editor.document.getText(); + const search = text.search(queryName); + if (search >= 0) { + const position = editor.document.positionAt(search); + editor.revealRange(new vscode.Range(position, position)); + editor.selection = new vscode.Selection(position, position); + } + } +} + +async function openCopilotForQuery(currentQuery: string) { + const chatMessage = vscode.l10n.t('I want to edit this GitHub search query: \n```\n{0}\n```\nOutput only one, minimally modified query in a codeblock.\nModify it so that it ', currentQuery); + + // Open chat with the query pre-populated + await vscode.commands.executeCommand(commands.NEW_CHAT, { inputValue: chatMessage, isPartialQuery: true, agentMode: false }); +} \ No newline at end of file diff --git a/src/common/temporaryState.ts b/src/common/temporaryState.ts index c740f7d1c2..c1964d3029 100644 --- a/src/common/temporaryState.ts +++ b/src/common/temporaryState.ts @@ -3,34 +3,58 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { disposeAll } from './lifecycle'; import Logger from './logger'; +import { isDescendant } from './utils'; let tempState: TemporaryState | undefined; export class TemporaryState extends vscode.Disposable { + static readonly ID = 'TemporaryState'; private readonly SUBPATH = 'temp'; private readonly disposables: vscode.Disposable[] = []; + private readonly persistInSessionDisposables: vscode.Disposable[] = []; - constructor(private _storageUri: vscode.Uri) { - super(() => this.disposables.forEach(disposable => disposable.dispose())); + constructor(private readonly _storageUri: vscode.Uri) { + super(() => disposeAll(this.disposables)); } private get path(): vscode.Uri { return vscode.Uri.joinPath(this._storageUri, this.SUBPATH); } - private addDisposable(disposable: vscode.Disposable) { - if (this.disposables.length > 30) { - const oldDisposable = this.disposables.shift(); - oldDisposable?.dispose(); + override dispose() { + disposeAll(this.disposables); + disposeAll(this.persistInSessionDisposables); + } + + private addDisposable(disposable: vscode.Disposable, persistInSession: boolean) { + if (persistInSession) { + this.persistInSessionDisposables.push(disposable); + } else { + if (this.disposables.length > 30) { + const oldDisposable = this.disposables.shift(); + oldDisposable?.dispose(); + } + this.disposables.push(disposable); } - this.disposables.push(disposable); } - private async writeState(subpath: string, filename: string, contents: Uint8Array): Promise { + private async writeState(subpath: string, filename: string, contents: Uint8Array, persistInSession: boolean, repositoryUri: vscode.Uri): Promise { let filePath: vscode.Uri = this.path; - const workspace = (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) - ? vscode.workspace.workspaceFolders[0].name : undefined; + let workspace: string | undefined; + + if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) { + const matchingFolder = vscode.workspace.workspaceFolders.find(folder => + isDescendant(folder.uri.fsPath, repositoryUri.fsPath) || isDescendant(repositoryUri.fsPath, folder.uri.fsPath) + ); + workspace = matchingFolder?.name; + } + + // Fall back to the first workspace folder if no match found + if (!workspace && vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) { + workspace = vscode.workspace.workspaceFolders[0].name; + } if (workspace) { filePath = vscode.Uri.joinPath(filePath, workspace); @@ -45,36 +69,72 @@ export class TemporaryState extends vscode.Disposable { const dispose = { dispose: () => { - return vscode.workspace.fs.delete(file, { recursive: true }); + try { + return vscode.workspace.fs.delete(file, { recursive: true }); + } catch (e) { + // No matter the error, we do not want to throw in dispose. + } } }; - this.addDisposable(dispose); + this.addDisposable(dispose, persistInSession); return file; } + private async readState(subpath: string, filename: string, repositoryUri: vscode.Uri): Promise { + let filePath: vscode.Uri = this.path; + let workspace: string | undefined; + + if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) { + const matchingFolder = vscode.workspace.workspaceFolders.find(folder => + isDescendant(folder.uri.fsPath, repositoryUri.fsPath) || isDescendant(repositoryUri.fsPath, folder.uri.fsPath) + ); + workspace = matchingFolder?.name; + } + + // Fall back to the first workspace folder if no match found + if (!workspace && vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) { + workspace = vscode.workspace.workspaceFolders[0].name; + } + + if (workspace) { + filePath = vscode.Uri.joinPath(filePath, workspace); + } + filePath = vscode.Uri.joinPath(filePath, subpath); + const file = vscode.Uri.joinPath(filePath, filename); + return vscode.workspace.fs.readFile(file); + } + static async init(context: vscode.ExtensionContext): Promise { if (context.globalStorageUri && !tempState) { tempState = new TemporaryState(context.globalStorageUri); try { await vscode.workspace.fs.delete(tempState.path, { recursive: true }); } catch (e) { - Logger.appendLine(`TemporaryState> Error in initialization: ${e.message}`); + Logger.appendLine(`Error in initialization: ${e.message}`, TemporaryState.ID); } try { await vscode.workspace.fs.createDirectory(tempState.path); } catch (e) { - Logger.appendLine(`TemporaryState> Error in initialization: ${e.message}`); + Logger.appendLine(`Error in initialization: ${e.message}`, TemporaryState.ID); } context.subscriptions.push(tempState); return tempState; } } - static async write(subpath: string, filename: string, contents: Uint8Array): Promise { + static async write(subpath: string, filename: string, contents: Uint8Array, persistInSession: boolean = false, repositoryUri: vscode.Uri): Promise { + if (!tempState) { + return; + } + + return tempState.writeState(subpath, filename, contents, persistInSession, repositoryUri); + } + + static async read(subpath: string, filename: string, repositoryUri: vscode.Uri): Promise { if (!tempState) { return; } - return tempState.writeState(subpath, filename, contents); + return tempState.readState(subpath, filename, repositoryUri); } } \ No newline at end of file diff --git a/src/common/timelineEvent.ts b/src/common/timelineEvent.ts index 24c992d26a..e56efbe293 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 } from '../github/interface'; import { IComment } from './comment'; +import { IAccount, IActor, Reaction } from '../github/interface'; export enum EventType { Committed, @@ -16,8 +16,17 @@ export enum EventType { Labeled, Milestoned, Assigned, + Unassigned, HeadRefDeleted, Merged, + CrossReferenced, + Closed, + Reopened, + BaseRefChanged, + CopilotStarted, + CopilotFinished, + CopilotFinishedError, + CopilotReviewStarted, Other, } @@ -33,11 +42,12 @@ export interface CommentEvent { htmlUrl: string; body: string; bodyHTML?: string; - user: IAccount; + user?: IAccount; event: EventType.Commented; canEdit?: boolean; canDelete?: boolean; createdAt: string; + reactions?: Reaction[]; } export interface ReviewResolveInfo { @@ -47,6 +57,8 @@ export interface ReviewResolveInfo { isResolved: boolean; } +export type ReviewStateValue = 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED' | 'PENDING' | 'REQUESTED'; + export interface ReviewEvent { id: number; reviewThread?: ReviewResolveInfo @@ -58,7 +70,8 @@ export interface ReviewEvent { htmlUrl: string; user: IAccount; authorAssociation: string; - state: 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED' | 'PENDING' | 'REQUESTED'; + state?: ReviewStateValue; + reactions?: Reaction[]; } export interface CommitEvent { @@ -69,7 +82,8 @@ export interface CommitEvent { htmlUrl: string; message: string; bodyHTML?: string; - authoredDate: Date; + committedDate: Date; + status?: 'EXPECTED' | 'ERROR' | 'FAILURE' | 'PENDING' | 'SUCCESS'; } export interface NewCommitsSinceReviewEvent { @@ -80,7 +94,7 @@ export interface NewCommitsSinceReviewEvent { export interface MergedEvent { id: string; graphNodeId: string; - user: IAccount; + user: IActor; createdAt: string; mergeRef: string; sha: string; @@ -92,16 +106,107 @@ export interface MergedEvent { export interface AssignEvent { id: number; event: EventType.Assigned; - user: IAccount; - actor: IAccount; + assignees: IAccount[]; + actor: IActor; + createdAt: string; +} + +export interface UnassignEvent { + id: number; + event: EventType.Unassigned; + unassignees: IAccount[]; + actor: IActor; + createdAt: string; } export interface HeadRefDeleteEvent { id: string; event: EventType.HeadRefDeleted; - actor: IAccount; + actor: IActor; createdAt: string; headRef: string; } -export type TimelineEvent = CommitEvent | ReviewEvent | CommentEvent | NewCommitsSinceReviewEvent | MergedEvent | AssignEvent | HeadRefDeleteEvent; +export interface CrossReferencedEvent { + id: string; + event: EventType.CrossReferenced + actor: IActor; + createdAt: string; + source: { + number: number; + url: string; + extensionUrl: string; + title: string; + isIssue: boolean; + owner: string; + repo: string; + }; + willCloseTarget: boolean; +} + +export interface ClosedEvent { + id: string + event: EventType.Closed; + actor: IActor; + createdAt: string; +} + +export interface ReopenedEvent { + id: string; + event: EventType.Reopened; + actor: IActor; + createdAt: string; +} + +export interface BaseRefChangedEvent { + id: string; + event: EventType.BaseRefChanged; + actor: IActor; + createdAt: string; + currentRefName: string; + previousRefName: string; +} + +export interface SessionPullInfo { + id: number; + host: string; + owner: string; + repo: string; + pullNumber: number; +} + +export interface SessionLinkInfo extends SessionPullInfo { + sessionIndex: number; + openToTheSide?: boolean; +} + +export interface CopilotStartedEvent { + id: string; + event: EventType.CopilotStarted; + createdAt: string; + onBehalfOf: IAccount; + sessionLink: SessionLinkInfo; +} + +export interface CopilotFinishedEvent { + id: string; + event: EventType.CopilotFinished; + createdAt: string; + onBehalfOf: IAccount; +} + +export interface CopilotFinishedErrorEvent { + id: string; + event: EventType.CopilotFinishedError; + createdAt: string; + onBehalfOf: IAccount; + sessionLink: SessionLinkInfo; +} + +export interface CopilotReviewStartedEvent { + id: string; + event: EventType.CopilotReviewStarted; + createdAt: string; +} + +export type TimelineEvent = CommitEvent | ReviewEvent | CommentEvent | NewCommitsSinceReviewEvent | MergedEvent | AssignEvent | UnassignEvent | HeadRefDeleteEvent | CrossReferencedEvent | ClosedEvent | ReopenedEvent | BaseRefChangedEvent | CopilotStartedEvent | CopilotFinishedEvent | CopilotFinishedErrorEvent | CopilotReviewStartedEvent; diff --git a/src/common/uri.ts b/src/common/uri.ts index 0286887d5a..1982ef0fea 100644 --- a/src/common/uri.ts +++ b/src/common/uri.ts @@ -5,12 +5,19 @@ 'use strict'; +import { Buffer } from 'buffer'; import * as pathUtils from 'path'; +import fetch from 'cross-fetch'; import * as vscode from 'vscode'; +import { RemoteInfo } from '../../common/types'; import { Repository } from '../api/api'; -import { PullRequestModel } from '../github/pullRequestModel'; +import { EXTENSION_ID } from '../constants'; 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; @@ -37,16 +44,23 @@ export interface PRUriParams { } export function fromPRUri(uri: vscode.Uri): PRUriParams | undefined { + if (uri.query === '') { + return undefined; + } try { return JSON.parse(uri.query) as PRUriParams; } catch (e) { } } export interface PRNodeUriParams { - prIdentifier: string + prIdentifier: string; + showCopilot?: boolean; } export function fromPRNodeUri(uri: vscode.Uri): PRNodeUriParams | undefined { + if (uri.query === '') { + return undefined; + } try { return JSON.parse(uri.query) as PRNodeUriParams; } catch (e) { } @@ -55,20 +69,54 @@ export function fromPRNodeUri(uri: vscode.Uri): PRNodeUriParams | undefined { export interface GitHubUriParams { fileName: string; branch: string; + owner?: string; isEmpty?: boolean; } export function fromGitHubURI(uri: vscode.Uri): GitHubUriParams | undefined { + if (uri.query === '') { + return undefined; + } try { return JSON.parse(uri.query) as GitHubUriParams; } catch (e) { } } +export function toGitHubUri(fileUri: vscode.Uri, scheme: Schemes.GithubPr | Schemes.GitPr, params: GitHubUriParams): vscode.Uri { + return fileUri.with({ + scheme, + query: JSON.stringify(params) + }); +} + export interface GitUriOptions { replaceFileExtension?: boolean; submoduleOf?: string; base: boolean; } +export interface GitHubCommitUriParams { + commit: string; + owner: string; + repo: string; +} + +export function fromGitHubCommitUri(uri: vscode.Uri): GitHubCommitUriParams | undefined { + if (uri.scheme !== Schemes.GitHubCommit || uri.query === '') { + return undefined; + } + try { + return JSON.parse(uri.query) as GitHubCommitUriParams; + } catch (e) { } +} + +export function toGitHubCommitUri(fileName: string, params: GitHubCommitUriParams): vscode.Uri { + return vscode.Uri.from({ + scheme: Schemes.GitHubCommit, + path: `/${fileName}`, + query: JSON.stringify(params) + }); +} + const ImageMimetypes = ['image/png', 'image/gif', 'image/jpeg', 'image/webp', 'image/tiff', 'image/bmp']; // Known media types that VS Code can handle: https://github.com/microsoft/vscode/blob/a64e8e5673a44e5b9c2d493666bde684bd5a135c/src/vs/base/common/mime.ts#L33-L84 export const KnownMediaExtensions = [ @@ -128,15 +176,17 @@ export const EMPTY_IMAGE_URI = vscode.Uri.parse( `data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==`, ); -export async function asImageDataURI(uri: vscode.Uri, repository: Repository): Promise { +export async function asTempStorageURI(uri: vscode.Uri, repository: Repository): Promise { try { - const { commit, baseCommit, headCommit, isBase, path } = JSON.parse(uri.query); + const { commit, baseCommit, headCommit, isBase, path }: { commit: string, baseCommit: string, headCommit: string, isBase: string, path: string } = JSON.parse(uri.query); const ext = pathUtils.extname(path); if (!KnownMediaExtensions.includes(ext)) { return; } const ref = uri.scheme === Schemes.Review ? commit : isBase ? baseCommit : headCommit; - const { object } = await repository.getObjectDetails(ref, uri.fsPath); + + const absolutePath = pathUtils.join(repository.rootUri.fsPath, path).replace(/\\/g, '/'); + const { object } = await repository.getObjectDetails(ref, absolutePath); const { mimetype } = await repository.detectObjectType(object); if (mimetype === 'text/plain') { @@ -144,14 +194,208 @@ export async function asImageDataURI(uri: vscode.Uri, repository: Repository): P } if (ImageMimetypes.indexOf(mimetype) > -1) { - const contents = await repository.buffer(ref, uri.fsPath); - return TemporaryState.write(pathUtils.dirname(path), pathUtils.basename(path), contents); + const contents = await repository.buffer(ref, absolutePath); + return TemporaryState.write(pathUtils.dirname(path), pathUtils.basename(path), contents, false, repository.rootUri); } } catch (err) { return; } } +export namespace DataUri { + const iconsFolder = 'userIcons'; + + function iconFilename(user: IAccount | ITeam): string { + // 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 { + return vscode.Uri.joinPath(context.globalStorageUri, iconsFolder); + } + + function fileCacheUri(context: vscode.ExtensionContext, user: IAccount | ITeam): vscode.Uri { + return vscode.Uri.joinPath(cacheLocation(context), iconFilename(user)); + } + + function cacheLogUri(context: vscode.ExtensionContext): vscode.Uri { + return vscode.Uri.joinPath(cacheLocation(context), 'cache.log'); + } + + async function writeAvatarToCache(context: vscode.ExtensionContext, user: IAccount | ITeam, contents: Uint8Array): Promise { + await vscode.workspace.fs.createDirectory(cacheLocation(context)); + const file = fileCacheUri(context, user); + await vscode.workspace.fs.writeFile(file, contents); + return file; + } + + async function readAvatarFromCache(context: vscode.ExtensionContext, user: IAccount | ITeam): Promise { + try { + const file = fileCacheUri(context, user); + return vscode.workspace.fs.readFile(file); + } catch (e) { + return; + } + } + + export function asImageDataURI(contents: Buffer): vscode.Uri { + return vscode.Uri.parse( + `data:image/svg+xml;size:${contents.byteLength};base64,${contents.toString('base64')}` + ); + } + + export function copilotErrorAsImageDataURI(foreground: string, color: string): vscode.Uri { + const svgContent = ` + + +`; + const contents = Buffer.from(svgContent); + return asImageDataURI(contents); + } + + export function copilotInProgressAsImageDataURI(foreground: string, color: string): vscode.Uri { + const svgContent = ` + + +`; + const contents = Buffer.from(svgContent); + return asImageDataURI(contents); + } + + export function copilotSuccessAsImageDataURI(foreground: string, color: string): vscode.Uri { + const svgContent = ` + + +`; + const contents = Buffer.from(svgContent); + return asImageDataURI(contents); + } + + function genericUserIconAsImageDataURI(width: number, height: number): vscode.Uri { + // The account icon + const foreground = vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark ? '#FFFFFF' : '#000000'; + const svgContent = ` + +`; + const contents = Buffer.from(svgContent); + return asImageDataURI(contents); + } + + /** + * Checks if an avatar URL is from GitHub.com (as opposed to GitHub Enterprise). + * GitHub.com avatar URLs contain 'githubusercontent.com', while enterprise avatar URLs do not. + * @param avatarUrl The avatar URL to check + * @returns true if the avatar is from GitHub.com, false otherwise + */ + export function isGitHubDotComAvatar(avatarUrl: string | undefined): boolean { + return avatarUrl?.includes('githubusercontent.com') ?? false; + } + + export async function avatarCirclesAsImageDataUris(context: vscode.ExtensionContext, users: (IAccount | ITeam)[], height: number, width: number, localOnly?: boolean): Promise<(vscode.Uri | undefined)[]> { + let cacheLogOrder: string[]; + const cacheLog = cacheLogUri(context); + try { + const log = await vscode.workspace.fs.readFile(cacheLog); + cacheLogOrder = JSON.parse(log.toString()); + } catch (e) { + cacheLogOrder = []; + } + const startingCacheSize = cacheLogOrder.length; + + const results = await Promise.all(users.map(async (user) => { + const imageSourceUrl = user.avatarUrl; + if (imageSourceUrl === undefined) { + return undefined; + } + let innerImageContents: Buffer | undefined; + let cacheMiss: boolean = false; + try { + const fileContents = await readAvatarFromCache(context, user); + if (!fileContents) { + throw new Error('Temporary state not initialized'); + } + innerImageContents = Buffer.from(fileContents); + } catch (e) { + if (localOnly) { + return; + } + cacheMiss = true; + const doFetch = async () => { + const response = await fetch(imageSourceUrl.toString()); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + if (response.headers.get('content-type')?.startsWith('image/')) { + const buffer = await response.arrayBuffer(); + await writeAvatarToCache(context, user, new Uint8Array(buffer)); + innerImageContents = Buffer.from(buffer); + } + }; + try { + await doFetch(); + } catch (e) { + // We retry once. + try { + await doFetch(); + } catch (retryError) { + // Log the error and return a generic user icon instead of crashing + const userIdentifier = isITeam(user) ? `${user.org}/${user.slug}` : user.login || 'unknown'; + Logger.error(`Failed to fetch avatar after retry for user ${userIdentifier}: ${retryError}`, 'avatarCirclesAsImageDataUris'); + return genericUserIconAsImageDataURI(width, height); + } + } + } + if (!innerImageContents) { + return undefined; + } + if (cacheMiss) { + const icon = iconFilename(user); + cacheLogOrder.push(icon); + } + const innerImageEncoded = `data:image/jpeg;size:${innerImageContents.byteLength};base64,${innerImageContents.toString('base64')}`; + const contentsString = ` + + `; + const contents = Buffer.from(contentsString); + const finalDataUri = asImageDataURI(contents); + return finalDataUri; + })); + + const maxCacheSize = Math.max(users.length, 200); + if (cacheLogOrder.length > startingCacheSize && startingCacheSize > 0 && cacheLogOrder.length > maxCacheSize) { + // The cache is getting big, we should clean it up. + const toDelete = cacheLogOrder.splice(0, 50); + await Promise.all(toDelete.map(async (id) => { + try { + await vscode.workspace.fs.delete(vscode.Uri.joinPath(cacheLocation(context), id)); + } catch (e) { + Logger.error(`Failed to delete avatar from cache: ${e}`, 'avatarCirclesAsImageDataUris'); + } + })); + } + + await vscode.workspace.fs.writeFile(cacheLog, Buffer.from(JSON.stringify(cacheLogOrder))); + + return results; + } +} + +/** + * @param fileName The repo relative path to the file + */ +export function reviewPath(fileName: string, commitSha: string) { + return vscode.Uri.parse(pathUtils.posix.join(`commit~${commitSha.substr(0, 8)}`, fileName)); +} + export function toReviewUri( uri: vscode.Uri, filePath: string | undefined, @@ -205,8 +449,11 @@ export function toResourceUri(uri: vscode.Uri, prNumber: number, fileName: strin } export function fromFileChangeNodeUri(uri: vscode.Uri): FileChangeNodeUriParams | undefined { + if (uri.query === '') { + return undefined; + } try { - return uri.query ? JSON.parse(uri.query) as FileChangeNodeUriParams : undefined; + return JSON.parse(uri.query) as FileChangeNodeUriParams; } catch (e) { } } @@ -252,13 +499,30 @@ export function createPRNodeIdentifier(pullRequest: PullRequestModel | { remote: return identifier; } +export function parsePRNodeIdentifier(identifier: string): { remote: string, prNumber: number } | undefined { + const lastColon = identifier.lastIndexOf(':'); + if (lastColon === -1) { + return undefined; + } + const remote = identifier.substring(0, lastColon); + const prNumberStr = identifier.substring(lastColon + 1); + const prNumber = Number(prNumberStr); + if (!remote || isNaN(prNumber) || prNumber <= 0) { + return undefined; + } + return { remote, prNumber }; +} + 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}`); @@ -268,15 +532,274 @@ export function createPRNodeUri( }); } +export interface CommitsNodeUriParams { + owner: string; + repo: string; + prNumber: number; +} + +export function createCommitsNodeUri(owner: string, repo: string, prNumber: number): vscode.Uri { + const params: CommitsNodeUriParams = { + owner, + repo, + prNumber + }; + + return vscode.Uri.parse(`${Schemes.CommitsNode}:${owner}/${repo}/${prNumber}`).with({ + scheme: Schemes.CommitsNode, + query: JSON.stringify(params) + }); +} + +export function fromCommitsNodeUri(uri: vscode.Uri): CommitsNodeUriParams | undefined { + if (uri.scheme !== Schemes.CommitsNode) { + return undefined; + } + try { + return JSON.parse(uri.query) as CommitsNodeUriParams; + } catch (e) { + return undefined; + } +} + +export interface NotificationUriParams { + key: string; +} + +export function toNotificationUri(params: NotificationUriParams) { + return vscode.Uri.from({ scheme: Schemes.Notification, path: params.key }); +} + +export function fromNotificationUri(uri: vscode.Uri): NotificationUriParams | undefined { + if (uri.scheme !== Schemes.Notification) { + return; + } + try { + return { + key: uri.path, + }; + } catch (e) { } +} + + +interface IssueFileQuery { + origin: string; +} + +export interface NewIssueUriParams { + originUri: vscode.Uri; + repoUriParams?: RepoUriParams; +} + +interface RepoUriQuery { + folderManagerRootUri: string; +} + +export function toNewIssueUri(params: NewIssueUriParams) { + const query: IssueFileQuery = { + origin: params.originUri.toString() + }; + if (params.repoUriParams) { + query.origin = toRepoUri(params.repoUriParams).toString(); + } + return vscode.Uri.from({ scheme: Schemes.NewIssue, path: '/NewIssue.md', query: JSON.stringify(query) }); +} + +export function fromNewIssueUri(uri: vscode.Uri): NewIssueUriParams | undefined { + if (uri.scheme !== Schemes.NewIssue) { + return; + } + try { + const query = JSON.parse(uri.query); + const originUri = vscode.Uri.parse(query.origin); + const repoUri = fromRepoUri(originUri); + return { + originUri, + repoUriParams: repoUri + }; + } catch (e) { } +} + +export interface RepoUriParams { + owner: string; + repo: string; + repoRootUri: vscode.Uri; +} + +function toRepoUri(params: RepoUriParams) { + const repoQuery: RepoUriQuery = { + folderManagerRootUri: params.repoRootUri.toString() + }; + return vscode.Uri.from({ scheme: Schemes.Repo, path: `${params.owner}/${params.repo}`, query: JSON.stringify(repoQuery) }); +} + +export function fromRepoUri(uri: vscode.Uri): RepoUriParams | undefined { + if (uri.scheme !== Schemes.Repo) { + return; + } + const [owner, repo] = uri.path.split('/'); + try { + const query = JSON.parse(uri.query); + const repoRootUri = vscode.Uri.parse(query.folderManagerRootUri); + return { + owner, + repo, + repoRootUri + }; + } catch (e) { } +} + +const ownerRegex = /^(?!-)(?!.*--)[a-zA-Z0-9-]+(? { + const query = JSON.stringify(params); + return vscode.env.asExternalUri(vscode.Uri.from({ scheme: vscode.env.uriScheme, authority: EXTENSION_ID, path: UriHandlerPaths.OpenIssueWebview, query })); +} + +export function fromOpenIssueWebviewUri(uri: vscode.Uri): OpenIssueWebviewUriParams | undefined { + if (compareIgnoreCase(uri.authority, EXTENSION_ID) !== 0) { + return; + } + if (uri.path !== UriHandlerPaths.OpenIssueWebview) { + return; + } + try { + const query = JSON.parse(uri.query.split('&')[0]); + if (!validateOpenWebviewParams(query.owner, query.repo, query.issueNumber)) { + return; + } + return query; + } catch (e) { } +} + +export interface OpenPullRequestWebviewUriParams { + owner: string; + repo: string; + pullRequestNumber: number; +} + +export async function toOpenPullRequestWebviewUri(params: OpenPullRequestWebviewUriParams): Promise { + const query = JSON.stringify(params); + return vscode.env.asExternalUri(vscode.Uri.from({ scheme: vscode.env.uriScheme, authority: EXTENSION_ID, path: UriHandlerPaths.OpenPullRequestWebview, query })); +} + +export async function toOpenPullRequestChangesUri(params: OpenPullRequestWebviewUriParams): Promise { + const query = JSON.stringify(params); + return vscode.env.asExternalUri(vscode.Uri.from({ scheme: vscode.env.uriScheme, authority: EXTENSION_ID, path: UriHandlerPaths.OpenPullRequestChanges, query })); +} + +export function fromOpenOrCheckoutPullRequestWebviewUri(uri: vscode.Uri): OpenPullRequestWebviewUriParams | undefined { + if (compareIgnoreCase(uri.authority, EXTENSION_ID) !== 0) { + return; + } + if (uri.path !== UriHandlerPaths.OpenPullRequestWebview && uri.path !== UriHandlerPaths.CheckoutPullRequest && uri.path !== UriHandlerPaths.OpenPullRequestChanges) { + 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; + } + return query; + } catch (e) { } +} + +export function toQueryUri(params: { remote: RemoteInfo | undefined, isCopilot?: boolean }) { + const uri = vscode.Uri.from({ scheme: Schemes.PRQuery, path: params.isCopilot ? 'copilot' : undefined, query: params.remote ? JSON.stringify({ remote: params.remote }) : undefined }); + return uri; +} + +export function fromQueryUri(uri: vscode.Uri): { remote: RemoteInfo | undefined, isCopilot?: boolean } | undefined { + if (uri.scheme !== Schemes.PRQuery) { + return; + } + try { + const query = uri.query ? JSON.parse(uri.query) : undefined; + return { + remote: query.remote, + isCopilot: uri.path === 'copilot' + }; + } catch (e) { } +} + export enum Schemes { File = 'file', - Review = 'review', - Pr = 'pr', + Review = 'review', // File content for a checked out PR + Pr = 'pr', // File content from GitHub for non-checkout PR PRNode = 'prnode', - FileChange = 'filechange', - GithubPr = 'githubpr', + FileChange = 'filechange', // Tree items, for decorations + GithubPr = 'githubpr', // File content from GitHub in create flow + GitPr = 'gitpr', // File content from git in create flow VscodeVfs = 'vscode-vfs', // Remote Repository - Comment = 'comment' // Comments from the VS Code comment widget + Comment = 'comment', // Comments from the VS Code comment widget + MergeOutput = 'merge-output', // Merge output + Notification = 'notification', // Notification tree items in the notification view + NewIssue = 'newissue', // New issue file + Repo = 'repo', // New issue file for passing data + Git = 'git', // File content from the git extension + PRQuery = 'prquery', // PR query tree item + GitHubCommit = 'githubcommit', // file content from GitHub for a commit + CommitsNode = 'commitsnode' // Commits tree node, for decorations } export function resolvePath(from: vscode.Uri, to: string) { @@ -286,11 +809,3 @@ export function resolvePath(from: vscode.Uri, to: string) { return pathUtils.posix.resolve(from.path, to); } } - -class UriEventHandler extends vscode.EventEmitter implements vscode.UriHandler { - public handleUri(uri: vscode.Uri) { - this.fire(uri); - } -} - -export const handler = new UriEventHandler(); diff --git a/src/common/user.ts b/src/common/user.ts new file mode 100644 index 0000000000..0056b83f12 --- /dev/null +++ b/src/common/user.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// https://docs.github.com/en/enterprise-cloud@latest/admin/identity-and-access-management/iam-configuration-reference/username-considerations-for-external-authentication#about-username-normalization +export const ALLOWED_USERS = `[a-zA-Z0-9-]+`; + +// https://jsdoc.app/index.html +export const JSDOC_NON_USERS = ['abstract', 'virtual', 'access', 'alias', 'async', 'augments', 'extends', 'author', 'borrows', 'callback', 'class', 'constructor', 'classdesc', 'constant', 'const', 'constructs', 'copyright', 'default', 'defaultvalue', 'deprecated', 'description', 'desc', 'effect', 'enum', 'event', 'example', 'exports', 'external', 'host', 'file', 'fileoverview', 'overview', 'fires', 'emits', 'function', 'func', 'method', 'generator', 'global', 'hideconstructor', 'ignore', 'implements', 'inheritdoc', 'inner', 'instance', 'interface', 'kind', 'lends', 'license', 'listens', 'member', 'var', 'memberof', 'mixes', 'mixin', 'module', 'name', 'namespace', 'override', 'package', 'param', 'arg', 'argument', 'private', 'property', 'prop', 'protected', 'public', 'readonly', 'requires', 'returns', 'return', 'see', 'since', 'static', 'summary', 'this', 'throws', 'exception', 'todo', 'tutorial', 'type', 'typedef', 'variation', 'version', 'yields', 'yield', 'link']; + +// https://github.com/php-fig/fig-standards/blob/master/proposed/phpdoc-tags.md +export const PHPDOC_NON_USERS = ['api', 'author', 'copyright', 'deprecated', 'generated', 'internal', 'link', 'method', 'package', 'param', 'property', 'return', 'see', 'since', 'throws', 'todo', 'uses', 'var', 'version']; + +// https://www.doxygen.nl/manual/commands.html +export const DOXYGEN_NON_USERS = ['a', 'addindex', 'addtogroup', 'anchor', 'arg', 'attention', 'author', 'authors', 'b', 'brief', 'bug', 'c', 'callergraph', 'callgraph', 'category', 'cite', 'class', 'code', 'collaborationgraph', 'concept', 'cond', 'copybrief', 'copydetails', 'copydoc', 'copyright', 'date', 'def', 'defgroup', 'deprecated', 'details', 'diafile', 'dir', 'directorygraph', 'docbookinclude', 'docbookonly', 'dontinclude', 'dot', 'dotfile', 'doxyconfig', 'e', 'else', 'elseif', 'em', 'emoji', 'endcode', 'endcond', 'enddocbookonly', 'enddot', 'endhtmlonly', 'endif', 'endinternal', 'endlatexonly', 'endlink', 'endmanonly', 'endmsc', 'endparblock', 'endrtfonly', 'endsecreflist', 'endverbatim', 'enduml', 'endxmlonly', 'enum', 'example', 'exception', 'extends', 'f(', 'f)', 'f$', 'f[', 'f]', 'f{', 'f}', 'file', 'fileinfo', 'fn', 'groupgraph', 'headerfile', 'hidecallergraph', 'hidecallgraph', 'hidecollaborationgraph', 'hidedirectorygraph', 'hidegroupgraph', 'hideincludedbygraph', 'hideincludegraph', 'hideinheritancegraph', 'hideinlinesource', 'hiderefby', 'hiderefs', 'hideinitializer', 'htmlinclude', 'htmlonly', 'idlexcept', 'if', 'ifnot', 'image', 'implements', 'important', 'include', 'includedoc', 'includedbygraph', 'includegraph', 'includelineno', 'ingroup', 'inheritancegraph', 'internal', 'invariant', 'interface', 'latexinclude', 'latexonly', 'li', 'line', 'lineinfo', 'link', 'mainpage', 'maninclude', 'manonly', 'memberof', 'module', 'msc', 'mscfile', 'n', 'name', 'namespace', 'noop', 'nosubgrouping', 'note', 'overload', 'p', 'package', 'page', 'par', 'paragraph', 'param', 'parblock', 'post', 'pre', 'private', 'privatesection', 'property', 'protected', 'protectedsection', 'protocol', 'public', 'publicsection', 'pure', 'qualifier', 'raisewarning', 'ref', 'refitem', 'related', 'relates', 'relatedalso', 'relatesalso', 'remark', 'remarks', 'result', 'return', 'returns', 'retval', 'rtfinclude', 'rtfonly', 'sa', 'secreflist', 'section', 'see', 'short', 'showdate', 'showinitializer', 'showinlinesource', 'showrefby', 'showrefs', 'since', 'skip', 'skipline', 'snippet', 'snippetdoc', 'snippetlineno', 'static', 'startuml', 'struct', 'subpage', 'subparagraph', 'subsection', 'subsubparagraph', 'subsubsection', 'tableofcontents', 'test', 'throw', 'throws', 'todo', 'tparam', 'typedef', 'union', 'until', 'var', 'verbatim', 'verbinclude', 'version', 'vhdlflow', 'warning', 'weakgroup', 'xmlinclude', 'xmlonly', 'xrefitem']; \ No newline at end of file diff --git a/src/common/utils.ts b/src/common/utils.ts index a1e9bb2f84..0f36f1202b 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -8,7 +8,8 @@ import { sep } from 'path'; import dayjs from 'dayjs'; import * as relativeTime from 'dayjs/plugin/relativeTime'; import * as updateLocale from 'dayjs/plugin/updateLocale'; -import type { Disposable, Event, Uri } from 'vscode'; +import type { Disposable, Event, ExtensionContext, Uri } from 'vscode'; +import { combinedDisposable } from './lifecycle'; // TODO: localization for webview needed dayjs.extend(relativeTime.default, { @@ -65,19 +66,6 @@ export function uniqBy(arr: T[], fn: (el: T) => string): T[] { }); } -export function dispose(disposables: T[]): T[] { - disposables.forEach(d => d.dispose()); - return []; -} - -export function toDisposable(d: () => void): Disposable { - return { dispose: d }; -} - -export function combinedDisposable(disposables: Disposable[]): Disposable { - return toDisposable(() => dispose(disposables)); -} - export function anyEvent(...events: Event[]): Event { return (listener, thisArgs = null, disposables?) => { const result = combinedDisposable(events.map(event => event(i => listener.call(thisArgs, i)))); @@ -91,12 +79,12 @@ export function anyEvent(...events: Event[]): Event { } export function filterEvent(event: Event, filter: (e: T) => boolean): Event { - return (listener, thisArgs = null, disposables?) => + return (listener, thisArgs = null, disposables?: Disposable[]) => event(e => filter(e) && listener.call(thisArgs, e), null, disposables); } export function onceEvent(event: Event): Event { - return (listener, thisArgs = null, disposables?) => { + return (listener, thisArgs = null, disposables?: Disposable[]) => { const result = event( e => { result.dispose(); @@ -114,19 +102,19 @@ function isWindowsPath(path: string): boolean { return /^[a-zA-Z]:\\/.test(path); } -export function isDescendant(parent: string, descendant: string): boolean { - if (parent === descendant) { - return true; +export function isDescendant(parent: string, descendant: string, caseInsensitive: boolean = false, separator: string = sep): boolean { + // Windows is case insensitive + if (isWindowsPath(parent) || caseInsensitive) { + parent = parent.toLowerCase(); + descendant = descendant.toLowerCase(); } - if (parent.charAt(parent.length - 1) !== sep) { - parent += sep; + if (parent === descendant) { + return true; } - // Windows is case insensitive - if (isWindowsPath(parent)) { - parent = parent.toLowerCase(); - descendant = descendant.toLowerCase(); + if (parent.charAt(parent.length - 1) !== separator) { + parent += separator; } return descendant.startsWith(parent); @@ -147,18 +135,18 @@ export class UnreachableCaseError extends Error { } interface HookError extends Error { - errors: any; + errors: (string | { message: string })[]; } function isHookError(e: Error): e is HookError { - return !!(e as any).errors; + return !!(e as Partial).errors; } -function hasFieldErrors(e: any): e is Error & { errors: { value: string; field: string; code: string }[] } { +function hasFieldErrors(e: any): e is Error & { errors: { value: string; field: string; status: string }[] } { let areFieldErrors = true; if (!!e.errors && Array.isArray(e.errors)) { for (const error of e.errors) { - if (!error.field || !error.value || !error.code) { + if (!error.field || !error.value || !error.status) { areFieldErrors = false; break; } @@ -189,14 +177,14 @@ export function formatError(e: HookError | any): string { if (e.message === 'Validation Failed' && hasFieldErrors(e)) { furtherInfo = e.errors .map(error => { - return `Value "${error.value}" cannot be set for field ${error.field} (code: ${error.code})`; + return `Value "${error.value}" cannot be set for field ${error.field} (code: ${error.status})`; }) .join(', '); } else if (e.message.startsWith('Validation Failed:')) { return e.message; } else if (isHookError(e) && e.errors) { return e.errors - .map((error: any) => { + .map((error) => { if (typeof error === 'string') { return error; } else { @@ -212,10 +200,6 @@ export function formatError(e: HookError | any): string { return errorMessage; } -export interface PromiseAdapter { - (value: T, resolve: (value?: U | PromiseLike) => void, reject: (reason: any) => void): any; -} - // Copied from https://github.com/microsoft/vscode/blob/cfd9d25826b5b5bc3b06677521660b4f1ba6639a/extensions/vscode-api-tests/src/utils.ts#L135-L136 export async function asPromise(event: Event): Promise { return new Promise((resolve) => { @@ -226,6 +210,12 @@ export async function asPromise(event: Event): Promise { }); } +export async function promiseWithTimeout(promise: Promise, ms: number): Promise { + return Promise.race([promise, new Promise(resolve => { + setTimeout(() => resolve(undefined), ms); + })]); +} + export function dateFromNow(date: Date | string): string { const djs = dayjs(date); @@ -376,6 +366,10 @@ export interface Predicate { (input: T): boolean; } +export interface AsyncPredicate { + (input: T): Promise; +} + export const enum CharCode { Period = 46, /** @@ -710,6 +704,19 @@ export class UriIterator implements IKeyIterator { } } +export function isPreRelease(context: ExtensionContext): boolean { + const uri = context.extensionUri; + const path = uri.path; + const lastIndexOfDot = path.lastIndexOf('.'); + if (lastIndexOfDot === -1) { + return false; + } + const patchVersion = path.substr(lastIndexOfDot + 1); + // The patch version of release versions should never be more than 1 digit since it is only used for recovery releases. + // The patch version of pre-release is the date + time. + return patchVersion.length > 1; +} + class TernarySearchTreeNode { segment!: string; value: V | undefined; @@ -978,3 +985,33 @@ export async function stringReplaceAsync(str: string, regex: RegExp, asyncFn: (s let offset = 0; 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); + + for (let i = 0; i < batches; i++) { + const batch = items.slice(i * batchSize, (i + 1) * batchSize); + await Promise.all(batch.map(processFn)); + } +} + +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 098d04eb9b..f887fd349b 100644 --- a/src/common/webview.ts +++ b/src/common/webview.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import { commands } from './executeCommands'; +import { Disposable } from './lifecycle'; export const PULL_REQUEST_OVERVIEW_VIEW_TYPE = 'PullRequestOverview'; @@ -16,29 +17,21 @@ export interface IRequestMessage { export interface IReplyMessage { seq?: string; - err?: any; + err?: string; + // eslint-disable-next-line rulesdir/no-any-except-union-method-signature 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 { +export class WebviewBase extends Disposable { protected _webview?: vscode.Webview; - protected _disposables: vscode.Disposable[] = []; private _waitForReady: Promise; - private _onIsReady: vscode.EventEmitter = new vscode.EventEmitter(); + private _onIsReady: vscode.EventEmitter = this._register(new vscode.EventEmitter()); protected readonly MESSAGE_UNHANDLED: string = 'message not handled'; constructor() { + super(); this._waitForReady = new Promise(resolve => { const disposable = this._onIsReady.event(() => { disposable.dispose(); @@ -50,13 +43,10 @@ export class WebviewBase { public initialize(): void { const disposable = this._webview?.onDidReceiveMessage( async message => { - await this._onDidReceiveMessage(message); - }, - null, - this._disposables, - ); + await this._onDidReceiveMessage(message as IRequestMessage); + }); if (disposable) { - this._disposables.push(disposable); + this._register(disposable); } } @@ -87,17 +77,13 @@ export class WebviewBase { this._webview?.postMessage(reply); } - protected async _throwError(originalMessage: IRequestMessage, error: any) { + protected async _throwError(originalMessage: IRequestMessage | undefined, error: string) { const reply: IReplyMessage = { - seq: originalMessage.req, + seq: originalMessage?.req, err: error, }; this._webview?.postMessage(reply); } - - public dispose() { - this._disposables.forEach(d => d.dispose()); - } } export class WebviewViewBase extends WebviewBase { @@ -122,7 +108,7 @@ export class WebviewViewBase extends WebviewBase { localResourceRoots: [this._extensionUri], }; - this._disposables.push(this._view.onDidDispose(() => { + this._register(this._view.onDidDispose(() => { this._webview = undefined; this._view = undefined; })); diff --git a/src/env/browser/ssh.ts b/src/env/browser/ssh.ts index 0ddaa5e688..511b33ac45 100644 --- a/src/env/browser/ssh.ts +++ b/src/env/browser/ssh.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { parse as parseConfig } from 'ssh-config'; +import Logger from '../../common/logger'; const SSH_URL_RE = /^(?:([^@:]+)@)?([^:/]+):?(.+)$/; const URL_SCHEME_RE = /^([a-z-+]+):\/\//; @@ -51,12 +52,12 @@ export const sshParse = (url: string): Config | undefined => { * @param {ConfigResolver?} resolveConfig ssh config resolver (default: from ~/.ssh/config) * @returns {Config} */ - export const resolve = (url: string, resolveConfig = Resolvers.current) => { +export const resolve = (url: string, resolveConfig = Resolvers.current) => { const config = sshParse(url); return config && resolveConfig(config); }; -export function baseResolver(config: Config) { +export function baseResolver(config: Config): Config { return { ...config, Hostname: config.Host, @@ -83,13 +84,20 @@ export type ConfigResolver = (config: Config) => Config; export function chainResolvers(...chain: (ConfigResolver | undefined)[]): ConfigResolver { const resolvers = chain.filter(x => !!x) as ConfigResolver[]; return (config: Config) => - resolvers.reduce( - (resolved, next) => ({ - ...resolved, - ...next(resolved), - }), - config, - ); + resolvers.reduce((resolved, next) => { + try { + return { + ...resolved, + ...next(resolved), + }; + } catch (err) { + // We cannot trust that some resolvers are not going to throw (i.e user has malformed .ssh/config file). + // Since we can't guarantee that ssh-config package won't throw and we're reducing over the entire chain of resolvers, + // we'll skip erroneous resolvers for now and log. Potentially can validate + Logger.warn(`Failed to parse config for '${config.Host}, this can occur when the extension configurations is invalid or system ssh config files are malformed. Skipping erroneous resolver for now.'`); + return resolved; + } + }, config); } export function resolverFromConfig(text: string): ConfigResolver { diff --git a/src/env/node/net.ts b/src/env/node/net.ts index 1eac5b7f8f..ec1f6fd5ea 100644 --- a/src/env/node/net.ts +++ b/src/env/node/net.ts @@ -26,7 +26,7 @@ function getAgent(url: string | undefined = process.env.HTTPS_PROXY): Agent { const auth = username && password && `${username}:${password}`; return httpsOverHttp({ proxy: { host: hostname, port, proxyAuth: auth } }); } catch (e) { - window.showErrorMessage(l10n.t('HTTPS_PROXY environment variable ignored: {0}', e.message)); + window.showErrorMessage(l10n.t('HTTPS_PROXY environment variable ignored: {0}', (e as Error).message)); return globalAgent; } } diff --git a/src/experimentationService.ts b/src/experimentationService.ts index 7e17192802..f184926664 100644 --- a/src/experimentationService.ts +++ b/src/experimentationService.ts @@ -11,6 +11,7 @@ import { IExperimentationTelemetry, TargetPopulation, } from 'vscode-tas-client'; +import { Disposable } from './common/lifecycle'; /* __GDPR__ "query-expfeature" : { @@ -18,10 +19,15 @@ import { } */ -export class ExperimentationTelemetry implements IExperimentationTelemetry { +export class ExperimentationTelemetry extends Disposable implements IExperimentationTelemetry { private sharedProperties: Record = {}; - constructor(private baseReporter: TelemetryReporter | undefined) { } + constructor(private baseReporter: TelemetryReporter | undefined) { + super(); + if (baseReporter) { + this._register(baseReporter); + } + } sendTelemetryEvent(eventName: string, properties?: Record, measurements?: Record) { this.baseReporter?.sendTelemetryEvent( @@ -56,10 +62,6 @@ export class ExperimentationTelemetry implements IExperimentationTelemetry { } this.sendTelemetryEvent(eventName, event); } - - async dispose(): Promise { - return this.baseReporter?.dispose(); - } } function getTargetPopulation(): TargetPopulation { @@ -79,6 +81,7 @@ function getTargetPopulation(): TargetPopulation { class NullExperimentationService implements IExperimentationService { readonly initializePromise: Promise = Promise.resolve(); + readonly initialFetch: Promise = Promise.resolve(); isFlightEnabled(_flight: string): boolean { return false; @@ -110,17 +113,17 @@ export async function createExperimentationService( ): Promise { const id = context.extension.id; const name = context.extension.packageJSON['name']; - const version = context.extension.packageJSON['version']; + const version: string = context.extension.packageJSON['version']; const targetPopulation = getTargetPopulation(); // We only create a real experimentation service for the stable version of the extension, not insiders. return name === 'vscode-pull-request-github' ? getExperimentationService( - id, - version, - targetPopulation, - experimentationTelemetry, - context.globalState, - ) + id, + version, + targetPopulation, + experimentationTelemetry, + context.globalState, + ) : new NullExperimentationService(); } diff --git a/src/extension.ts b/src/extension.ts index 15c3d0a4b3..a65dfd4232 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,372 +1,502 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * 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'; -import { registerCommands } from './commands'; -import { commands } from './common/executeCommands'; -import Logger from './common/logger'; -import * as PersistentState from './common/persistentState'; -import { parseRepositoryRemotes } from './common/remote'; -import { Resource } from './common/resources'; -import { BRANCH_PUBLISH, FILE_LIST_LAYOUT, GIT, OPEN_DIFF_ON_CLICK, PR_SETTINGS_NAMESPACE } from './common/settingKeys'; -import { TemporaryState } from './common/temporaryState'; -import { Schemes, handler as uriHandler } from './common/uri'; -import { EXTENSION_ID, FOCUS_REVIEW_MODE } from './constants'; -import { createExperimentationService, ExperimentationTelemetry } from './experimentationService'; -import { CredentialStore } from './github/credentials'; -import { FolderRepositoryManager, SETTINGS_NAMESPACE } from './github/folderRepositoryManager'; -import { RepositoriesManager } from './github/repositoriesManager'; -import { registerBuiltinGitProvider, registerLiveShareGitProvider } from './gitProviders/api'; -import { GitHubContactServiceProvider } from './gitProviders/GitHubContactServiceProvider'; -import { GitLensIntegration } from './integrations/gitlens/gitlensImpl'; -import { IssueFeatureRegistrar } from './issues/issueFeatureRegistrar'; -import { FileTypeDecorationProvider } from './view/fileTypeDecorationProvider'; -import { getInMemPRFileSystemProvider } from './view/inMemPRContentProvider'; -import { PullRequestChangesTreeDataProvider } from './view/prChangesTreeDataProvider'; -import { PRNotificationDecorationProvider } from './view/prNotificationDecorationProvider'; -import { PullRequestsTreeDataProvider } from './view/prsTreeDataProvider'; -import { ReviewManager, ShowPullRequest } from './view/reviewManager'; -import { ReviewsManager } from './view/reviewsManager'; -import { WebviewViewCoordinator } from './view/webviewViewCoordinator'; - -const ingestionKey = '0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255'; - -let telemetry: ExperimentationTelemetry; - -const PROMPTS_SCOPE = 'prompts'; -const PROMPT_TO_CREATE_PR_ON_PUBLISH_KEY = 'createPROnPublish'; - -async function init( - context: vscode.ExtensionContext, - git: GitApiImpl, - credentialStore: CredentialStore, - repositories: Repository[], - tree: PullRequestsTreeDataProvider, - liveshareApiPromise: Promise, - showPRController: ShowPullRequest, - reposManager: RepositoriesManager, -): Promise { - context.subscriptions.push(Logger); - Logger.appendLine('Git repository found, initializing review manager and pr tree view.'); - - vscode.authentication.onDidChangeSessions(async e => { - if (e.provider.id === 'github') { - await reposManager.clearCredentialCache(); - if (reviewManagers) { - reviewManagers.forEach(reviewManager => reviewManager.updateState(true)); - } - } - }); - - context.subscriptions.push( - git.onDidPublish(async e => { - // Only notify on branch publish events - if (!e.branch) { - return; - } - - if (vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'ask' | 'never' | undefined>(BRANCH_PUBLISH) !== 'ask') { - return; - } - - const reviewManager = reviewManagers.find( - manager => manager.repository.rootUri.toString() === e.repository.rootUri.toString(), - ); - if (reviewManager?.isCreatingPullRequest) { - return; - } - - const folderManager = reposManager.folderManagers.find( - manager => manager.repository.rootUri.toString() === e.repository.rootUri.toString()); - - if (!folderManager || folderManager.gitHubRepositories.length === 0) { - return; - } - - const defaults = await folderManager.getPullRequestDefaults(); - if (defaults.base === e.branch) { - return; - } - - const create = vscode.l10n.t('Create Pull Request...'); - const dontShowAgain = vscode.l10n.t('Don\'t Show Again'); - const result = await vscode.window.showInformationMessage( - vscode.l10n.t('Would you like to create a Pull Request for branch \'{0}\'?', e.branch), - create, - dontShowAgain, - ); - if (result === create) { - reviewManager?.createPullRequest(e.branch); - } else if (result === dontShowAgain) { - await vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(BRANCH_PUBLISH, 'never', vscode.ConfigurationTarget.Global); - } - }), - ); - - context.subscriptions.push(vscode.window.registerUriHandler(uriHandler)); - - // Sort the repositories to match folders in a multiroot workspace (if possible). - const workspaceFolders = vscode.workspace.workspaceFolders; - if (workspaceFolders) { - repositories = repositories.sort((a, b) => { - let indexA = workspaceFolders.length; - let indexB = workspaceFolders.length; - for (let i = 0; i < workspaceFolders.length; i++) { - if (workspaceFolders[i].uri.toString() === a.rootUri.toString()) { - indexA = i; - } else if (workspaceFolders[i].uri.toString() === b.rootUri.toString()) { - indexB = i; - } - if (indexA !== workspaceFolders.length && indexB !== workspaceFolders.length) { - break; - } - } - return indexA - indexB; - }); - } - - liveshareApiPromise.then(api => { - if (api) { - // register the pull request provider to suggest PR contacts - api.registerContactServiceProvider('github-pr', new GitHubContactServiceProvider(reposManager)); - } - }); - - const changesTree = new PullRequestChangesTreeDataProvider(context, git, reposManager); - context.subscriptions.push(changesTree); - - const activePrViewCoordinator = new WebviewViewCoordinator(context); - context.subscriptions.push(activePrViewCoordinator); - const reviewManagers = reposManager.folderManagers.map( - folderManager => new ReviewManager(context, folderManager.repository, folderManager, telemetry, changesTree, showPRController, activePrViewCoordinator), - ); - context.subscriptions.push(new FileTypeDecorationProvider(reposManager, reviewManagers)); - - const reviewsManager = new ReviewsManager(context, reposManager, reviewManagers, tree, changesTree, telemetry, credentialStore, git); - context.subscriptions.push(reviewsManager); - - git.onDidChangeState(() => { - Logger.appendLine(`Git initialization state changed: state=${git.state}`); - reviewManagers.forEach(reviewManager => reviewManager.updateState(true)); - }); - - git.onDidOpenRepository(repo => { - function addRepo() { - // Make sure we don't already have a folder manager for this repo. - const existing = reposManager.getManagerForFile(repo.rootUri); - if (existing) { - Logger.appendLine(`Repo ${repo.rootUri} has already been setup.`); - return; - } - const newFolderManager = new FolderRepositoryManager(context, repo, telemetry, git, credentialStore); - reposManager.insertFolderManager(newFolderManager); - const newReviewManager = new ReviewManager( - context, - newFolderManager.repository, - newFolderManager, - telemetry, - changesTree, - showPRController, - activePrViewCoordinator - ); - reviewsManager.addReviewManager(newReviewManager); - tree.refresh(); - } - addRepo(); - tree.notificationProvider.refreshOrLaunchPolling(); - const disposable = repo.state.onDidChange(() => { - Logger.appendLine(`Repo state for ${repo.rootUri} changed.`); - addRepo(); - disposable.dispose(); - }); - }); - - git.onDidCloseRepository(repo => { - reposManager.removeRepo(repo); - reviewsManager.removeReviewManager(repo); - tree.notificationProvider.refreshOrLaunchPolling(); - tree.refresh(); - }); - - tree.initialize(reposManager, reviewManagers.map(manager => manager.reviewModel), credentialStore); - - context.subscriptions.push(new PRNotificationDecorationProvider(tree.notificationProvider)); - - registerCommands(context, reposManager, reviewManagers, telemetry, tree); - - const layout = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); - await vscode.commands.executeCommand('setContext', 'fileListLayout:flat', layout === 'flat'); - - const issuesFeatures = new IssueFeatureRegistrar(git, reposManager, reviewManagers, context, telemetry); - context.subscriptions.push(issuesFeatures); - await issuesFeatures.initialize(); - - context.subscriptions.push(new GitLensIntegration()); - - await vscode.commands.executeCommand('setContext', 'github:initialized', true); - - const experimentationService = await createExperimentationService(context, telemetry); - await experimentationService.initializePromise; - await experimentationService.isCachedFlightEnabled('githubaa'); - registerPostCommitCommandsProvider(reposManager, git); - /* __GDPR__ - "startup" : {} - */ - telemetry.sendTelemetryEvent('startup'); -} - -export async function activate(context: vscode.ExtensionContext): Promise { - // 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'); - if (stable !== undefined) { - throw new Error( - 'GitHub Pull Requests and Issues Nightly cannot be used while GitHub Pull Requests and Issues is also installed. Please ensure that only one version of the extension is installed.', - ); - } - } - - const showPRController = new ShowPullRequest(); - vscode.commands.registerCommand('github.api.preloadPullRequest', async (shouldShow: boolean) => { - await vscode.commands.executeCommand('setContext', FOCUS_REVIEW_MODE, true); - await commands.focusView('github:activePullRequest:welcome'); - showPRController.shouldShow = shouldShow; - }); - const openDiff = vscode.workspace.getConfiguration(GIT, null).get(OPEN_DIFF_ON_CLICK, true); - await vscode.commands.executeCommand('setContext', 'openDiffOnClick', openDiff); - - // initialize resources - Resource.initialize(context); - Logger.debug('Creating API implementation.', 'Activation'); - const apiImpl = new GitApiImpl(); - - const version = vscode.extensions.getExtension(EXTENSION_ID)!.packageJSON.version; - telemetry = new ExperimentationTelemetry(new TelemetryReporter(EXTENSION_ID, version, ingestionKey)); - context.subscriptions.push(telemetry); - - await deferredActivate(context, apiImpl, showPRController); - - return apiImpl; -} - -async function doRegisterBuiltinGitProvider(context: vscode.ExtensionContext, credentialStore: CredentialStore, apiImpl: GitApiImpl): Promise { - const builtInGitProvider = await registerBuiltinGitProvider(credentialStore, apiImpl); - if (builtInGitProvider) { - context.subscriptions.push(builtInGitProvider); - return true; - } - return false; -} - -function registerPostCommitCommandsProvider(reposManager: RepositoriesManager, git: GitApiImpl) { - const componentId = 'GitPostCommitCommands'; - class Provider implements PostCommitCommandsProvider { - - getCommands(repository: Repository) { - Logger.debug(`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 => { - return !!repoRemotes.find(remote => { - return remote.equals(githubRepo.remote); - }); - })); - Logger.debug(`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)'), - tooltip: vscode.l10n.t('Commit & Create Pull Request') - }] : []; - } - } - - function hasGitHubRepos(): boolean { - return reposManager.folderManagers.some(folderManager => folderManager.gitHubRepositories.length > 0); - } - function tryRegister(): boolean { - Logger.debug('Trying to register post commit commands.', 'GitPostCommitCommands'); - if (hasGitHubRepos()) { - Logger.debug('GitHub remote(s) found, registering post commit commands.', componentId); - git.registerPostCommitCommandsProvider(new Provider()); - return true; - } - return false; - } - - if (!tryRegister()) { - const reposDisposable = reposManager.onDidLoadAnyRepositories(() => { - if (tryRegister()) { - reposDisposable.dispose(); - } - }); - } -} - -async function deferredActivateRegisterBuiltInGitProvider(context: vscode.ExtensionContext, apiImpl: GitApiImpl, credentialStore: CredentialStore) { - Logger.debug('Registering built in git provider.', 'Activation'); - if (!(await doRegisterBuiltinGitProvider(context, credentialStore, apiImpl))) { - const extensionsChangedDisposable = vscode.extensions.onDidChange(async () => { - if (await doRegisterBuiltinGitProvider(context, credentialStore, apiImpl)) { - extensionsChangedDisposable.dispose(); - } - }); - context.subscriptions.push(extensionsChangedDisposable); - } -} - -async function deferredActivate(context: vscode.ExtensionContext, apiImpl: GitApiImpl, showPRController: ShowPullRequest) { - Logger.debug('Initializing state.', 'Activation'); - PersistentState.init(context); - // Migrate from state to setting - if (PersistentState.fetch(PROMPTS_SCOPE, PROMPT_TO_CREATE_PR_ON_PUBLISH_KEY) === false) { - await vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(BRANCH_PUBLISH, 'never', vscode.ConfigurationTarget.Global); - PersistentState.store(PROMPTS_SCOPE, PROMPT_TO_CREATE_PR_ON_PUBLISH_KEY, true); - } - TemporaryState.init(context); - Logger.debug('Creating credential store.', 'Activation'); - const credentialStore = new CredentialStore(telemetry, context); - context.subscriptions.push(credentialStore); - await credentialStore.create({ silent: true }); - - deferredActivateRegisterBuiltInGitProvider(context, apiImpl, credentialStore); - - Logger.debug('Registering live share git provider.', 'Activation'); - const liveshareGitProvider = registerLiveShareGitProvider(apiImpl); - context.subscriptions.push(liveshareGitProvider); - const liveshareApiPromise = liveshareGitProvider.initialize(); - - context.subscriptions.push(apiImpl); - - Logger.debug('Creating tree view.', 'Activation'); - const prTree = new PullRequestsTreeDataProvider(telemetry, context); - context.subscriptions.push(prTree); - Logger.appendLine('Looking for git repository'); - const repositories = apiImpl.repositories; - Logger.appendLine(`Found ${repositories.length} repositories during activation`); - - const folderManagers = repositories.map( - repository => new FolderRepositoryManager(context, repository, telemetry, apiImpl, credentialStore), - ); - context.subscriptions.push(...folderManagers); - - const reposManager = new RepositoriesManager(folderManagers, credentialStore, telemetry); - context.subscriptions.push(reposManager); - const inMemPRFileSystemProvider = getInMemPRFileSystemProvider({ reposManager, gitAPI: apiImpl, credentialStore })!; - context.subscriptions.push(vscode.workspace.registerFileSystemProvider(Schemes.Pr, inMemPRFileSystemProvider, { isReadonly: true })); - - await init(context, apiImpl, credentialStore, repositories, prTree, liveshareApiPromise, showPRController, reposManager); -} - -export async function deactivate() { - if (telemetry) { - telemetry.dispose(); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +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'; +import { registerCommands } from './commands'; +import { commands, contexts } from './common/executeCommands'; +import { isSubmodule } from './common/gitUtils'; +import Logger from './common/logger'; +import * as PersistentState from './common/persistentState'; +import { parseRepositoryRemotes } from './common/remote'; +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'; +import { Schemes } from './common/uri'; +import { isDescendant } from './common/utils'; +import { EXTENSION_ID, FOCUS_REVIEW_MODE } from './constants'; +import { createExperimentationService, ExperimentationTelemetry } from './experimentationService'; +import { CopilotRemoteAgentManager } from './github/copilotRemoteAgent'; +import { CredentialStore } from './github/credentials'; +import { FolderRepositoryManager } from './github/folderRepositoryManager'; +import { OverviewRestorer } from './github/overviewRestorer'; +import { RepositoriesManager } from './github/repositoriesManager'; +import { registerBuiltinGitProvider, registerLiveShareGitProvider } from './gitProviders/api'; +import { GitHubContactServiceProvider } from './gitProviders/GitHubContactServiceProvider'; +import { GitLensIntegration } from './integrations/gitlens/gitlensImpl'; +import { IssueFeatureRegistrar } from './issues/issueFeatureRegistrar'; +import { StateManager } from './issues/stateManager'; +import { IssueContextProvider } from './lm/issueContextProvider'; +import { ChatParticipant, ChatParticipantState } from './lm/participants'; +import { PullRequestContextProvider } from './lm/pullRequestContextProvider'; +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 { resumePendingCheckout, UriHandler } from './uriHandler'; +import { CommentDecorationProvider } from './view/commentDecorationProvider'; +import { CommitsDecorationProvider } from './view/commitsDecorationProvider'; +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 { 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'; +import { WebviewViewCoordinator } from './view/webviewViewCoordinator'; + +const ingestionKey = '0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255'; + +let telemetry: ExperimentationTelemetry; + +const ACTIVATION = 'Activation'; + +async function init( + context: vscode.ExtensionContext, + git: GitApiImpl, + credentialStore: CredentialStore, + repositories: Repository[], + tree: PullRequestsTreeDataProvider, + liveshareApiPromise: Promise, + showPRController: ShowPullRequest, + reposManager: RepositoriesManager, + createPrHelper: CreatePullRequestHelper, + copilotRemoteAgentManager: CopilotRemoteAgentManager, + themeWatcher: ThemeWatcher, + prsTreeModel: PrsTreeModel, +): Promise { + context.subscriptions.push(Logger); + Logger.appendLine('Git repository found, initializing review manager and pr tree view.', ACTIVATION); + + context.subscriptions.push(credentialStore.onDidChangeSessions(async e => { + if (e.provider.id === 'github') { + await reposManager.clearCredentialCache(); + if (reviewsManager) { + reviewsManager.reviewManagers.forEach(reviewManager => reviewManager.updateState(true)); + } + } + })); + + context.subscriptions.push( + git.onDidPublish(async e => { + // Only notify on branch publish events + if (!e.branch) { + return; + } + + const createOnPublishBranch = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'ask' | 'never' | 'always' | undefined>(BRANCH_PUBLISH); + + if (createOnPublishBranch === 'never') { + return; + } + + const reviewManager = reviewsManager.reviewManagers.find( + manager => manager.repository.rootUri.toString() === e.repository.rootUri.toString(), + ); + if (reviewManager?.isCreatingPullRequest) { + return; + } + + const folderManager = reposManager.folderManagers.find( + manager => manager.repository.rootUri.toString() === e.repository.rootUri.toString()); + + if (!folderManager || folderManager.gitHubRepositories.length === 0) { + return; + } + + const defaults = await folderManager.getPullRequestDefaults(); + if (defaults.base === e.branch) { + return; + } + + if (createOnPublishBranch === 'always') { + reviewManager?.createPullRequest(e.branch); + return; + } + + const create = vscode.l10n.t('Create Pull Request...'); + const dontShowAgain = vscode.l10n.t('Don\'t Show Again'); + const result = await vscode.window.showInformationMessage( + vscode.l10n.t('Would you like to create a Pull Request for branch \'{0}\'?', e.branch), + create, + dontShowAgain, + ); + if (result === create) { + reviewManager?.createPullRequest(e.branch); + } else if (result === dontShowAgain) { + await vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(BRANCH_PUBLISH, 'never', vscode.ConfigurationTarget.Global); + } + }), + ); + + // Sort the repositories to match folders in a multiroot workspace (if possible). + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders) { + repositories = repositories.sort((a, b) => { + let indexA = workspaceFolders.length; + let indexB = workspaceFolders.length; + for (let i = 0; i < workspaceFolders.length; i++) { + if (workspaceFolders[i].uri.toString() === a.rootUri.toString()) { + indexA = i; + } else if (workspaceFolders[i].uri.toString() === b.rootUri.toString()) { + indexB = i; + } + if (indexA !== workspaceFolders.length && indexB !== workspaceFolders.length) { + break; + } + } + return indexA - indexB; + }); + } + + liveshareApiPromise.then(api => { + if (api) { + // register the pull request provider to suggest PR contacts + api.registerContactServiceProvider('github-pr', new GitHubContactServiceProvider(reposManager)); + } + }); + + const changesTree = new PullRequestChangesTreeDataProvider(git, reposManager); + context.subscriptions.push(changesTree); + + const activePrViewCoordinator = new WebviewViewCoordinator(context); + context.subscriptions.push(activePrViewCoordinator); + + let reviewManagerIndex = 0; + const reviewManagers = reposManager.folderManagers.map( + folderManager => new ReviewManager(reviewManagerIndex++, context, folderManager.repository, folderManager, telemetry, changesTree, tree, showPRController, activePrViewCoordinator, createPrHelper, git), + ); + const treeDecorationProviders = new TreeDecorationProviders(reposManager); + context.subscriptions.push(treeDecorationProviders); + treeDecorationProviders.registerProviders([new FileTypeDecorationProvider(), new CommentDecorationProvider(reposManager), new CommitsDecorationProvider(reposManager)]); + + 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)); + }); + + git.onDidOpenRepository(repo => { + function addRepo() { + // Make sure we don't already have a folder manager for this repo. + const existing = reposManager.folderManagers.find(manager => manager.repository.rootUri.toString() === repo.rootUri.toString()); + if (existing) { + Logger.appendLine(`Repo ${repo.rootUri} has already been setup.`, ACTIVATION); + return; + } + + // Check if submodules should be ignored + const ignoreSubmodules = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(IGNORE_SUBMODULES, false); + if (ignoreSubmodules && isSubmodule(repo, git)) { + Logger.appendLine(`Repo ${repo.rootUri} is a submodule and will be ignored due to ${IGNORE_SUBMODULES} setting.`, ACTIVATION); + return; + } + + const newFolderManager = new FolderRepositoryManager(reposManager.folderManagers.length, context, repo, telemetry, git, credentialStore, createPrHelper, themeWatcher); + reposManager.insertFolderManager(newFolderManager); + const newReviewManager = new ReviewManager( + reviewManagerIndex++, + context, + newFolderManager.repository, + newFolderManager, + telemetry, + changesTree, + tree, + showPRController, + activePrViewCoordinator, + createPrHelper, + git + ); + reviewsManager.addReviewManager(newReviewManager); + } + + // Check if repo is in one of the workspace folders or vice versa + Logger.debug(`Checking if repo ${repo.rootUri.fsPath} is in a workspace folder.`, ACTIVATION); + Logger.debug(`Workspace folders: ${workspaceFolders?.map(folder => folder.uri.fsPath).join(', ')}`, ACTIVATION); + if (workspaceFolders && !workspaceFolders.some(folder => isDescendant(folder.uri.fsPath, repo.rootUri.fsPath, true) || isDescendant(repo.rootUri.fsPath, folder.uri.fsPath, true))) { + Logger.appendLine(`Repo ${repo.rootUri} is not in a workspace folder, ignoring.`, ACTIVATION); + return; + } + addRepo(); + const disposable = repo.state.onDidChange(() => { + Logger.appendLine(`Repo state for ${repo.rootUri} changed.`, ACTIVATION); + addRepo(); + disposable.dispose(); + }); + }); + + git.onDidCloseRepository(repo => { + reposManager.removeRepo(repo); + reviewsManager.removeReviewManager(repo); + }); + + tree.initialize(reviewsManager.reviewManagers.map(manager => manager.reviewModel), notificationsManager); + + registerCommands(context, reposManager, reviewsManager, telemetry, copilotRemoteAgentManager, notificationsManager, prsTreeModel, tree); + + const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); + await vscode.commands.executeCommand('setContext', 'fileListLayout:flat', layout === 'flat'); + + const issueStateManager = new StateManager(git, reposManager, context); + const issuesFeatures = new IssueFeatureRegistrar(git, reposManager, reviewsManager, context, telemetry, issueStateManager, copilotRemoteAgentManager); + context.subscriptions.push(issuesFeatures); + await issuesFeatures.initialize(); + + const pullRequestContextProvider = new PullRequestContextProvider(prsTreeModel, reposManager, git, context); + vscode.chat.registerChatContextProvider({ scheme: 'webview-panel', pattern: '**/webview-PullRequestOverview**' }, 'githubpr', pullRequestContextProvider); + vscode.chat.registerChatContextProvider({ scheme: 'webview-panel', pattern: '**/webview-IssueOverview**' }, 'githubissue', new IssueContextProvider(issueStateManager, reposManager, context)); + pullRequestContextProvider.initialize(); + + const notificationsFeatures = new NotificationsFeatureRegister(credentialStore, reposManager, telemetry, notificationsManager); + context.subscriptions.push(notificationsFeatures); + + context.subscriptions.push(new GitLensIntegration()); + + context.subscriptions.push(new OverviewRestorer(reposManager, telemetry, context.extensionUri, credentialStore)); + + await vscode.commands.executeCommand('setContext', 'github:initialized', true); + + registerPostCommitCommandsProvider(context, reposManager, git); + + // Resume any pending checkout request stored before workspace reopened. + await resumePendingCheckout(reviewsManager, context, reposManager); + + initChat(context, credentialStore, reposManager); + 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(); + /* __GDPR__ + "startup" : {} + */ + telemetry.sendTelemetryEvent('startup'); +} + +function initChat(context: vscode.ExtensionContext, credentialStore: CredentialStore, reposManager: RepositoriesManager) { + const createParticipant = () => { + const chatParticipantState = new ChatParticipantState(); + context.subscriptions.push(new ChatParticipant(context, chatParticipantState)); + registerTools(context, credentialStore, reposManager, chatParticipantState); + }; + + const chatEnabled = () => vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(EXPERIMENTAL_CHAT, false); + if (chatEnabled()) { + createParticipant(); + } else { + initBasedOnSettingChange(PR_SETTINGS_NAMESPACE, EXPERIMENTAL_CHAT, chatEnabled, createParticipant, context.subscriptions); + } +} + +export async function activate(context: vscode.ExtensionContext): Promise { + Logger.appendLine(`Extension version: ${vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON.version}`, 'Activation'); + + // @ts-ignore + if (EXTENSION_ID === 'GitHub.vscode-pull-request-github-insiders') { + const stable = vscode.extensions.getExtension('github.vscode-pull-request-github'); + if (stable !== undefined) { + throw new Error( + 'GitHub Pull Requests and Issues Nightly cannot be used while GitHub Pull Requests and Issues is also installed. Please ensure that only one version of the extension is installed.', + ); + } + } + + const showPRController = new ShowPullRequest(); + vscode.commands.registerCommand('github.api.preloadPullRequest', async (shouldShow: boolean) => { + await vscode.commands.executeCommand('setContext', FOCUS_REVIEW_MODE, true); + await commands.focusView('github:activePullRequest:welcome'); + showPRController.shouldShow = shouldShow; + }); + await setGitSettingContexts(context); + + Logger.debug('Creating API implementation.', 'Activation'); + + telemetry = new ExperimentationTelemetry(new TelemetryReporter(ingestionKey)); + context.subscriptions.push(telemetry); + + const deferred = await deferredActivate(context, showPRController); + await commands.setContext(contexts.ACTIVATED, true); + return deferred; +} + +async function setGitSettingContexts(context: vscode.ExtensionContext) { + // We set contexts instead of using the config directly in package.json because the git extension might not actually be available. + const settings: [string, () => void][] = [ + ['openDiffOnClick', () => vscode.workspace.getConfiguration(GIT, null).get(OPEN_DIFF_ON_CLICK, true)], + ['showInlineOpenFileAction', () => vscode.workspace.getConfiguration(GIT, null).get(SHOW_INLINE_OPEN_FILE_ACTION, true)] + ]; + for (const [contextName, setting] of settings) { + commands.setContext(contextName, setting()); + context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(`${GIT}.${contextName}`)) { + commands.setContext(contextName, setting()); + } + })); + } +} + +async function doRegisterBuiltinGitProvider(context: vscode.ExtensionContext, credentialStore: CredentialStore, apiImpl: GitApiImpl): Promise { + const builtInGitProvider = await registerBuiltinGitProvider(credentialStore, apiImpl); + if (builtInGitProvider) { + context.subscriptions.push(builtInGitProvider); + return true; + } + return false; +} + +function registerPostCommitCommandsProvider(context: vscode.ExtensionContext, reposManager: RepositoriesManager, git: GitApiImpl) { + const componentId = 'GitPostCommitCommands'; + class Provider implements PostCommitCommandsProvider { + + getCommands(repository: Repository) { + 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 => { + return !!repoRemotes.find(remote => { + return remote.equals(githubRepo.remote); + }); + })); + 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)'), + tooltip: vscode.l10n.t('Commit & Create Pull Request') + }] : []; + } + } + + function hasGitHubRepos(): boolean { + return reposManager.folderManagers.some(folderManager => folderManager.gitHubRepositories.length > 0); + } + function tryRegister(): boolean { + Logger.appendLine('Trying to register post commit commands.', 'GitPostCommitCommands'); + if (hasGitHubRepos()) { + Logger.appendLine('GitHub remote(s) found, registering post commit commands.', componentId); + context.subscriptions.push(git.registerPostCommitCommandsProvider(new Provider())); + return true; + } + return false; + } + + if (!tryRegister()) { + const reposDisposable = reposManager.onDidLoadAnyRepositories(() => { + if (tryRegister()) { + reposDisposable.dispose(); + } + }); + } +} + +async function deferredActivateRegisterBuiltInGitProvider(context: vscode.ExtensionContext, apiImpl: GitApiImpl, credentialStore: CredentialStore) { + 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)) { + extensionsChangedDisposable.dispose(); + } + }); + context.subscriptions.push(extensionsChangedDisposable); + } +} + +async function deferredActivate(context: vscode.ExtensionContext, showPRController: ShowPullRequest) { + Logger.debug('Initializing state.', 'Activation'); + PersistentState.init(context); + await migrate(context); + TemporaryState.init(context); + Logger.debug('Creating credential store.', 'Activation'); + const credentialStore = new CredentialStore(telemetry, context); + context.subscriptions.push(credentialStore); + const experimentationService = await createExperimentationService(context, telemetry); + await experimentationService.initializePromise; + await experimentationService.isCachedFlightEnabled('githubaa'); + await credentialStore.create(); + + 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); + + deferredActivateRegisterBuiltInGitProvider(context, apiImpl, credentialStore); + + Logger.debug('Registering live share git provider.', 'Activation'); + const liveshareGitProvider = registerLiveShareGitProvider(apiImpl); + context.subscriptions.push(liveshareGitProvider); + const liveshareApiPromise = liveshareGitProvider.initialize(); + + context.subscriptions.push(apiImpl); + + Logger.debug('Creating tree view.', 'Activation'); + + const copilotRemoteAgentManager = new CopilotRemoteAgentManager(credentialStore, reposManager, telemetry, context, prsTreeModel); + context.subscriptions.push(copilotRemoteAgentManager); + + const prTree = new PullRequestsTreeDataProvider(prsTreeModel, telemetry, context, reposManager); + context.subscriptions.push(prTree); + context.subscriptions.push(credentialStore.onDidGetSession(() => prTree.refreshAll(true))); + Logger.appendLine('Looking for git repository', ACTIVATION); + const repositories = apiImpl.repositories; + Logger.appendLine(`Found ${repositories.length} repositories during activation`, ACTIVATION); + const createPrHelper = new CreatePullRequestHelper(); + context.subscriptions.push(createPrHelper); + + const themeWatcher = new ThemeWatcher(); + context.subscriptions.push(themeWatcher); + + let folderManagerIndex = 0; + const folderManagers = repositories.map( + repository => new FolderRepositoryManager(folderManagerIndex++, context, repository, telemetry, apiImpl, credentialStore, createPrHelper, themeWatcher), + ); + context.subscriptions.push(...folderManagers); + for (const folderManager of folderManagers) { + reposManager.insertFolderManager(folderManager); + } + + const inMemPRFileSystemProvider = getInMemPRFileSystemProvider({ reposManager, gitAPI: apiImpl, credentialStore })!; + const readOnlyMessage = new vscode.MarkdownString(vscode.l10n.t('Cannot edit this pull request file. [Check out](command:pr.checkoutFromReadonlyFile) this pull request to edit.')); + readOnlyMessage.isTrusted = { enabledCommands: ['pr.checkoutFromReadonlyFile'] }; + context.subscriptions.push(vscode.workspace.registerFileSystemProvider(Schemes.Pr, inMemPRFileSystemProvider, { isReadonly: readOnlyMessage })); + 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, prsTreeModel); + return apiImpl; +} + +export async function deactivate() { + if (telemetry) { + telemetry.dispose(); + } +} diff --git a/src/extensionState.ts b/src/extensionState.ts index e639e46f7d..4722bff657 100644 --- a/src/extensionState.ts +++ b/src/extensionState.ts @@ -11,6 +11,10 @@ export const NEVER_SHOW_PULL_NOTIFICATION = 'github.pullRequest.pullNotification // Not synced keys export const REPO_KEYS = 'github.pullRequest.repos'; +export const PREVIOUS_CREATE_METHOD = 'github.pullRequest.previousCreateMethod'; +export const LAST_USED_EMAIL = 'github.pullRequest.lastUsedEmail'; +export const BRANCHES_ASSOCIATED_WITH_PRS = 'github.pullRequest.branchesAssociatedWithPRs'; +export const RECENTLY_USED_BRANCHES = 'github.pullRequest.recentlyUsedBranches'; export interface RepoState { mentionableUsers?: IAccount[]; @@ -21,6 +25,10 @@ export interface ReposState { repos: { [ownerAndRepo: string]: RepoState }; } +export interface RecentlyUsedBranchesState { + branches: { [ownerAndRepo: string]: string[] }; +} + export function setSyncedKeys(context: vscode.ExtensionContext) { context.globalState.setKeysForSync([NEVER_SHOW_PULL_NOTIFICATION]); } \ No newline at end of file diff --git a/src/gitExtensionIntegration.ts b/src/gitExtensionIntegration.ts index 5cac393313..88ee9cc940 100644 --- a/src/gitExtensionIntegration.ts +++ b/src/gitExtensionIntegration.ts @@ -7,6 +7,7 @@ import { RemoteSource, RemoteSourceProvider } from './@types/git'; import { AuthProvider } from './common/authentication'; import { OctokitCommon } from './github/common'; import { CredentialStore, GitHub } from './github/credentials'; +import { isEnterprise } from './github/utils'; interface Repository { readonly full_name: string; @@ -39,7 +40,7 @@ export class GithubRemoteSourceProvider implements RemoteSourceProvider { private userReposCache: RemoteSource[] = []; constructor(private readonly credentialStore: CredentialStore, private readonly authProviderId: AuthProvider = AuthProvider.github) { - if (authProviderId === AuthProvider['github-enterprise']) { + if (isEnterprise(authProviderId)) { this.name = 'GitHub Enterprise'; } } diff --git a/src/gitProviders/GitHubContactServiceProvider.ts b/src/gitProviders/GitHubContactServiceProvider.ts index ad7f11308c..bc3973cfef 100644 --- a/src/gitProviders/GitHubContactServiceProvider.ts +++ b/src/gitProviders/GitHubContactServiceProvider.ts @@ -18,7 +18,7 @@ interface ContactServiceProvider { interface NotifyContactServiceEventArgs { type: string; - body?: any | undefined; + body?: { contacts: Contact[]; exclusive?: boolean } | undefined; } /** @@ -132,7 +132,7 @@ export class GitHubContactServiceProvider implements ContactServiceProvider { } } - private notify(type: string, body: any) { + private notify(type: string, body: { contacts: Contact[]; exclusive?: boolean }) { this.onNotifiedEmitter.fire({ type, body, 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 5262201927..bb6df80374 100644 --- a/src/gitProviders/builtinGit.ts +++ b/src/gitProviders/builtinGit.ts @@ -4,10 +4,12 @@ *--------------------------------------------------------------------------------------------*/ 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'; -export class BuiltinGitProvider implements IGit, vscode.Disposable { +export class BuiltinGitProvider extends Disposable implements IGit { get repositories(): Repository[] { return this._gitAPI.repositories as any[]; } @@ -25,25 +27,31 @@ export class BuiltinGitProvider implements IGit, vscode.Disposable { private _onDidPublish = new vscode.EventEmitter(); readonly onDidPublish: vscode.Event = this._onDidPublish.event; - private _gitAPI: GitAPI; - private _disposables: vscode.Disposable[]; + private readonly _gitAPI: GitAPI; private constructor(extension: vscode.Extension) { + super(); const gitExtension = extension.exports; try { this._gitAPI = gitExtension.getAPI(1); } catch (e) { // The git extension will throw if a git model cannot be found, i.e. if git is not installed. - vscode.window.showErrorMessage('Activating the Pull Requests and Issues extension failed. Please make sure you have git installed.'); + commands.setContext('gitNotInstalled', true); throw e; } - this._disposables = []; - this._disposables.push(this._gitAPI.onDidCloseRepository(e => this._onDidCloseRepository.fire(e as any))); - this._disposables.push(this._gitAPI.onDidOpenRepository(e => this._onDidOpenRepository.fire(e as any))); - this._disposables.push(this._gitAPI.onDidChangeState(e => this._onDidChangeState.fire(e))); - this._disposables.push(this._gitAPI.onDidPublish(e => this._onDidPublish.fire(e))); + 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 { @@ -63,8 +71,4 @@ export class BuiltinGitProvider implements IGit, vscode.Disposable { dispose: () => { } }; } - - dispose() { - this._disposables.forEach(disposable => disposable.dispose()); - } } diff --git a/src/gitProviders/vsls.ts b/src/gitProviders/vsls.ts index 987aa7d95d..a5eeee9c9d 100644 --- a/src/gitProviders/vsls.ts +++ b/src/gitProviders/vsls.ts @@ -4,10 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; + import { LiveShare } from 'vsls/vscode.js'; -import { API } from '../api/api'; 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. @@ -31,16 +33,15 @@ async function getVSLSApi() { return extensionApi.getApi(liveShareApiVersion); } -export class LiveShareManager implements vscode.Disposable { +export class LiveShareManager extends Disposable { private _liveShareAPI?: LiveShare; private _host?: VSLSHost; private _guest?: VSLSGuest; - private _localDisposables: vscode.Disposable[]; - private _globalDisposables: vscode.Disposable[]; + private readonly _localDisposables: vscode.Disposable[] = []; - constructor(private _api: API) { - this._localDisposables = []; - this._globalDisposables = []; + constructor(private readonly _api: API) { + super(); + this._register({ dispose: () => disposeAll(this._localDisposables) }); } /** @@ -55,9 +56,7 @@ export class LiveShareManager implements vscode.Disposable { return; } - this._globalDisposables.push( - this._liveShareAPI.onDidChangeSession(e => this._onDidChangeSession(e.session), this), - ); + this._register(this._liveShareAPI.onDidChangeSession(e => this._onDidChangeSession(e.session), this)); if (this._liveShareAPI!.session) { this._onDidChangeSession(this._liveShareAPI!.session); } @@ -66,7 +65,7 @@ export class LiveShareManager implements vscode.Disposable { } private async _onDidChangeSession(session: any) { - this._localDisposables.forEach(disposable => disposable.dispose()); + disposeAll(this._localDisposables); if (session.role === 1 /* Role.Host */) { this._host = new VSLSHost(this._liveShareAPI!, this._api); @@ -82,10 +81,4 @@ export class LiveShareManager implements vscode.Disposable { this._localDisposables.push(this._api.registerGitProvider(this._guest)); } } - - public dispose() { - this._liveShareAPI = undefined; - this._localDisposables.forEach(d => d.dispose()); - this._globalDisposables.forEach(d => d.dispose()); - } } diff --git a/src/gitProviders/vslsguest.ts b/src/gitProviders/vslsguest.ts index 271db4abd6..fd823781af 100644 --- a/src/gitProviders/vslsguest.ts +++ b/src/gitProviders/vslsguest.ts @@ -4,9 +4,11 @@ *--------------------------------------------------------------------------------------------*/ 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 { VSLS_GIT_PR_SESSION_NAME, VSLS_REPOSITORY_INITIALIZATION_NAME, @@ -14,10 +16,10 @@ import { VSLS_STATE_CHANGE_NOTIFY_NAME, } from '../constants'; -export class VSLSGuest implements IGit, vscode.Disposable { - private _onDidOpenRepository = new vscode.EventEmitter(); +export class VSLSGuest extends Disposable implements IGit { + private _onDidOpenRepository = this._register(new vscode.EventEmitter()); readonly onDidOpenRepository: vscode.Event = this._onDidOpenRepository.event; - private _onDidCloseRepository = new vscode.EventEmitter(); + private _onDidCloseRepository = this._register(new vscode.EventEmitter()); readonly onDidCloseRepository: vscode.Event = this._onDidCloseRepository.event; private _openRepositories: Repository[] = []; get repositories(): Repository[] { @@ -25,9 +27,8 @@ export class VSLSGuest implements IGit, vscode.Disposable { } private _sharedServiceProxy?: SharedServiceProxy; - private _disposables: vscode.Disposable[]; - constructor(private _liveShareAPI: LiveShare) { - this._disposables = []; + constructor(private readonly _liveShareAPI: LiveShare) { + super(); } public async initialize() { @@ -40,14 +41,10 @@ export class VSLSGuest implements IGit, vscode.Disposable { if (this._sharedServiceProxy.isServiceAvailable) { await this._refreshWorkspaces(true); } - this._disposables.push( - this._sharedServiceProxy.onDidChangeIsServiceAvailable(async e => { - await this._refreshWorkspaces(e); - }), - ); - this._disposables.push( - vscode.workspace.onDidChangeWorkspaceFolders(this._onDidChangeWorkspaceFolders.bind(this)), - ); + this._register(this._sharedServiceProxy.onDidChangeIsServiceAvailable(async e => { + await this._refreshWorkspaces(e); + })); + this._register(vscode.workspace.onDidChangeWorkspaceFolders(this._onDidChangeWorkspaceFolders.bind(this))); } private async _onDidChangeWorkspaceFolders(e: vscode.WorkspaceFoldersChangeEvent) { @@ -118,13 +115,7 @@ export class VSLSGuest implements IGit, vscode.Disposable { } public getRepository(folder: vscode.WorkspaceFolder): Repository { - return this._openRepositories.filter(repository => (repository as any).workspaceFolder === folder)[0]; - } - - public dispose() { - this._sharedServiceProxy = undefined; - this._disposables.forEach(d => d.dispose()); - this._disposables = []; + return this._openRepositories.filter(repository => (repository as (Repository & { workspaceFolder: vscode.WorkspaceFolder })).workspaceFolder === folder)[0]; } } @@ -132,6 +123,7 @@ class LiveShareRepositoryProxyHandler { constructor() { } get(obj: any, prop: any) { + // eslint-disable-next-line no-restricted-syntax if (prop in obj) { return obj[prop]; } @@ -157,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 1dc86ea9f3..0cbb235761 100644 --- a/src/gitProviders/vslshost.ts +++ b/src/gitProviders/vslshost.ts @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; + import { LiveShare, SharedService } from 'vsls/vscode.js'; import { API } from '../api/api'; +import { Disposable } from '../common/lifecycle'; import { VSLS_GIT_PR_SESSION_NAME, VSLS_REPOSITORY_INITIALIZATION_NAME, @@ -13,11 +15,10 @@ import { VSLS_STATE_CHANGE_NOTIFY_NAME, } from '../constants'; -export class VSLSHost implements vscode.Disposable { +export class VSLSHost extends Disposable { private _sharedService?: SharedService; - private _disposables: vscode.Disposable[]; - constructor(private _liveShareAPI: LiveShare, private _api: API) { - this._disposables = []; + constructor(private readonly _liveShareAPI: LiveShare, private _api: API) { + super(); } public async initialize() { @@ -45,15 +46,15 @@ export class VSLSHost implements vscode.Disposable { if (localRepository) { const commandArgs = args.slice(2); if (type === VSLS_REPOSITORY_INITIALIZATION_NAME) { - this._disposables.push( - localRepository.state.onDidChange(_ => { - this._sharedService!.notify(VSLS_STATE_CHANGE_NOTIFY_NAME, { - HEAD: localRepository.state.HEAD, - remotes: localRepository.state.remotes, - refs: localRepository.state.refs, - }); - }), - ); + + this._register(localRepository.state.onDidChange(_ => { + this._sharedService!.notify(VSLS_STATE_CHANGE_NOTIFY_NAME, { + HEAD: localRepository.state.HEAD, + remotes: localRepository.state.remotes, + refs: localRepository.state.refs, + }); + })); + return { HEAD: localRepository.state.HEAD, remotes: localRepository.state.remotes, @@ -78,9 +79,4 @@ export class VSLSHost implements vscode.Disposable { return null; } } - public dispose() { - this._disposables.forEach(d => d.dispose()); - this._sharedService = undefined; - this._disposables = []; - } } diff --git a/src/github/activityBarViewProvider.ts b/src/github/activityBarViewProvider.ts index c65b6b24a8..65deb964fa 100644 --- a/src/github/activityBarViewProvider.ts +++ b/src/github/activityBarViewProvider.ts @@ -4,23 +4,29 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { onDidUpdatePR, openPullRequestOnGitHub } from '../commands'; -import { IComment } from '../common/comment'; -import { ReviewEvent as CommonReviewEvent } from '../common/timelineEvent'; -import { dispose, formatError } from '../common/utils'; -import { getNonce, IRequestMessage, WebviewViewBase } from '../common/webview'; -import { ReviewManager } from '../view/reviewManager'; +import { openPullRequestOnGitHub } from '../commands'; import { FolderRepositoryManager } from './folderRepositoryManager'; -import { GithubItemStateEnum, ReviewEvent, ReviewState } from './interface'; -import { PullRequestModel } from './pullRequestModel'; +import { GithubItemStateEnum, IAccount, MergeMethod, ReviewEventEnum, ReviewState } from './interface'; +import { isCopilotOnMyBehalf, PullRequestModel } from './pullRequestModel'; import { getDefaultMergeMethod } from './pullRequestOverview'; -import { PullRequestView } from './pullRequestOverviewCommon'; +import { PullRequestReviewCommon, ReviewContext } from './pullRequestReviewCommon'; import { isInCodespaces, parseReviewers } from './utils'; +import { MergeArguments, PullRequest, ReviewType } from './views'; +import { IComment } from '../common/comment'; +import { emojify, ensureEmojis } from '../common/emoji'; +import { disposeAll } from '../common/lifecycle'; +import Logger from '../common/logger'; +import { CHECKOUT_DEFAULT_BRANCH, CHECKOUT_PULL_REQUEST_BASE_BRANCH, DELETE_BRANCH_AFTER_MERGE, POST_DONE, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; +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 readonly viewType = 'github:activePullRequest'; + public override readonly viewType = 'github:activePullRequest'; private _existingReviewers: ReviewState[] = []; - private _prChangeListener: vscode.Disposable | undefined; + private _updatingPromise: Promise | undefined; constructor( extensionUri: vscode.Uri, @@ -30,30 +36,24 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W ) { super(extensionUri); - onDidUpdatePR( - pr => { - if (pr) { - this._item.update(pr); - } - - this._postMessage({ - command: 'update-state', - state: this._item.state, - }); - }, - null, - this._disposables, - ); - - this._disposables.push(this._folderRepositoryManager.onDidMergePullRequest(_ => { - this._postMessage({ - command: 'update-state', - state: GithubItemStateEnum.Merged, - }); + this._register(vscode.commands.registerCommand('pr.readyForReview', async () => { + return this.readyForReviewCommand(); + })); + this._register(vscode.commands.registerCommand('pr.readyForReviewAndMerge', async (context: { mergeMethod: MergeMethod }) => { + return this.readyForReviewAndMergeCommand(context); + })); + 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._folderRepositoryManager.telemetry); + })); + this._register(vscode.commands.registerCommand('review.requestChangesOnDotCom', () => { + return openPullRequestOnGitHub(this._item, this._folderRepositoryManager.telemetry); })); } - public resolveWebviewView( + public override resolveWebviewView( webviewView: vscode.WebviewView, _context: vscode.WebviewViewResolveContext, _token: vscode.CancellationToken, @@ -64,7 +64,15 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W this.updatePullRequest(this._item); } - protected async _onDidReceiveMessage(message: IRequestMessage) { + private async updateBranch(message: IRequestMessage): Promise { + return PullRequestReviewCommon.updateBranch( + this.getReviewContext(), + message, + () => this.refresh() + ); + } + + protected override async _onDidReceiveMessage(message: IRequestMessage) { const result = await super._onDidReceiveMessage(message); if (result !== this.MESSAGE_UNHANDLED) { return; @@ -87,144 +95,224 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W case 'pr.readyForReview': return this.setReadyForReview(message); case 'pr.approve': - return this.approvePullRequest(message); + return this.approvePullRequestMessage(message); case 'pr.request-changes': - return this.requestChanges(message); + return this.requestChangesMessage(message); case 'pr.submit': - return this.submitReview(message); + 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': + return this.updateBranch(message); + case 'pr.re-request-review': + return this.reRequestReview(message); } } private async checkoutDefaultBranch(message: IRequestMessage): Promise { - try { - const defaultBranch = await this._folderRepositoryManager.getPullRequestRepositoryDefaultBranch(this._item); - const prBranch = this._folderRepositoryManager.repository.state.HEAD?.name; - await this._folderRepositoryManager.checkoutDefaultBranch(defaultBranch); - if (prBranch) { - await this._folderRepositoryManager.cleanupAfterPullRequest(prBranch, this._item); - } - } finally { - // Complete webview promise so that button becomes enabled again - this._replyMessage(message, {}); - } + return PullRequestReviewCommon.checkoutDefaultBranch(this.getReviewContext(), message); + } + + private reRequestReview(message: IRequestMessage): void { + return PullRequestReviewCommon.reRequestReview(this.getReviewContext(), message); } public async refresh(): Promise { - await this.updatePullRequest(this._item); + return vscode.window.withProgress({ location: { viewId: 'github:activePullRequest' } }, async () => { + await this._item.initializeReviewThreadCache(); + await this.updatePullRequest(this._item); + }); + } + + private getCurrentUserReviewState(reviewers: ReviewState[], currentUser: IAccount): string | undefined { + return PullRequestReviewCommon.getCurrentUserReviewState(reviewers, currentUser); + } + + /** + * Get the review context for helper functions + */ + private getReviewContext(): ReviewContext { + return { + item: this._item, + folderRepositoryManager: this._folderRepositoryManager, + existingReviewers: this._existingReviewers, + postMessage: (message: any) => this._postMessage(message), + replyMessage: (message: IRequestMessage, response: any) => this._replyMessage(message, response), + throwError: (message: IRequestMessage | undefined, error: string) => this._throwError(message, error), + getTimeline: () => this._item.getTimelineEvents() + }; } private _prDisposables: vscode.Disposable[] | undefined = undefined; private registerPrSpecificListeners(pullRequestModel: PullRequestModel) { if (this._prDisposables !== undefined) { - dispose(this._prDisposables); + disposeAll(this._prDisposables); } this._prDisposables = []; - this._prDisposables.push(pullRequestModel.onDidInvalidate(() => this.updatePullRequest(pullRequestModel))); + this._prDisposables.push(pullRequestModel.onDidChange(e => { + if ((e.state || e.comments || e.reviewers) && !this._updatingPromise) { + 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._prDisposables === undefined) || (pullRequestModel.number !== this._item.number)) { - this.registerPrSpecificListeners(pullRequestModel); + const isSamePullRequest = pullRequestModel.equals(this._item); + if (this._updatingPromise && isSamePullRequest) { + Logger.error('Already updating pull request view', PullRequestViewProvider.name); + return; + } else if (this._updatingPromise && !isSamePullRequest) { + this._item = pullRequestModel; + await this._updatingPromise; + } else { + this._item = 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() - ]) - .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}`, - ); - } - this._item = pullRequest; - if (!this._view) { - // If the there is no PR webview, then there is nothing else to update. - return; + try { + if (this._view && !this._view.visible) { + this._updatePendingVisibility?.dispose(); + this._updatePendingVisibility = this._view.onDidChangeVisibility(async () => { + this.updatePullRequest(pullRequestModel); + this._updatePendingVisibility?.dispose(); + }); + } + + if ((this._prDisposables === undefined) || (pullRequestModel.number !== this._item.number)) { + this.registerPrSpecificListeners(pullRequestModel); + } + this._item = pullRequestModel; + const updatingPromise = 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(), + pullRequestModel.getCoAuthors(), + this._folderRepositoryManager.mergeQueueMethodForBranch(pullRequestModel.base.ref, pullRequestModel.remote.owner, pullRequestModel.remote.repositoryName), + ensureEmojis(this._folderRepositoryManager.context), + ]); + const clearingPromise = updatingPromise.finally(() => { + if (this._updatingPromise === clearingPromise) { + this._updatingPromise = undefined; } + }); + this._updatingPromise = clearingPromise; + const [pullRequest, repositoryAccess, timelineEvents, requestedReviewers, branchInfo, defaultBranch, currentUser, viewerCanEdit, hasReviewDraft, coAuthors, mergeQueueMethod] = await updatingPromise; - this._view.title = `${pullRequest.title} #${pullRequestModel.number.toString()}`; - - 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, + if (!pullRequest) { + throw new Error( + `Fail to resolve Pull Request #${pullRequestModel.number} in ${pullRequestModel.remote.owner}/${pullRequestModel.remote.repositoryName}`, ); + } - const isCrossRepository = - pullRequest.base && - pullRequest.head && - !pullRequest.base.repositoryCloneUrl.equals(pullRequest.head.repositoryCloneUrl); - - const continueOnGitHub = isCrossRepository && isInCodespaces(); - - this._postMessage({ - command: 'pr.initialize', - pullrequest: { - number: pullRequest.number, - title: pullRequest.title, - url: pullRequest.html_url, - createdAt: pullRequest.createdAt, - body: pullRequest.body, - bodyHTML: pullRequest.bodyHTML, - labels: pullRequest.item.labels, - author: { - login: pullRequest.author.login, - name: pullRequest.author.name, - avatarUrl: pullRequest.userAvatar, - url: pullRequest.author.url, - }, - 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: { statuses: [] }, - events: [], - mergeMethodsAvailability, - defaultMergeMethod, - repositoryDefaultBranch: defaultBranch, - isIssue: false, - isAuthor: currentUser.login === pullRequest.author.login, - reviewers: this._existingReviewers, - continueOnGitHub, - isDarkTheme: vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark, - hasReviewDraft - }, - }); - }) - .catch(e => { - vscode.window.showErrorMessage(formatError(e)); + if (!this._item.equals(pullRequestModel)) { + return; + } + + this._item = pullRequest; + if (!this._view) { + // If the there is no PR webview, then there is nothing else to update. + return; + } + + 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 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 postDoneAction = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(POST_DONE, CHECKOUT_DEFAULT_BRANCH); + const doneCheckoutBranch = postDoneAction.startsWith(CHECKOUT_PULL_REQUEST_BASE_BRANCH) + ? pullRequest.base.ref + : defaultBranch; + + 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.owner}/${pullRequest.base.name}:${pullRequest.base.ref}`, + isRemoteHeadDeleted: pullRequest.isRemoteHeadDeleted, + isLocalHeadDeleted: !branchInfo, + head: pullRequest.head ? `${pullRequest.head.owner}/${pullRequest.head.name}:${pullRequest.head.ref}` : '', + canEdit: canEdit, + hasWritePermission, + mergeable: pullRequest.item.mergeable, + isDraft: pullRequest.isDraft, + status: null, + reviewRequirement: null, + canUpdateBranch: pullRequest.item.viewerCanUpdate, + events: timelineEvents, + mergeMethodsAvailability, + defaultMergeMethod, + mergeQueueMethod, + repositoryDefaultBranch: defaultBranch, + doneCheckoutBranch, + 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, + isCopilotOnMyBehalf: await isCopilotOnMyBehalf(pullRequest, currentUser, coAuthors) + }; + + this._postMessage({ + command: 'pr.initialize', + pullrequest: context, }); + + } catch (e) { + vscode.window.showErrorMessage(`Error updating active pull request view: ${formatError(e)}`); + } } private close(message: IRequestMessage): void { @@ -249,75 +337,64 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W }); } - private updateReviewers(review?: CommonReviewEvent): void { - if (review) { - const existingReviewer = this._existingReviewers.find( - reviewer => review.user.login === reviewer.reviewer.login, - ); - if (existingReviewer) { - existingReviewer.state = review.state; - } else { - this._existingReviewers.push({ - reviewer: review.user, - state: review.state, - }); - } - } - } - private approvePullRequest(message: IRequestMessage): void { - this._item.approve(message.args).then( - review => { - this.updateReviewers(review); - this._replyMessage(message, { - review: review, - reviewers: this._existingReviewers, - }); - //refresh the pr list as this one is approved - vscode.commands.executeCommand('pr.refreshList'); - }, - e => { - vscode.window.showErrorMessage(vscode.l10n.t('Approving pull request failed. {0}', formatError(e))); - - this._throwError(message, `${formatError(e)}`); - }, + private async doReviewCommand(context: { body: string }, reviewType: ReviewType, action: (body: string) => Promise) { + return PullRequestReviewCommon.doReviewCommand( + this.getReviewContext(), + context, + reviewType, + false, + action ); } - private requestChanges(message: IRequestMessage): void { - this._item.requestChanges(message.args).then( - review => { - this.updateReviewers(review); - this._replyMessage(message, { - review: review, - reviewers: this._existingReviewers, - }); - }, - e => { - vscode.window.showErrorMessage(vscode.l10n.t('Requesting changes failed. {0}', formatError(e))); - this._throwError(message, `${formatError(e)}`); - }, + private async doReviewMessage(message: IRequestMessage, action: (body) => Promise) { + return PullRequestReviewCommon.doReviewMessage( + this.getReviewContext(), + message, + false, + action ); } - private submitReview(message: IRequestMessage): void { - this._item.submitReview(ReviewEvent.Comment, message.args).then( - review => { - this.updateReviewers(review); - this._replyMessage(message, { - review: review, - reviewers: this._existingReviewers, - }); - }, - e => { - vscode.window.showErrorMessage(vscode.l10n.t('Submitting review failed. {0}', formatError(e))); - this._throwError(message, `${formatError(e)}`); - }, - ); + private approvePullRequest(body: string): Promise { + return this._item.approve(this._folderRepositoryManager.repository, body); + } + + private async approvePullRequestMessage(message: IRequestMessage): Promise { + await this.doReviewMessage(message, (body) => this.approvePullRequest(body)); + } + + private async approvePullRequestCommand(context: { body: string }): Promise { + await this.doReviewCommand(context, ReviewType.Approve, (body) => this.approvePullRequest(body)); + } + + private requestChanges(body: string): Promise { + return this._item.requestChanges(body); + } + + private async requestChangesCommand(context: { body: string }): Promise { + await this.doReviewCommand(context, ReviewType.RequestChanges, (body) => this.requestChanges(body)); + } + + private async requestChangesMessage(message: IRequestMessage): Promise { + await this.doReviewMessage(message, (body) => this.requestChanges(body)); + } + + private submitReview(body: string): Promise { + return this._item.submitReview(ReviewEventEnum.Comment, body); + } + + private submitReviewCommand(context: { body: string }) { + return this.doReviewCommand(context, ReviewType.Comment, (body) => this.submitReview(body)); + } + + private submitReviewMessage(message: IRequestMessage) { + return this.doReviewMessage(message, (body) => this.submitReview(body)); } private async deleteBranch(message: IRequestMessage) { - const result = await PullRequestView.deleteBranch(this._folderRepositoryManager, this._item); + const result = await PullRequestReviewCommon.deleteBranch(this._folderRepositoryManager, this._item); if (result.isReply) { this._replyMessage(message, result.message); } else { @@ -325,24 +402,23 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W } } - private setReadyForReview(message: IRequestMessage>): void { - this._item - .setReadyForReview() - .then(isDraft => { - vscode.commands.executeCommand('pr.refreshList'); - - this._replyMessage(message, { isDraft }); - }) - .catch(e => { - vscode.window.showErrorMessage(vscode.l10n.t('Unable to set PR ready for review. {0}', formatError(e))); - this._throwError(message, {}); - }); + private async setReadyForReview(message: IRequestMessage>): Promise { + return PullRequestReviewCommon.setReadyForReview(this.getReviewContext(), message); + } + + private async readyForReviewCommand(): Promise { + return PullRequestReviewCommon.readyForReviewCommand(this.getReviewContext()); + } + + private async readyForReviewAndMergeCommand(context: { mergeMethod: MergeMethod }): Promise { + return PullRequestReviewCommon.readyForReviewAndMergeCommand(this.getReviewContext(), context); } private async mergePullRequest( - message: IRequestMessage<{ title: string; description: string; method: 'merge' | 'squash' | 'rebase' }>, + message: IRequestMessage, ): Promise { const { title, description, method } = message.args; + const email = await this._folderRepositoryManager.getPreferredEmail(this._item); const yes = vscode.l10n.t('Yes'); const confirmation = await vscode.window.showInformationMessage( vscode.l10n.t('Merge this pull request?'), @@ -353,28 +429,32 @@ 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) - .then(result => { - vscode.commands.executeCommand('pr.refreshList'); - - if (!result.merged) { - vscode.window.showErrorMessage(vscode.l10n.t('Merging PR failed: {0}', result.message)); + if (!result.merged) { + vscode.window.showErrorMessage(vscode.l10n.t('Merging pull request failed: {0}', result?.message ?? '')); + } else { + // Check if auto-delete branch setting is enabled + const deleteBranchAfterMerge = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(DELETE_BRANCH_AFTER_MERGE, false); + if (deleteBranchAfterMerge) { + // Automatically delete the branch after successful merge + await PullRequestReviewCommon.autoDeleteBranchesAfterMerge(this._folderRepositoryManager, this._item); } + } - 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 9975684c53..23a253493c 100644 --- a/src/github/common.ts +++ b/src/github/common.ts @@ -4,6 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import * as OctokitRest from '@octokit/rest'; import { Endpoints } from '@octokit/types'; +import { DocumentNode } from 'graphql'; +import { FolderRepositoryManager } from './folderRepositoryManager'; +import { GitHubRepository } from './githubRepository'; +import { Repository } from '../api/api'; +import { GitHubRemote } from '../common/remote'; export namespace OctokitCommon { export type IssuesAssignParams = OctokitRest.RestEndpointMethodTypes['issues']['addAssignees']['parameters']; @@ -11,11 +16,37 @@ export namespace OctokitCommon { export type IssuesCreateResponseData = OctokitRest.RestEndpointMethodTypes['issues']['create']['response']['data']; export type IssuesListCommentsResponseData = OctokitRest.RestEndpointMethodTypes['issues']['listComments']['response']['data']; export type IssuesListEventsForTimelineResponseData = Endpoints['GET /repos/{owner}/{repo}/issues/{issue_number}/timeline']['response']['data']; - export type IssuesListEventsForTimelineResponseItemActor = IssuesListEventsForTimelineResponseData[0]['actor']; + export type IssuesListEventsForTimelineResponseItemActor = { + name?: string | null; + email?: string | null; + login: string; + id: number; + node_id: string; + avatar_url: string; + gravatar_id: string; + url: string; + html_url: string; + followers_url: string; + following_url: string; + gists_url: string; + starred_url: string; + subscriptions_url: string; + organizations_url: string; + repos_url: string; + events_url: string; + received_events_url: string; + type: string; + site_admin: boolean; + starred_at: string; + 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']; export type PullsGetResponseUser = Exclude; export type PullsListCommitsResponseData = Endpoints['GET /repos/{owner}/{repo}/pulls/{pull_number}/commits']['response']['data']; export type PullsListRequestedReviewersResponseData = Endpoints['GET /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers']['response']['data']; @@ -33,7 +64,6 @@ export namespace OctokitCommon { export type PullsListResponseItemHeadUser = PullsListResponseItemHead['user']; export type PullsListResponseItemHeadRepoOwner = PullsListResponseItemHead['repo']['owner']; export type PullsListReviewRequestsResponseTeamsItem = Endpoints['GET /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers']['response']['data']['teams'][0]; - export type PullsListResponseItemHeadRepoTemplateRepository = PullsListResponseItem['head']['repo']['template_repository']; export type PullsListCommitsResponseItem = Endpoints['GET /repos/{owner}/{repo}/pulls/{pull_number}/commits']['response']['data'][0]; export type ReposCompareCommitsResponseData = OctokitRest.RestEndpointMethodTypes['repos']['compareCommits']['response']['data']; export type ReposGetCombinedStatusForRefResponseStatusesItem = Endpoints['GET /repos/{owner}/{repo}/commits/{ref}/status']['response']['data']['statuses'][0]; @@ -44,4 +74,35 @@ export namespace OctokitCommon { export type ReposGetResponseOrganization = ReposGetResponseData['organization']; export type ReposListBranchesResponseData = Endpoints['GET /repos/{owner}/{repo}/branches']['response']['data']; export type SearchReposResponseItem = Endpoints['GET /search/repositories']['response']['data']['items'][0]; + export type CompareCommits = Endpoints['GET /repos/{owner}/{repo}/compare/{base}...{head}']['response']['data']; + export type Commit = CompareCommits['commits'][0]; + export type CommitFiles = CompareCommits['files'] + export type Notification = Endpoints['GET /notifications']['response']['data'][0]; + export type ListEventsForTimelineResponse = Endpoints['GET /repos/{owner}/{repo}/issues/{issue_number}/timeline']['response']['data'][0]; + export type ListWorkflowRunsForRepo = Endpoints['GET /repos/{owner}/{repo}/actions/runs']['response']['data']; + export type WorkflowRun = Endpoints['GET /repos/{owner}/{repo}/actions/runs']['response']['data']['workflow_runs'][0]; + export type WorkflowJob = Endpoints['GET /repos/{owner}/{repo}/actions/jobs/{job_id}']['response']['data']; + export type WorkflowJobs = Endpoints['GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs']['response']['data']; +} + +export function mergeQuerySchemaWithShared(sharedSchema: DocumentNode, schema: DocumentNode) { + const sharedSchemaDefinitions = sharedSchema.definitions; + const schemaDefinitions = schema.definitions; + const mergedDefinitions = schemaDefinitions.concat(sharedSchemaDefinitions); + return { + ...schema, + ...sharedSchema, + definitions: mergedDefinitions + }; +} + + +export interface RepoInfo { + owner: string; + repo: string; + baseRef: string; + remote: GitHubRemote; + repository: Repository; + ghRepository: GitHubRepository; + fm: FolderRepositoryManager; } diff --git a/src/github/conflictGuide.ts b/src/github/conflictGuide.ts new file mode 100644 index 0000000000..b6995fc541 --- /dev/null +++ b/src/github/conflictGuide.ts @@ -0,0 +1,168 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Change, Repository } from '../api/api'; +import { commands } from '../common/executeCommands'; +import { Disposable } from '../common/lifecycle'; +import { asPromise } from '../common/utils'; + +export class ConflictModel extends Disposable { + public readonly startingConflictsCount: number; + private _lastReportedRemainingCount: number; + private _onConflictCountChanged: vscode.EventEmitter = new vscode.EventEmitter(); + public readonly onConflictCountChanged: vscode.Event = this._onConflictCountChanged.event; // reports difference in number of conflicts + private _finishedCommit: vscode.EventEmitter = new vscode.EventEmitter(); + public readonly message: string; + + constructor(private readonly _repository: Repository, private readonly _upstream: string, private readonly _into: string, public readonly push: boolean) { + super(); + this.startingConflictsCount = this.remainingConflicts.length; + this._lastReportedRemainingCount = this.startingConflictsCount; + this._repository.inputBox.value = this.message = `Merge branch '${this._upstream}' into ${this._into}`; + this._watchForRemainingConflictsChange(); + } + + private _watchForRemainingConflictsChange() { + this._register(vscode.window.tabGroups.onDidChangeTabs(async (e) => { + if (e.closed.length > 0) { + await this._repository.status(); + this._reportProgress(); + } + })); + this._register(this._repository.state.onDidChange(async () => { + this._reportProgress(); + })); + } + + private _reportProgress() { + if (this._lastReportedRemainingCount === 0) { + // Already done. + return; + } + const remainingCount = this.remainingConflicts.length; + if (this._lastReportedRemainingCount !== remainingCount) { + this._onConflictCountChanged.fire(this._lastReportedRemainingCount - remainingCount); + this._lastReportedRemainingCount = remainingCount; + } + if (this._lastReportedRemainingCount === 0) { + this.listenForCommit(); + } + } + + private async listenForCommit() { + let localDisposable: vscode.Disposable | undefined; + const result = await new Promise(resolve => { + const startingCommit = this._repository.state.HEAD?.commit; + localDisposable = this._register(this._repository.state.onDidChange(() => { + if (this._repository.state.HEAD?.commit !== startingCommit && this._repository.state.indexChanges.length === 0 && this._repository.state.mergeChanges.length === 0) { + resolve(true); + } + })); + }); + + localDisposable?.dispose(); + if (result && this.push) { + this._repository.push(); + } + this._finishedCommit.fire(result); + } + + get remainingConflicts(): Change[] { + return this._repository.state.mergeChanges; + } + + private async closeMergeEditors(): Promise { + for (const group of vscode.window.tabGroups.all) { + for (const tab of group.tabs) { + if (tab.input instanceof vscode.TabInputTextMerge) { + vscode.window.tabGroups.close(tab); + } + } + } + } + + public async abort(): Promise { + this._repository.inputBox.value = ''; + // set up an event to listen for when we are all out of merge changes before closing the merge editors. + // Just waiting for the merge doesn't cut it + // Even with this, we still need to wait 1 second, and then it still might say there are conflicts. Why is this? + const disposable = this._register(this._repository.state.onDidChange(async () => { + if (this._repository.state.mergeChanges.length === 0) { + await new Promise(resolve => setTimeout(resolve, 1000)); + this.closeMergeEditors(); + disposable.dispose(); + } + })); + await this._repository.mergeAbort(); + this._finishedCommit.fire(false); + } + + private async first(): Promise { + if (this.remainingConflicts.length === 0) { + return; + } + await commands.focusView('workbench.scm'); + this._reportProgress(); + await Promise.all(this.remainingConflicts.map(conflict => commands.executeCommand('git.openMergeEditor', conflict.uri))); + } + + public static async begin(repository: Repository, upstream: string, into: string, push: boolean): Promise { + const model = new ConflictModel(repository, upstream, into, push); + if (model.remainingConflicts.length === 0) { + return undefined; + } + model._register(new ConflictNotification(model, repository)); + model.first(); + return model; + } + + public finished(): Promise { + return asPromise(this._finishedCommit.event); + } +} + +class ConflictNotification extends Disposable { + + constructor(private readonly _conflictModel: ConflictModel, private readonly _repository: Repository) { + super(); + vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, cancellable: true }, async (progress, token) => { + const report = (increment: number) => { + progress.report({ message: vscode.l10n.t('Use the Source Control view to resolve conflicts, {0} of {0} remaining', this._conflictModel.remainingConflicts.length, this._conflictModel.startingConflictsCount), increment }); + }; + report(0); + return new Promise((resolve) => { + this._register(this._conflictModel.onConflictCountChanged((conflictsChangedBy) => { + const increment = conflictsChangedBy * (100 / this._conflictModel.startingConflictsCount); + report(increment); + if (this._conflictModel.remainingConflicts.length === 0) { + resolve(true); + } + })); + this._register(token.onCancellationRequested(() => { + this._conflictModel.abort(); + resolve(false); + })); + }); + }).then(async (result) => { + if (result) { + const commit = vscode.l10n.t('Commit'); + const cancel = vscode.l10n.t('Abort Merge'); + let message: string; + if (this._conflictModel.push) { + message = vscode.l10n.t('All conflicts resolved. Commit and push the resolution to continue.'); + } else { + message = vscode.l10n.t('All conflicts resolved. Commit the resolution to continue.'); + } + const result = await vscode.window.showInformationMessage(message, commit, cancel); + if (result === commit) { + await this._repository.commit(this._conflictModel.message); + } else if (result === cancel) { + await this._conflictModel.abort(); + } + } + }); + } +} \ No newline at end of file diff --git a/src/github/conflictResolutionCoordinator.ts b/src/github/conflictResolutionCoordinator.ts new file mode 100644 index 0000000000..a0bb0e8bbc --- /dev/null +++ b/src/github/conflictResolutionCoordinator.ts @@ -0,0 +1,202 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +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'; +import { Schemes } from '../common/uri'; +import { asPromise } from '../common/utils'; +import { ConflictResolutionTreeView } from '../view/conflictResolution/conflictResolutionTreeView'; +import { GitHubContentProvider } from '../view/gitHubContentProvider'; + +interface MergeEditorInputData { uri: vscode.Uri; title?: string; detail?: string; description?: string } +const ORIGINAL_FILE = + `<<<<<<< HEAD:file.txt +A +======= +B +>>>>>>> fa7472b59e45e5b86c985a175aac33af7a8322a3:file.txt`; + +class MergeOutputProvider extends Disposable implements vscode.FileSystemProvider { + private _createTime: number = 0; + private _modifiedTimes: Map = new Map(); + private _mergedFiles: Map = new Map(); + get mergeResults(): Map { + return this._mergedFiles; + } + private _onDidChangeFile = this._register(new vscode.EventEmitter()); + onDidChangeFile: vscode.Event = this._onDidChangeFile.event; + + constructor(private readonly _conflictResolutionModel: ConflictResolutionModel) { + super(); + this._createTime = new Date().getTime(); + } + watch(_uri: vscode.Uri, _options: { readonly recursive: boolean; readonly excludes: readonly string[]; }): vscode.Disposable { + // no-op because no one else can modify this file. + return { + dispose: () => { } + }; + } + stat(uri: vscode.Uri): vscode.FileStat { + return { + type: vscode.FileType.File, + ctime: this._createTime, + mtime: this._modifiedTimes.get(uri.path) ?? 0, + size: this._mergedFiles.get(uri.path)?.length ?? 0, + }; + } + readDirectory(_uri: vscode.Uri): [string, vscode.FileType][] { + throw new Error('Method not implemented.'); + } + createDirectory(_uri: vscode.Uri): void { + throw new Error('Method not implemented.'); + } + async readFile(uri: vscode.Uri): Promise { + if (!this._mergedFiles.has(uri.path)) { + // If the result file contains a conflict marker then the merge editor will automagically compute the merge result. + this.updateFile(uri.path, buffer.Buffer.from(ORIGINAL_FILE)); + } + return this._mergedFiles.get(uri.path)!; + } + writeFile(uri: vscode.Uri, content: Uint8Array, _options: { readonly create: boolean; readonly overwrite: boolean; }): void { + this.updateFile(uri.path, content); + } + delete(_uri: vscode.Uri, _options: { readonly recursive: boolean; }): void { + throw new Error('Method not implemented.'); + } + rename(_oldUri: vscode.Uri, _newUri: vscode.Uri, _options: { readonly overwrite: boolean; }): void { + throw new Error('Method not implemented.'); + } + + private updateFile(file: string, contents: Uint8Array): void { + this._mergedFiles.set(file, contents); + this._modifiedTimes.set(file, new Date().getTime()); + } + + clear(): void { + const fileEvents: vscode.FileChangeEvent[] = []; + for (const file of this._mergedFiles.keys()) { + fileEvents.push({ uri: vscode.Uri.from({ scheme: this._conflictResolutionModel.mergeScheme, path: file }), type: vscode.FileChangeType.Changed }); + this.updateFile(file, buffer.Buffer.from(ORIGINAL_FILE)); + } + this._onDidChangeFile.fire(fileEvents); + } + + override dispose(): void { + super.dispose(); + this._mergedFiles.clear(); + } +} + +export class ConflictResolutionCoordinator extends Disposable { + private readonly _mergeOutputProvider: MergeOutputProvider; + + constructor(private readonly _telemetry: ITelemetry, private readonly _conflictResolutionModel: ConflictResolutionModel, private readonly _githubRepositories: GitHubRepository[]) { + super(); + this._mergeOutputProvider = this._register(new MergeOutputProvider(this._conflictResolutionModel)); + } + + private async openConflict(conflict: Conflict) { + const prHeadUri = this._conflictResolutionModel.prHeadUri(conflict); + const baseUri = this._conflictResolutionModel.baseUri(conflict); + + const prHead: MergeEditorInputData = { uri: prHeadUri, title: vscode.l10n.t('Pull Request Head') }; + const base: MergeEditorInputData = { uri: baseUri, title: vscode.l10n.t('{0} Branch', this._conflictResolutionModel.prBaseBranchName) }; + + const mergeBaseUri: vscode.Uri = this._conflictResolutionModel.mergeBaseUri(conflict); + const mergeOutput = this._conflictResolutionModel.mergeOutputUri(conflict); + const options = { + base: mergeBaseUri, + input1: prHead, + input2: base, + output: mergeOutput + }; + await commands.executeCommand( + '_open.mergeEditor', + options + ); + } + + private register(): void { + this._register(vscode.workspace.registerFileSystemProvider(Schemes.GithubPr, new GitHubContentProvider(this._githubRepositories), { isReadonly: true })); + this._register(vscode.workspace.registerFileSystemProvider(this._conflictResolutionModel.mergeScheme, this._mergeOutputProvider)); + this._register(vscode.commands.registerCommand('pr.resolveConflict', (conflict: Conflict) => { + return this.openConflict(conflict); + })); + this._register(vscode.commands.registerCommand('pr.acceptMerge', async (uri: vscode.Uri | unknown) => { + return this.acceptMerge(uri); + })); + this._register(vscode.commands.registerCommand('pr.exitConflictResolutionMode', async () => { + const exit = vscode.l10n.t('Exit and lose changes'); + const result = await vscode.window.showWarningMessage(vscode.l10n.t('Are you sure you want to exit conflict resolution mode? All changes will be lost.'), { modal: true }, exit); + if (result === exit) { + return this.exitConflictResolutionMode(false); + } + })); + this._register(vscode.commands.registerCommand('pr.completeMerge', async () => { + return this.exitConflictResolutionMode(true); + })); + this._register(new ConflictResolutionTreeView(this._conflictResolutionModel)); + } + + private async acceptMerge(uri: vscode.Uri | unknown): Promise { + if (!(uri instanceof vscode.Uri)) { + return; + } + const { activeTab } = vscode.window.tabGroups.activeTabGroup; + if (!activeTab || !(activeTab.input instanceof vscode.TabInputTextMerge)) { + return; + } + + const result = await commands.executeCommand('mergeEditor.acceptMerge') as { successful: boolean }; + if (result.successful) { + const contents = new TextDecoder().decode(this._mergeOutputProvider.mergeResults.get(uri.path)!); + this._conflictResolutionModel.addResolution(uri.path.substring(1), contents); + } + } + + async enterConflictResolutionMode(): Promise { + /* __GDPR__ + "pr.conflictResolution.start" : {} + */ + this._telemetry.sendTelemetryEvent('pr.conflictResolution.start'); + await commands.setContext(contexts.RESOLVING_CONFLICTS, true); + this.register(); + this.openConflict(this._conflictResolutionModel.startingConflicts[0]); + } + + private _onExitConflictResolutionMode = new vscode.EventEmitter(); + async exitConflictResolutionMode(allConflictsResolved: boolean): Promise { + /* __GDPR__ + "pr.conflictResolution.exit" : { + "allConflictsResolved" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this._telemetry.sendTelemetryEvent('pr.conflictResolution.exit', { allConflictsResolved: allConflictsResolved.toString() }); + + this._mergeOutputProvider.clear(); + await commands.setContext(contexts.RESOLVING_CONFLICTS, false); + const tabsToClose: vscode.Tab[] = []; + for (const group of vscode.window.tabGroups.all) { + for (const tab of group.tabs) { + if ((tab.input instanceof vscode.TabInputTextMerge) && (tab.input.result.scheme === this._conflictResolutionModel.mergeScheme)) { + tabsToClose.push(tab); + } + } + } + await vscode.window.tabGroups.close(tabsToClose); + this._onExitConflictResolutionMode.fire(allConflictsResolved); + this.dispose(); + } + + async enterConflictResolutionAndWaitForExit(): Promise { + await this.enterConflictResolutionMode(); + return asPromise(this._onExitConflictResolutionMode.event); + } +} \ No newline at end of file diff --git a/src/github/conflictResolutionModel.ts b/src/github/conflictResolutionModel.ts new file mode 100644 index 0000000000..01abccd6c8 --- /dev/null +++ b/src/github/conflictResolutionModel.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Schemes, toGitHubUri } from '../common/uri'; + +export interface Conflict { + prHeadFilePath: string; + contentsConflict: boolean; + filePathConflict: boolean; + modeConflict: boolean; +} + +export interface ResolvedConflict { + prHeadFilePath: string; + resolvedContents?: string; + // The other two fields can be added later. To begin with, we only support resolving the contents. + // resolvedFilePath: string; + // resolvedMode: string; +} + +export class ConflictResolutionModel { + private _startingConflicts: Map = new Map(); + private readonly _resolvedConflicts: Map = new Map(); + private readonly _onAddedResolution: vscode.EventEmitter = new vscode.EventEmitter(); + public readonly onAddedResolution: vscode.Event = this._onAddedResolution.event; + public readonly mergeScheme = `${Schemes.MergeOutput}-${new Date().getTime()}`; + + constructor(public readonly startingConflicts: Conflict[], public readonly repositoryName: string, public readonly prBaseOwner: string, + public readonly latestPrBaseSha: string, + public readonly prHeadOwner: string, public readonly prHeadBranchName: string, + public readonly prBaseBranchName: string, public readonly prMergeBaseRef: string) { + + for (const conflict of startingConflicts) { + this._startingConflicts.set(conflict.prHeadFilePath, conflict); + } + } + + isResolvable(): boolean { + return Array.from(this._startingConflicts.values()).every(conflict => { + return !conflict.filePathConflict && !conflict.modeConflict; + }); + } + + addResolution(filePath: string, contents: string): void { + this._resolvedConflicts.set(filePath, { prHeadFilePath: filePath, resolvedContents: contents }); + this._onAddedResolution.fire(); + } + + isResolved(filePath: string): boolean { + if (!this._startingConflicts.has(filePath)) { + throw new Error('Not a conflict file'); + } + return this._resolvedConflicts.has(filePath); + } + + get areAllConflictsResolved(): boolean { + return this._resolvedConflicts.size === this._startingConflicts.size; + } + + get resolvedConflicts(): Map { + if (this._resolvedConflicts.size !== this._startingConflicts.size) { + throw new Error('Not all conflicts have been resolved'); + } + return this._resolvedConflicts; + } + + public mergeOutputUri(conflict: Conflict) { + return vscode.Uri.parse(`${this.mergeScheme}:/${conflict.prHeadFilePath}`); + } + + public mergeBaseUri(conflict: { prHeadFilePath: string }): vscode.Uri { + const fileUri = vscode.Uri.file(conflict.prHeadFilePath); + return toGitHubUri(fileUri, Schemes.GithubPr, { fileName: conflict.prHeadFilePath, branch: this.prMergeBaseRef, owner: this.prBaseOwner }); + } + + public baseUri(conflict: Conflict): vscode.Uri { + const fileUri = vscode.Uri.file(conflict.prHeadFilePath); + return toGitHubUri(fileUri, Schemes.GithubPr, { fileName: conflict.prHeadFilePath, branch: this.latestPrBaseSha, owner: this.prBaseOwner }); + } + + public prHeadUri(conflict: Conflict): vscode.Uri { + const fileUri = vscode.Uri.file(conflict.prHeadFilePath); + return toGitHubUri(fileUri, Schemes.GithubPr, { fileName: conflict.prHeadFilePath, branch: this.prHeadBranchName, owner: this.prHeadOwner }); + } +} \ No newline at end of file diff --git a/src/github/copilotApi.ts b/src/github/copilotApi.ts new file mode 100644 index 0000000000..7b172e3cae --- /dev/null +++ b/src/github/copilotApi.ts @@ -0,0 +1,136 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import fetch from 'cross-fetch'; +import * as vscode from 'vscode'; +import { CredentialStore } from './credentials'; +import { LoggingOctokit } from './loggingOctokit'; +import { hasEnterpriseUri } from './utils'; +import { AuthProvider } from '../common/authentication'; +import Logger from '../common/logger'; +import { ITelemetry } from '../common/telemetry'; + +/** + * 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'; + + constructor( + private octokit: LoggingOctokit, + private token: string, + private telemetry: ITelemetry + ) { } + + private get baseUrl(): string { + return 'https://api.githubcopilot.com'; + } + + private async makeApiCallFullUrl(url: string, init: RequestInit): Promise { + const apiCall = () => fetch(url, init); + return this.octokit.call(apiCall); + } + private async makeApiCall(api: string, init: RequestInit): Promise { + return this.makeApiCallFullUrl(`${this.baseUrl}${api}`, init); + } + + public async getAllSessions(pullRequestId: number | undefined): Promise { + const response = await this.makeApiCall( + pullRequestId + ? `/agents/sessions/resource/pull/${pullRequestId}` + : `/agents/sessions`, + { + headers: { + Authorization: `Bearer ${this.token}`, + Accept: 'application/json', + }, + }); + if (!response.ok) { + await this.handleApiError(response, 'getAllSessions'); + } + const sessions = await response.json(); + return sessions.sessions; + } + + private async handleApiError(response: Response, action: string): Promise { + let errorBody: string | undefined = undefined; + try { + errorBody = await response.text(); + } catch (e) { /* ignore */ } + const msg = `'${action}' failed with ${response.statusText} ${errorBody ? `: ${errorBody}` : ''}`; + Logger.error(msg, CopilotApi.ID); + + /* __GDPR__ + "remoteAgent.apiError" : { + "action" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "status" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "body" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this.telemetry.sendTelemetryErrorEvent('remoteAgent.apiError', { + action, + status: response.status.toString(), + body: errorBody || '', + }); + + throw new Error(msg); + } +} + + +export interface SessionInfo { + id: string; + name: string; + user_id: number; + agent_id: number; + logs: string; + logs_blob_id: string; + state: 'completed' | 'in_progress' | 'failed' | 'queued'; + owner_id: number; + repo_id: number; + resource_type: string; + resource_id: number; + last_updated_at: string; + created_at: string; + completed_at: string; + event_type: string; + workflow_run_id: number; + premium_requests: number; + error: string | null; +} + +export async function getCopilotApi(credentialStore: CredentialStore, telemetry: ITelemetry, authProvider?: AuthProvider): Promise { + if (!authProvider) { + if (credentialStore.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { + authProvider = AuthProvider.githubEnterprise; + } else if (credentialStore.isAuthenticated(AuthProvider.github)) { + authProvider = AuthProvider.github; + } else { + return; + } + } + + const github = credentialStore.getHub(authProvider); + if (!github || !github.octokit) { + return; + } + + const { token } = await github.octokit.api.auth() as { token: string }; + return new CopilotApi(github.octokit, token, telemetry); +} \ No newline at end of file diff --git a/src/github/copilotPrWatcher.ts b/src/github/copilotPrWatcher.ts new file mode 100644 index 0000000000..429fa0c561 --- /dev/null +++ b/src/github/copilotPrWatcher.ts @@ -0,0 +1,303 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { DEV_MODE, PR_SETTINGS_NAMESPACE, QUERIES } from '../common/settingKeys'; +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; +} + +export class CopilotStateModel extends Disposable { + public static ID = 'CopilotStateModel'; + private _isInitialized = false; + private readonly _states: Map = new Map(); + private readonly _showNotification: Set = new Set(); + private readonly _onDidChangeStates = this._register(new vscode.EventEmitter()); + readonly onDidChangeCopilotStates = this._onDidChangeStates.event; + private readonly _onDidChangeNotifications = this._register(new vscode.EventEmitter()); + readonly onDidChangeCopilotNotifications = this._onDidChangeNotifications.event; + + makeKey(owner: string, repo: string, prNumber?: number): string { + if (prNumber === undefined) { + return `${owner}/${repo}`; + } + return `${owner}/${repo}#${prNumber}`; + } + + deleteKey(key: string): void { + if (this._states.has(key)) { + const item = this._states.get(key)!; + this._states.delete(key); + if (this._showNotification.has(key)) { + this._showNotification.delete(key); + this._onDidChangeNotifications.fire([item.item]); + } + this._onDidChangeStates.fire(); + } + } + + set(statuses: CodingAgentPRAndStatus[]): void { + const changedModels: PullRequestModel[] = []; + const changedKeys: string[] = []; + 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, status }); + if (status === CopilotPRStatus.Started) { + continue; + } + changedModels.push(item); + changedKeys.push(key); + } + if (changedModels.length > 0) { + if (this._isInitialized) { + changedKeys.forEach(key => this._showNotification.add(key)); + this._onDidChangeNotifications.fire(changedModels); + } + this._onDidChangeStates.fire(); + } + } + + get(owner: string, repo: string, prNumber: number): CopilotPRStatus { + const key = this.makeKey(owner, repo, prNumber); + return this._states.get(key)?.status ?? CopilotPRStatus.None; + } + + keys(): string[] { + return Array.from(this._states.keys()); + } + + clearNotification(owner: string, repo: string, prNumber: number): void { + const key = this.makeKey(owner, repo, prNumber); + if (this._showNotification.has(key)) { + this._showNotification.delete(key); + const item = this._states.get(key)?.item; + if (item) { + this._onDidChangeNotifications.fire([item]); + } + } + } + + clearAllNotifications(owner?: string, repo?: string): void { + if (this._showNotification.size > 0) { + const items: PullRequestModel[] = []; + + // If owner and repo are specified, only clear notifications for that repo + if (owner && repo) { + const keysToRemove: string[] = []; + const prefix = `${this.makeKey(owner, repo)}#`; + for (const key of this._showNotification.keys()) { + if (key.startsWith(prefix)) { + const item = this._states.get(key)?.item; + if (item) { + items.push(item); + } + keysToRemove.push(key); + } + } + keysToRemove.forEach(key => this._showNotification.delete(key)); + } else { + // Clear all notifications + for (const key of this._showNotification.keys()) { + const item = this._states.get(key)?.item; + if (item) { + items.push(item); + } + } + this._showNotification.clear(); + } + + if (items.length > 0) { + this._onDidChangeNotifications.fire(items); + } + } + } + + get notifications(): ReadonlySet { + return this._showNotification; + } + + getNotificationsCount(owner: string, repo: string): number { + let total = 0; + const partialKey = `${this.makeKey(owner, repo)}#`; + for (const state of this._showNotification.values()) { + if (state.startsWith(partialKey)) { + total++; + } + } + return total; + } + + setInitialized() { + this._isInitialized = true; + } + + get isInitialized(): boolean { + return this._isInitialized; + } + + getCounts(owner: string, repo: string): { total: number; inProgress: number; error: number } { + let inProgressCount = 0; + let errorCount = 0; + + for (const state of this._states.values()) { + if (state.item.remote.owner !== owner || state.item.remote.repositoryName !== repo) { + continue; + } + if (state.status === CopilotPRStatus.Started) { + inProgressCount++; + } else if (state.status === CopilotPRStatus.Failed) { + errorCount++; + } + } + + return { + total: this._states.size, + inProgress: inProgressCount, + error: errorCount + }; + } + + get all(): CodingAgentPRAndStatus[] { + return Array.from(this._states.values()); + } +} + +export class CopilotPRWatcher extends Disposable { + 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(); + this._initialize(); + }); + } else { + this._initialize(); + } + } + + private _initialize() { + this._prsTreeModel.refreshCopilotStateChanges(true); + this._pollForChanges(); + 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) { + 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 { + for (const pr of e) { + if (pr.model instanceof PullRequestModel) { + this._updateSingleState(pr.model); + } + } + } + } + })); + this._register(PullRequestOverviewPanel.onVisible(e => this._model.clearNotification(e.remote.owner, e.remote.repositoryName, e.number))); + + this._register(vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${QUERIES}`)) { + this._pollForChanges(); + } + })); + this._register(vscode.window.onDidChangeWindowState(e => { + if (e.active || e.focused) { + // If we are becoming active/focused, and it's been more than the poll interval since the last poll, poll now + if (Date.now() - this._lastPollTime > this._pollInterval) { + this._pollForChanges(); + } + } + })); + this._register({ dispose: () => this._pollTimeout && clearTimeout(this._pollTimeout) }); + } + + private get _pollInterval(): number { + if (vscode.window.state.active || vscode.window.state.focused) { + return 60 * 1000 * 2; // Poll every 2 minutes + } + return 60 * 1000 * 5; // Poll every 5 minutes + } + + private _pollTimeout: NodeJS.Timeout | undefined; + private _lastPollTime = 0; + private async _pollForChanges(): Promise { + // Skip polling if dev mode is enabled + const devMode = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(DEV_MODE, false); + if (devMode) { + return; + } + + if (this._pollTimeout) { + clearTimeout(this._pollTimeout); + this._pollTimeout = undefined; + } + this._lastPollTime = Date.now(); + const shouldContinue = await this._prsTreeModel.refreshCopilotStateChanges(true); + + if (shouldContinue) { + this._pollTimeout = setTimeout(() => { + this._pollForChanges(); + }, this._pollInterval); + } + } + + private async _updateSingleState(pr: PullRequestModel): Promise { + const changes: CodingAgentPRAndStatus[] = []; + + const copilotEvents = await pr.getCopilotTimelineEvents(false, !this._model.isInitialized); + let latestEvent = copilotEventToStatus(copilotEvents[copilotEvents.length - 1]); + if (latestEvent === CopilotPRStatus.None) { + if (!COPILOT_ACCOUNTS[pr.author.login]) { + return; + } + 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({ item: pr, status: latestEvent }); + } + this._model.set(changes); + } + +} \ No newline at end of file diff --git a/src/github/copilotRemoteAgent.ts b/src/github/copilotRemoteAgent.ts new file mode 100644 index 0000000000..bc2c7ce9e4 --- /dev/null +++ b/src/github/copilotRemoteAgent.ts @@ -0,0 +1,171 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { RepoInfo } from './common'; +import { CopilotApi, getCopilotApi } from './copilotApi'; +import { CopilotPRWatcher } from './copilotPrWatcher'; + +import { CredentialStore } from './credentials'; +import { FolderRepositoryManager } from './folderRepositoryManager'; +import { GitHubRepository } from './githubRepository'; +import { RepositoriesManager } from './repositoriesManager'; +import { CopilotRemoteAgentConfig } from '../common/config'; +import { COPILOT_CLOUD_AGENT, COPILOT_LOGINS } from '../common/copilot'; +import { Disposable } from '../common/lifecycle'; +import Logger from '../common/logger'; +import { GitHubRemote } from '../common/remote'; +import { ITelemetry } from '../common/telemetry'; +import { PrsTreeModel } from '../view/prsTreeModel'; + +const PREFERRED_GITHUB_CODING_AGENT_REMOTE_WORKSPACE_KEY = 'PREFERRED_GITHUB_CODING_AGENT_REMOTE'; + +export namespace SessionIdForPr { + + const prefix = 'pull-session-by-index'; + + export function getResource(prNumber: number, sessionIndex: number): vscode.Uri { + return vscode.Uri.from({ + scheme: COPILOT_CLOUD_AGENT, path: `/${prefix}-${prNumber}-${sessionIndex}`, + }); + } + + 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), + sessionIndex: parseInt(match[2], 10) + }; + } + return undefined; + } +} + +export class CopilotRemoteAgentManager extends Disposable { + public static ID = 'CopilotRemoteAgentManager'; + private _isAssignable: boolean | undefined; + + constructor( + private credentialStore: CredentialStore, + public repositoriesManager: RepositoriesManager, + private telemetry: ITelemetry, + private context: vscode.ExtensionContext, + private readonly prsTreeModel: PrsTreeModel, + ) { + super(); + + this._register(new CopilotPRWatcher(this.repositoriesManager, this.prsTreeModel)); + } + private _copilotApiPromise: Promise | undefined; + private get copilotApi(): Promise { + if (!this._copilotApiPromise) { + this._copilotApiPromise = this.initializeCopilotApi(); + } + return this._copilotApiPromise; + } + + private async initializeCopilotApi(): Promise { + return await getCopilotApi(this.credentialStore, this.telemetry); + } + + 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 setCachedResult(false); + } + + const { fm } = repoInfo; + + try { + // Ensure assignable users are loaded + await fm.getAssignableUsers(); + const allAssignableUsers = fm.getAllAssignableUsers(); + + if (!allAssignableUsers) { + return setCachedResult(false); + } + return setCachedResult(allAssignableUsers.some(user => COPILOT_LOGINS.includes(user.login))); + } catch (error) { + // If there's an error fetching assignable users, assume not assignable + return setCachedResult(false); + } + } + + async isAvailable(): Promise { + // Check if the manager is enabled, copilot API is available, and it's assignable + if (!CopilotRemoteAgentConfig.getEnabled()) { + return false; + } + + if (!this.credentialStore.isAnyAuthenticated()) { + // If not signed in, then we optimistically say it's available. + return true; + } + + const repoInfo = await this.repoInfo(); + if (!repoInfo) { + return false; + } + + const copilotApi = await this.copilotApi; + if (!copilotApi) { + return false; + } + + return await this.isAssignable(); + } + + private firstFolderManager(): FolderRepositoryManager | undefined { + if (!this.repositoriesManager.folderManagers.length) { + return; + } + return this.repositoriesManager.folderManagers[0]; + } + + async repoInfo(fm?: FolderRepositoryManager): Promise { + fm = fm || this.firstFolderManager(); + const repository = fm?.repository; + const ghRepository = fm?.gitHubRepositories.find(repo => repo.remote instanceof GitHubRemote) as GitHubRepository | undefined; + if (!fm || !repository || !ghRepository) { + return; + } + const baseRef = repository.state.HEAD?.name; // TODO: Consider edge cases + const preferredRemoteName = this.context.workspaceState.get(PREFERRED_GITHUB_CODING_AGENT_REMOTE_WORKSPACE_KEY); + const ghRemotes = await fm.getGitHubRemotes(); + if (!ghRemotes || ghRemotes.length === 0) { + return; + } + + const remote = + preferredRemoteName + ? ghRemotes.find(remote => remote.remoteName === preferredRemoteName) // Cached preferred value + : (ghRemotes.find(remote => remote.remoteName === 'origin') || ghRemotes[0]); // Fallback to the first remote + + if (!remote) { + Logger.error(`no valid remotes for coding agent`, CopilotRemoteAgentManager.ID); + // Clear preference, something is wrong + this.context.workspaceState.update(PREFERRED_GITHUB_CODING_AGENT_REMOTE_WORKSPACE_KEY, undefined); + return; + } + + // Extract repo data from target remote + const { owner, repositoryName: repo } = remote; + if (!owner || !repo || !baseRef || !repository) { + return; + } + return { owner, repo, baseRef, remote, repository, ghRepository, fm }; + } + +} \ No newline at end of file diff --git a/src/github/createPRLinkProvider.ts b/src/github/createPRLinkProvider.ts index 9c689c9e77..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; @@ -24,11 +24,9 @@ export class GitHubCreatePullRequestLinkProvider implements vscode.TerminalLinkP .get<'vscode' | 'github' | undefined>(TERMINAL_LINK_HANDLER); } - static registerProvider(disposables: vscode.Disposable[], reviewManager: ReviewManager, folderManager: FolderRepositoryManager) { - disposables.push( - vscode.window.registerTerminalLinkProvider( - new GitHubCreatePullRequestLinkProvider(reviewManager, folderManager), - ) + static registerProvider(reviewManager: ReviewManager, folderManager: FolderRepositoryManager): vscode.Disposable { + return vscode.window.registerTerminalLinkProvider( + new GitHubCreatePullRequestLinkProvider(reviewManager, folderManager), ); } diff --git a/src/github/createPRViewProvider.ts b/src/github/createPRViewProvider.ts index 552ebfbf7c..b4444adf30 100644 --- a/src/github/createPRViewProvider.ts +++ b/src/github/createPRViewProvider.ts @@ -4,67 +4,74 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { CreateParams, CreatePullRequest, RemoteInfo } from '../../common/views'; -import type { Branch } from '../api/api'; -import { GitHubServerType } from '../common/authentication'; -import { commands, contexts } from '../common/executeCommands'; -import Logger from '../common/logger'; -import { Protocol } from '../common/protocol'; -import { GitHubRemote } from '../common/remote'; -import { ASSIGN_TO, CREATE_DRAFT, PULL_REQUEST_DESCRIPTION, PUSH_BRANCH } from '../common/settingKeys'; -import { getNonce, IRequestMessage, WebviewViewBase } from '../common/webview'; import { byRemoteName, - DetachedHeadError, FolderRepositoryManager, PullRequestDefaults, - SETTINGS_NAMESPACE, titleAndBodyFrom, } from './folderRepositoryManager'; import { GitHubRepository } from './githubRepository'; -import { ILabel, MergeMethod, RepoAccessAndMergeMethods } from './interface'; +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 { ISSUE_EXPRESSION, parseIssueExpressionOutput, variableSubstitution } from './utils'; +import { branchPicks, getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, getProjectFromQuickPick, reviewersQuickPick } from './quickPicks'; +import { getIssueNumberLabelFromParsed, ISSUE_EXPRESSION, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput, variableSubstitution } from './utils'; +import { ChangeTemplateReply, DisplayLabel, PreReviewState } from './views'; +import { RemoteInfo } from '../../common/types'; +import { ChooseBaseRemoteAndBranchResult, ChooseCompareRemoteAndBranchResult, ChooseRemoteAndBranchArgs, CreateParamsNew, CreatePullRequestNew, TitleAndDescriptionArgs } from '../../common/views'; +import type { Branch } from '../api/api'; +import { debounce } from '../common/async'; +import { GitHubServerType } from '../common/authentication'; +import { emojify, ensureEmojis } from '../common/emoji'; +import { commands, contexts } from '../common/executeCommands'; +import Logger from '../common/logger'; +import { Protocol } from '../common/protocol'; +import { GitHubRemote } from '../common/remote'; +import { + ASSIGN_TO, + CREATE_BASE_BRANCH, + DEFAULT_CREATE_OPTION, + PR_SETTINGS_NAMESPACE, + PULL_REQUEST_DESCRIPTION, + PULL_REQUEST_LABELS, + PUSH_BRANCH +} from '../common/settingKeys'; +import { ITelemetry } from '../common/telemetry'; +import { asPromise, compareIgnoreCase, formatError, promiseWithTimeout } from '../common/utils'; +import { generateUuid } from '../common/uuid'; +import { IRequestMessage, WebviewViewBase } from '../common/webview'; +import { PREVIOUS_CREATE_METHOD, RECENTLY_USED_BRANCHES, RecentlyUsedBranchesState } from '../extensionState'; +import { CreatePullRequestDataModel } from '../view/createPullRequestDataModel'; 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 -export class CreatePullRequestViewProvider extends WebviewViewBase implements vscode.WebviewViewProvider { - public readonly viewType = 'github:createPullRequest'; - - private _onDone = new vscode.EventEmitter(); - readonly onDone: vscode.Event = this._onDone.event; - - private _onDidChangeBaseRemote = new vscode.EventEmitter(); - readonly onDidChangeBaseRemote: vscode.Event = this._onDidChangeBaseRemote.event; - - private _onDidChangeBaseBranch = new vscode.EventEmitter(); - readonly onDidChangeBaseBranch: vscode.Event = this._onDidChangeBaseBranch.event; - - private _onDidChangeCompareRemote = new vscode.EventEmitter(); - readonly onDidChangeCompareRemote: vscode.Event = this._onDidChangeCompareRemote.event; +export interface BasePullRequestDataModel { + baseOwner: string; + repositoryName: string; +} - private _onDidChangeCompareBranch = new vscode.EventEmitter(); - readonly onDidChangeCompareBranch: vscode.Event = this._onDidChangeCompareBranch.event; +export abstract class BaseCreatePullRequestViewProvider extends WebviewViewBase implements vscode.WebviewViewProvider { + protected static readonly ID = 'CreatePullRequestViewProvider'; + public override readonly viewType = 'github:createPullRequestWebview'; - private _compareBranch: string; - private _baseBranch: string; - private _baseRemote: RemoteInfo; + protected _onDone = new vscode.EventEmitter(); + readonly onDone: vscode.Event = this._onDone.event; - private _firstLoad: boolean = true; + protected _firstLoad: boolean = true; constructor( + protected readonly telemetry: ITelemetry, + protected readonly model: T, extensionUri: vscode.Uri, - private readonly _folderRepositoryManager: FolderRepositoryManager, - private readonly _pullRequestDefaults: PullRequestDefaults, - compareBranch: Branch, + protected readonly _folderRepositoryManager: FolderRepositoryManager, + protected readonly _pullRequestDefaults: PullRequestDefaults, + protected _defaultCompareBranch: string ) { super(extensionUri); - - this._defaultCompareBranch = compareBranch; } - public resolveWebviewView( + public override resolveWebviewView( webviewView: vscode.WebviewView, _context: vscode.WebviewViewResolveContext, _token: vscode.CancellationToken, @@ -75,41 +82,618 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs if (this._firstLoad) { this._firstLoad = false; // Reset any stored state. - // TODO @RMacfarlane Clear stored state on extension deactivation instead. - this.initializeParams(true); + return this.initializeParams(true); } else { - this.initializeParams(); + return this.initializeParams(); + } + } + + public override show() { + super.show(); + } + + public static withProgress(task: (progress: vscode.Progress<{ message?: string; increment?: number }>, token: vscode.CancellationToken) => Thenable) { + return vscode.window.withProgress({ location: { viewId: 'github:createPullRequestWebview' } }, task); + } + + protected async getPullRequestDefaultLabels(defaultBaseRemote: RemoteInfo): Promise { + + const pullRequestLabelSettings = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).inspect(PULL_REQUEST_LABELS); + + if (!pullRequestLabelSettings) { + return []; + } + + const defaultLabelValues = new Array(); + + if (pullRequestLabelSettings.workspaceValue) { + defaultLabelValues.push(...pullRequestLabelSettings.workspaceValue); + } + if (pullRequestLabelSettings.globalValue) { + defaultLabelValues.push(...pullRequestLabelSettings.globalValue); + } + + // Return early if no config present + if (!defaultLabelValues || defaultLabelValues.length === 0) { + return []; + } + + // Fetch labels from the repo and filter with case-sensitive comparison to be safe, + // dropping any labels that don't exist on the repo. + // TODO: @alexr00 - Add a cache for this. + const labels = await this._folderRepositoryManager.getLabels(undefined, { owner: defaultBaseRemote.owner, repo: defaultBaseRemote.repositoryName }); + const defaultLabels = labels.filter(label => defaultLabelValues.includes(label.name)); + + return defaultLabels; + } + + protected abstract getTitleAndDescription(compareBranch: Branch, baseBranch: string): Promise<{ title: string, description: string }>; + + protected async getMergeConfiguration(owner: string, name: string, refetch: boolean = false): Promise { + const repo = await this._folderRepositoryManager.createGitHubRepositoryFromOwnerName(owner, name); + return repo.getRepoAccessAndMergeMethods(refetch); + } + + protected saveRecentlyUsedBranch(owner: string, repositoryName: string, branchName: string): void { + const repoKey = `${owner}/${repositoryName}`; + const state = this._folderRepositoryManager.context.workspaceState.get(RECENTLY_USED_BRANCHES, { branches: {} }); + + // Get the current list for this repo + let recentBranches = state.branches[repoKey] || []; + + // Remove the branch if it's already in the list + recentBranches = recentBranches.filter(b => b !== branchName); + + // Add it to the front + recentBranches.unshift(branchName); + + // Limit to 10 branches + recentBranches = recentBranches.slice(0, 10); + + // Save back to state + state.branches[repoKey] = recentBranches; + this._folderRepositoryManager.context.workspaceState.update(RECENTLY_USED_BRANCHES, state); + } + + private initializeWhenVisibleDisposable: vscode.Disposable | undefined; + public async initializeParams(reset: boolean = false): Promise { + if (this._view?.visible === false && this.initializeWhenVisibleDisposable === undefined) { + this.initializeWhenVisibleDisposable = this._view?.onDidChangeVisibility(() => { + this.initializeWhenVisibleDisposable?.dispose(); + this.initializeWhenVisibleDisposable = undefined; + void this.initializeParams(); + }); + return; + } + + if (reset) { + // First clear all state ASAP + this._postMessage({ command: 'reset' }); + } + await this.initializeParamsPromise(); + } + + private _alreadyInitializing: Promise | undefined; + private async initializeParamsPromise(): Promise { + if (!this._alreadyInitializing) { + this._alreadyInitializing = this.doInitializeParams(); + this._alreadyInitializing.then(() => { + this._alreadyInitializing = undefined; + }); + } + return this._alreadyInitializing; + } + + protected abstract detectBaseMetadata(defaultCompareBranch: Branch): Promise; + + protected getTitleAndDescriptionProvider(name?: string) { + return this._folderRepositoryManager.getTitleAndDescriptionProvider(name); + } + + protected async getCreateParams(): Promise { + const defaultCompareBranch = await this._folderRepositoryManager.repository.getBranch(this._defaultCompareBranch); + const [detectedBaseMetadata, remotes, defaultOrigin] = await Promise.all([ + this.detectBaseMetadata(defaultCompareBranch), + this._folderRepositoryManager.getGitHubRemotes(), + this._folderRepositoryManager.getOrigin(defaultCompareBranch), + ensureEmojis(this._folderRepositoryManager.context) + ]); + + const defaultBaseRemote: RemoteInfo = { + owner: detectedBaseMetadata?.owner ?? this._pullRequestDefaults.owner, + repositoryName: detectedBaseMetadata?.repositoryName ?? this._pullRequestDefaults.repo, + }; + + const defaultCompareRemote: RemoteInfo = { + owner: defaultOrigin.remote.owner, + repositoryName: defaultOrigin.remote.repositoryName, + }; + + const defaultBaseBranch = detectedBaseMetadata?.branch ?? this._pullRequestDefaults.base; + + const [defaultTitleAndDescription, mergeConfiguration, viewerPermission, mergeQueueMethodForBranch, labels] = await Promise.all([ + this.getTitleAndDescription(defaultCompareBranch, defaultBaseBranch), + this.getMergeConfiguration(defaultBaseRemote.owner, defaultBaseRemote.repositoryName), + defaultOrigin.getViewerPermission(), + this._folderRepositoryManager.mergeQueueMethodForBranch(defaultBaseBranch, defaultBaseRemote.owner, defaultBaseRemote.repositoryName), + this.getPullRequestDefaultLabels(defaultBaseRemote) + ]); + + const defaultCreateOption = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'lastUsed' | 'create' | 'createDraft' | 'createAutoMerge'>(DEFAULT_CREATE_OPTION, 'lastUsed'); + const lastCreateMethod: { autoMerge: boolean, mergeMethod: MergeMethod | undefined, isDraft: boolean } | undefined = this._folderRepositoryManager.context.workspaceState.get<{ autoMerge: boolean, mergeMethod: MergeMethod, isDraft } | undefined>(PREVIOUS_CREATE_METHOD, undefined); + const repoMergeMethod = getDefaultMergeMethod(mergeConfiguration.mergeMethodsAvailability); + + // default values are for 'create' + let defaultMergeMethod: MergeMethod = repoMergeMethod; + let isDraftDefault: boolean = false; + let autoMergeDefault: boolean = false; + defaultMergeMethod = (defaultCreateOption === 'lastUsed' && lastCreateMethod?.mergeMethod) ? lastCreateMethod?.mergeMethod : repoMergeMethod; + + if (defaultCreateOption === 'lastUsed') { + defaultMergeMethod = lastCreateMethod?.mergeMethod ?? repoMergeMethod; + isDraftDefault = !!lastCreateMethod?.isDraft; + autoMergeDefault = mergeConfiguration.viewerCanAutoMerge && !!lastCreateMethod?.autoMerge; + } else if (defaultCreateOption === 'createDraft') { + isDraftDefault = true; + } else if (defaultCreateOption === 'createAutoMerge') { + autoMergeDefault = mergeConfiguration.viewerCanAutoMerge; } + commands.setContext(contexts.CREATE_PR_PERMISSIONS, viewerPermission); + + const descriptionSource = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'commit' | 'template' | 'none' | 'Copilot'>(PULL_REQUEST_DESCRIPTION); + const useCopilot: boolean = !!this.getTitleAndDescriptionProvider('Copilot') && (descriptionSource === 'Copilot'); + const usingTemplate: boolean = descriptionSource === 'template'; + const defaultTitleAndDescriptionProvider = this.getTitleAndDescriptionProvider()?.title; + if (defaultTitleAndDescriptionProvider) { + /* __GDPR__ + "pr.defaultTitleAndDescriptionProvider" : { + "providerTitle" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this.telemetry.sendTelemetryEvent('pr.defaultTitleAndDescriptionProvider', { providerTitle: defaultTitleAndDescriptionProvider }); + } + const preReviewer = this._folderRepositoryManager.getAutoReviewer(); + + this.labels = labels.map(label => ({ ...label, displayName: emojify(label.name) })); + + const params: CreateParamsNew = { + canModifyBranches: true, + defaultBaseRemote, + defaultBaseBranch, + defaultCompareRemote, + defaultCompareBranch: this._defaultCompareBranch, + defaultTitle: defaultTitleAndDescription.title, + defaultDescription: defaultTitleAndDescription.description, + defaultMergeMethod, + baseHasMergeQueue: !!mergeQueueMethodForBranch, + remoteCount: remotes.length, + allowAutoMerge: mergeConfiguration.viewerCanAutoMerge, + mergeMethodsAvailability: mergeConfiguration.mergeMethodsAvailability, + autoMergeDefault, + createError: '', + labels: this.labels, + isDraftDefault, + isDarkTheme: vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark, + generateTitleAndDescriptionTitle: defaultTitleAndDescriptionProvider, + creating: false, + initializeWithGeneratedTitleAndDescription: useCopilot, + preReviewState: PreReviewState.None, + preReviewer: preReviewer?.title, + reviewing: false, + usingTemplate + }; + + return params; + } + + private async doInitializeParams(): Promise { + const params = await this.getCreateParams(); + + Logger.appendLine(`Initializing "create" view: ${JSON.stringify(params)}`, BaseCreatePullRequestViewProvider.ID); + + this._postMessage({ + command: 'pr.initialize', + params, + }); + return params; + } + + private async autoAssign(pr: PullRequestModel): Promise { + const configuration = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(ASSIGN_TO); + if (!configuration) { + return; + } + const resolved = variableSubstitution(configuration, pr, undefined, (await this._folderRepositoryManager.getCurrentUser(pr.githubRepository))?.login); + if (!resolved) { + return; + } + try { + const user = await pr.githubRepository.resolveUser(resolved); + if (user) { + await pr.replaceAssignees([user]); + } + } catch (e) { + Logger.error(`Unable to assign pull request to user ${resolved}.`, BaseCreatePullRequestViewProvider.ID); + } + } + + private async enableAutoMerge(pr: PullRequestModel, autoMerge: boolean, automergeMethod: MergeMethod | undefined): Promise { + if (autoMerge && automergeMethod) { + return pr.enableAutoMerge(automergeMethod); + } + } + + private async setLabels(pr: PullRequestModel, labels: ILabel[]): Promise { + if (labels.length > 0) { + await pr.setLabels(labels.map(label => label.name)); + } + } + + private async setAssignees(pr: PullRequestModel, assignees: IAccount[]): Promise { + if (assignees.length) { + await pr.replaceAssignees(assignees); + } else { + await this.autoAssign(pr); + } + } + + private async setReviewers(pr: PullRequestModel, reviewers: (IAccount | ITeam)[]): Promise { + if (reviewers.length) { + const users: IAccount[] = []; + const teams: ITeam[] = []; + for (const reviewer of reviewers) { + if (isITeam(reviewer)) { + teams.push(reviewer); + } else { + users.push(reviewer); + } + } + await pr.requestReview(users, teams, true); + } + } + + private setMilestone(pr: PullRequestModel, milestone: IMilestone | undefined) { + if (milestone) { + return pr.updateMilestone(milestone.id); + } + } + + private setProjects(pr: PullRequestModel, projects: IProject[]) { + if (projects.length) { + return pr.updateProjects(projects); + } + } + + private async getBaseRemote(): Promise { + return (await this._folderRepositoryManager.getGitHubRemotes()).find(remote => compareIgnoreCase(remote.owner, this.model.baseOwner) === 0 && compareIgnoreCase(remote.repositoryName, this.model.repositoryName) === 0)!; + } + + private getBaseGitHubRepo(): GitHubRepository | undefined { + return this._folderRepositoryManager.gitHubRepositories.find(repo => compareIgnoreCase(repo.remote.owner, this.model.baseOwner) === 0 && compareIgnoreCase(repo.remote.repositoryName, this.model.repositoryName) === 0); + } + + private milestone: IMilestone | undefined; + public async addMilestone(): Promise { + const remote = await this.getBaseRemote(); + const repo = this._folderRepositoryManager.gitHubRepositories.find(repo => repo.remote.remoteName === remote.remoteName)!; + + return getMilestoneFromQuickPick(this._folderRepositoryManager, repo, this.milestone, (milestone) => { + this.milestone = milestone; + return this._postMessage({ + command: 'set-milestone', + params: { milestone: this.milestone } + }); + }); + } + + private reviewers: (IAccount | ITeam)[] = []; + public async addReviewers(): Promise { + let quickPick: vscode.QuickPick | undefined; + const remote = await this.getBaseRemote(); + try { + const repo = this._folderRepositoryManager.gitHubRepositories.find(repo => repo.remote.remoteName === remote.remoteName)!; + const [metadata, author, teamsCount] = await Promise.all([repo?.getMetadata(), this._folderRepositoryManager.getCurrentUser(), this._folderRepositoryManager.getOrgTeamsCount(repo)]); + quickPick = await reviewersQuickPick(this._folderRepositoryManager, remote.remoteName, !!metadata?.organization, teamsCount, author, this.reviewers.map(reviewer => { return { reviewer, state: 'REQUESTED' }; }), []); + quickPick.busy = false; + const acceptPromise = asPromise(quickPick.onDidAccept).then(() => { + return quickPick!.selectedItems.filter(item => item.user) as (vscode.QuickPickItem & { user: IAccount | ITeam })[] | undefined; + }); + const hidePromise = asPromise(quickPick.onDidHide); + const allReviewers = await Promise.race<(vscode.QuickPickItem & { user: IAccount | ITeam })[] | void>([acceptPromise, hidePromise]); + quickPick.busy = true; + + if (allReviewers) { + this.reviewers = allReviewers.map(item => item.user); + this._postMessage({ + command: 'set-reviewers', + params: { reviewers: this.reviewers } + }); + } + } catch (e) { + Logger.error(`Failed to add reviewers: ${formatError(e)}`, BaseCreatePullRequestViewProvider.ID); + vscode.window.showErrorMessage(formatError(e)); + } finally { + quickPick?.hide(); + quickPick?.dispose(); + } + } + + private assignees: IAccount[] = []; + public async addAssignees(): Promise { + const remote = await this.getBaseRemote(); + const currentRepo = this._folderRepositoryManager.gitHubRepositories.find(repo => repo.remote.owner === remote.owner && repo.remote.repositoryName === remote.repositoryName); + const assigneesToAdd = await vscode.window.showQuickPick(getAssigneesQuickPickItems(this._folderRepositoryManager, currentRepo, remote.remoteName, this.assignees, undefined, true), + { canPickMany: true, matchOnDescription: true, placeHolder: vscode.l10n.t('Add assignees') }); + if (assigneesToAdd) { + const seenNewAssignees = new Set(); + const addedAssignees = assigneesToAdd.map(assignee => assignee.user).filter((assignee): assignee is IAccount => { + if (assignee && !seenNewAssignees.has(assignee.login)) { + seenNewAssignees.add(assignee.login); + return true; + } + return false; + }); + this.assignees = addedAssignees; + this._postMessage({ + command: 'set-assignees', + params: { assignees: this.assignees } + }); + } + } + private projects: IProject[] = []; + public async addProjects(): Promise { + const githubRepo = this.getBaseGitHubRepo(); + if (!githubRepo) { + return; + } + await new Promise((resolve) => { + getProjectFromQuickPick(this._folderRepositoryManager, githubRepo, this.projects, async (projects) => { + this.projects = projects; + this._postMessage({ + command: 'set-projects', + params: { projects: this.projects } + }); + resolve(); + }); + }); + } + + private labels: DisplayLabel[] = []; + public async addLabels(): Promise { + let newLabels: DisplayLabel[] = []; + + const labelsToAdd = await vscode.window.showQuickPick( + getLabelOptions(this._folderRepositoryManager, this.labels, this.model.baseOwner, this.model.repositoryName).then(options => { + newLabels = options.newLabels; + return options.labelPicks; + }), + { canPickMany: true, matchOnDescription: true, placeHolder: vscode.l10n.t('Apply labels') }, + ); + + if (labelsToAdd) { + const addedLabels: DisplayLabel[] = labelsToAdd.map(label => newLabels.find(l => l.name === label.name)!); + this.labels = addedLabels; + this._postMessage({ + command: 'set-labels', + params: { labels: this.labels } + }); + } + } + + private async removeLabel(message: IRequestMessage<{ label: ILabel }>,): Promise { + const { label } = message.args; + if (!label) + return; + + const previousLabelsLength = this.labels.length; + this.labels = this.labels.filter(l => l.name !== label.name); + if (previousLabelsLength === this.labels.length) + return; + + this._postMessage({ + command: 'set-labels', + params: { labels: this.labels } + }); + } + + public async createFromCommand(isDraft: boolean, autoMerge: boolean, autoMergeMethod: MergeMethod | undefined, mergeWhenReady?: boolean) { + const params: Partial = { + isDraft, + autoMerge, + autoMergeMethod: mergeWhenReady ? 'merge' : autoMergeMethod, + creating: true + }; + return this._postMessage({ + command: 'create', + params + }); + } + + protected abstract create(message: IRequestMessage): Promise; + + protected async postCreate(message: IRequestMessage, createdPR: PullRequestModel) { + return Promise.all([ + this.setLabels(createdPR, message.args.labels), + this.enableAutoMerge(createdPR, message.args.autoMerge, message.args.autoMergeMethod), + this.setAssignees(createdPR, message.args.assignees), + this.setReviewers(createdPR, message.args.reviewers), + this.setMilestone(createdPR, message.args.milestone), + this.setProjects(createdPR, message.args.projects)]); + } + + private async cancel(message: IRequestMessage) { + this._onDone.fire(undefined); + // Re-fetch the automerge info so that it's updated for next time. + await this.getMergeConfiguration(message.args.owner, message.args.repo, true); + return this._replyMessage(message, undefined); + } + + private async openDescriptionSettings(): Promise { + return vscode.commands.executeCommand('workbench.action.openSettings', 'githubPullRequests.pullRequestDescription'); + } + + protected override async _onDidReceiveMessage(message: IRequestMessage) { + const result = await super._onDidReceiveMessage(message); + if (result !== this.MESSAGE_UNHANDLED) { + return; + } + + switch (message.command) { + case 'pr.requestInitialize': + return this.initializeParamsPromise(); + + case 'pr.cancelCreate': + return this.cancel(message); + + case 'pr.create': + return this.create(message); + + case 'pr.changeLabels': + return this.addLabels(); + + case 'pr.changeReviewers': + return this.addReviewers(); + + case 'pr.changeAssignees': + return this.addAssignees(); + + case 'pr.changeMilestone': + return this.addMilestone(); + + case 'pr.changeProjects': + return this.addProjects(); + + case 'pr.removeLabel': + return this.removeLabel(message); + + case 'pr.openDescriptionSettings': + return this.openDescriptionSettings(); + + default: + return this.MESSAGE_UNHANDLED; + } + } + + override dispose() { + super.dispose(); + this._postMessage({ command: 'reset' }); + } + + private _getHtmlForWebview() { + const nonce = generateUuid(); + + const uri = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview-create-pr-view-new.js'); + + return ` + + + + + + + Create Pull Request + + +
+ + +`; + } +} + +function serializeRemoteInfo(remote: { owner: string, repositoryName: string }) { + return { owner: remote.owner, repositoryName: remote.repositoryName }; +} + +export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProvider implements vscode.WebviewViewProvider { + public override readonly viewType = 'github:createPullRequestWebview'; + + constructor( + telemetry: ITelemetry, + model: CreatePullRequestDataModel, + extensionUri: vscode.Uri, + folderRepositoryManager: FolderRepositoryManager, + pullRequestDefaults: PullRequestDefaults, + ) { + super(telemetry, model, extensionUri, folderRepositoryManager, pullRequestDefaults, model.compareBranch); + + this._register(this.model.onDidChange(async (e) => { + let baseRemote: RemoteInfo | undefined; + let baseBranch: string | undefined; + if (e.baseOwner) { + const gitHubRemote = this._folderRepositoryManager.findRepo(repo => compareIgnoreCase(repo.remote.owner, e.baseOwner!) === 0 && compareIgnoreCase(repo.remote.repositoryName, this.model.repositoryName) === 0)?.remote; + baseRemote = gitHubRemote ? serializeRemoteInfo(gitHubRemote) : undefined; + baseBranch = this.model.baseBranch; + } + if (e.baseBranch) { + baseBranch = e.baseBranch; + } + let compareRemote: RemoteInfo | undefined; + let compareBranch: string | undefined; + if (e.compareOwner) { + const gitHubRemote = this._folderRepositoryManager.findRepo(repo => compareIgnoreCase(repo.remote.owner, e.compareOwner!) === 0 && compareIgnoreCase(repo.remote.repositoryName, this.model.repositoryName) === 0)?.remote; + compareRemote = gitHubRemote ? serializeRemoteInfo(gitHubRemote) : undefined; + compareBranch = this.model.compareBranch; + } + if (e.compareBranch) { + compareBranch = e.compareBranch; + } + const params: Partial = { + baseRemote, + baseBranch, + compareRemote, + compareBranch, + warning: await this.existingPRMessage(), + }; + // TODO: consider updating title and description + return this._postMessage({ + command: 'pr.initialize', + params, + }); + + })); } - private _defaultCompareBranch: Branch; - get defaultCompareBranch() { - return this._defaultCompareBranch; + private async existingPRMessage(): Promise { + const [existingPR, hasUpstream] = await Promise.all([PullRequestGitHelper.getMatchingPullRequestMetadataForBranch(this._folderRepositoryManager.repository, this.model.compareBranch), this.model.getCompareHasUpstream()]); + if (!existingPR || !hasUpstream) { + return undefined; + } + + const [pr, compareBranch] = await Promise.all([this._folderRepositoryManager.resolvePullRequest(existingPR.owner, existingPR.repositoryName, existingPR.prNumber), this._folderRepositoryManager.repository.getBranch(this.model.compareBranch)]); + return (pr?.head?.sha === compareBranch.commit) ? vscode.l10n.t('A pull request already exists for this branch.') : undefined; } - set defaultCompareBranch(compareBranch: Branch | undefined) { - const branchChanged = compareBranch && (compareBranch.name !== this._defaultCompareBranch.name || - compareBranch.upstream?.remote !== this._defaultCompareBranch.upstream?.remote); - const commitChanged = compareBranch && (compareBranch.commit !== this._defaultCompareBranch.commit); - if (branchChanged || commitChanged) { - this._defaultCompareBranch = compareBranch!; - void this.initializeParams(); + public async setDefaultCompareBranch(compareBranch: Branch | undefined) { + 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, + }); + }); - if (branchChanged) { - this._onDidChangeCompareBranch.fire(this._defaultCompareBranch.name!); - } - } } - public show(compareBranch?: Branch): void { + public override show(compareBranch?: Branch): void { if (compareBranch) { - this.defaultCompareBranch = compareBranch; + this.setDefaultCompareBranch(compareBranch); // don't await, view will be updated when the branch is changed } super.show(); } - private async getTotalGitHubCommits(compareBranch: Branch, baseBranchName: string): Promise { + private async getTotalGitHubCommits(compareBranch: Branch, baseBranchName: string): Promise<{ commit: { message: string }; parents: { sha: string }[] }[] | undefined> { const origin = await this._folderRepositoryManager.getOrigin(compareBranch); if (compareBranch.upstream) { @@ -120,45 +704,68 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs const baseBranch = `${this._pullRequestDefaults.owner}:${baseBranchName}`; const compareResult = await origin.compareCommits(baseBranch, headBranch); - return compareResult?.total_commits; + return compareResult?.commits; } } return undefined; } - private async getTitleAndDescription(compareBranch: Branch, baseBranch: string): Promise<{ title: string, description: string }> { + protected async getTitleAndDescription(compareBranch: Branch, baseBranch: string): Promise<{ title: string, description: string }> { let title: string = ''; let description: string = ''; + const descriptionSource = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'commit' | 'template' | 'branchName' | 'none' | 'Copilot'>(PULL_REQUEST_DESCRIPTION); + if (descriptionSource === 'none') { + return { title, description }; + } + + const name = compareBranch.name; + const branchNameTitle = (name: string) => { + return `${name.charAt(0).toUpperCase()}${name.slice(1)}`; + }; + + // If branchName is selected, use the branch name as the title + if (descriptionSource === 'branchName') { + if (name) { + title = branchNameTitle(name); + } + return { title, description }; + } // Use same default as GitHub, if there is only one commit, use the commit, otherwise use the branch name, as long as it is not the default branch. // By default, the base branch we use for comparison is the base branch of origin. Compare this to the // compare branch if it has a GitHub remote. const origin = await this._folderRepositoryManager.getOrigin(compareBranch); - const useTemplate = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get(PULL_REQUEST_DESCRIPTION) === 'template'; let useBranchName = this._pullRequestDefaults.base === compareBranch.name; - Logger.debug(`Compare branch name: ${compareBranch.name}, Base branch name: ${this._pullRequestDefaults.base}`, 'CreatePullRequestViewProvider'); + Logger.debug(`Compare branch name: ${compareBranch.name}, Base branch name: ${this._pullRequestDefaults.base}`, CreatePullRequestViewProvider.ID); try { - const name = compareBranch.name; const [totalCommits, lastCommit, pullRequestTemplate] = await Promise.all([ this.getTotalGitHubCommits(compareBranch, baseBranch), - name ? titleAndBodyFrom(await this._folderRepositoryManager.getTipCommitMessage(name)) : undefined, - useTemplate ? await this.getPullRequestTemplate() : undefined + name ? titleAndBodyFrom(promiseWithTimeout(this._folderRepositoryManager.getTipCommitMessage(name), 5000)) : undefined, + descriptionSource === 'template' ? this.getPullRequestTemplate() : undefined ]); + const totalNonMergeCommits = totalCommits?.filter(commit => commit.parents.length < 2); - Logger.debug(`Total commits: ${totalCommits}`, 'CreatePullRequestViewProvider'); - if (totalCommits === undefined) { + Logger.debug(`Total commits: ${totalNonMergeCommits?.length}`, CreatePullRequestViewProvider.ID); + if (totalNonMergeCommits === undefined) { // There is no upstream branch. Use the last commit as the title and description. useBranchName = false; - } else if (totalCommits > 1) { + } else if (totalNonMergeCommits && totalNonMergeCommits.length > 1) { const defaultBranch = await origin.getDefaultBranch(); useBranchName = defaultBranch !== compareBranch.name; } + if (name && !lastCommit) { + Logger.appendLine('Timeout getting last commit message', CreatePullRequestViewProvider.ID); + /* __GDPR__ + "pr.create.getCommitTimeout" : {} + */ + this.telemetry.sendTelemetryEvent('pr.create.getCommitTimeout'); + } // Set title if (useBranchName && name) { - title = `${name.charAt(0).toUpperCase()}${name.slice(1)}`; + title = branchNameTitle(name); } else if (name && lastCommit) { title = lastCommit.title; } @@ -188,266 +795,499 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs } } catch (e) { // Ignore and fall back to commit message - Logger.debug(`Error while getting total commits: ${e}`, 'CreatePullRequestViewProvider'); + Logger.debug(`Error while getting total commits: ${e}`, CreatePullRequestViewProvider.ID); } return { title, description }; } private async getPullRequestTemplate(): Promise { - const templateUris = await this._folderRepositoryManager.getPullRequestTemplates(); - if (templateUris[0]) { - try { - const templateContent = await vscode.workspace.fs.readFile(templateUris[0]); - return new TextDecoder('utf-8').decode(templateContent); - } catch (e) { - Logger.warn(`Reading pull request template failed: ${e}`); - return undefined; + return this._folderRepositoryManager.getPullRequestTemplateBody(this.model.baseOwner); + } + + private async changeTemplate(message: IRequestMessage): Promise { + const templates = await this._folderRepositoryManager.getAllPullRequestTemplates(this.model.baseOwner); + + if (!templates || templates.length === 0) { + // No templates found - show helpful options + const learnMore = vscode.l10n.t('Learn More'); + const createTemplate = vscode.l10n.t('Create Template'); + const selected = await vscode.window.showQuickPick( + [ + { + label: createTemplate, + description: vscode.l10n.t('Create a new pull request template') + }, + { + label: learnMore, + description: vscode.l10n.t('Open GitHub documentation') + } + ], + { + placeHolder: vscode.l10n.t('No pull request templates found'), + ignoreFocusOut: true + } + ); + + if (selected?.label === learnMore) { + vscode.env.openExternal(vscode.Uri.parse('https://docs.github.com/communities/using-templates-to-encourage-useful-issues-and-pull-requests/creating-a-pull-request-template-for-your-repository')); + } else if (selected?.label === createTemplate) { + await this.createPullRequestTemplate(); } + return this._replyMessage(message, undefined); } - return undefined; - } + // Multiple templates exist - show quick pick + const selectedTemplate = await vscode.window.showQuickPick( + templates.map((template, index) => { + // Try to extract a meaningful name from the template (first line or first few chars) + const firstLine = template.split('\n')[0].trim(); + const label = firstLine || vscode.l10n.t('Template {0}', index + 1); + return { + label: label.substring(0, 50) + (label.length > 50 ? '...' : ''), + description: vscode.l10n.t('{0} characters', template.length), + template: template + }; + }), + { + placeHolder: vscode.l10n.t('Select a pull request template'), + ignoreFocusOut: true + } + ); - private async getMergeConfiguration(owner: string, name: string, refetch: boolean = false): Promise { - const repo = await this._folderRepositoryManager.createGitHubRepositoryFromOwnerName(owner, name); - return repo.getRepoAccessAndMergeMethods(refetch); + if (selectedTemplate) { + const reply: ChangeTemplateReply = { + description: selectedTemplate.template + }; + return this._replyMessage(message, reply); + } + return this._replyMessage(message, undefined); } - private initializeWhenVisibleDisposable: vscode.Disposable | undefined; - public async initializeParams(reset: boolean = false): Promise { - if (this._view?.visible === false && this.initializeWhenVisibleDisposable === undefined) { - this.initializeWhenVisibleDisposable = this._view?.onDidChangeVisibility(() => { - this.initializeWhenVisibleDisposable?.dispose(); - this.initializeWhenVisibleDisposable = undefined; - void this.initializeParams(); - }); + private async createPullRequestTemplate(): Promise { + // Show options for where to create the template + const templateLocations = [ + { + label: '.github/pull_request_template.md', + description: vscode.l10n.t('Default location for a single template') + }, + { + label: 'docs/pull_request_template.md', + description: vscode.l10n.t('Alternative location in docs folder') + }, + { + label: '.github/PULL_REQUEST_TEMPLATE/template.md', + description: vscode.l10n.t('For multiple templates') + } + ]; + + const selected = await vscode.window.showQuickPick(templateLocations, { + placeHolder: vscode.l10n.t('Choose where to create the pull request template'), + ignoreFocusOut: true + }); + + if (!selected) { return; } - if (reset) { - // First clear all state ASAP - this._postMessage({ command: 'reset' }); - } - // Do the fast initialization first, then update with the slower initialization. - const params = await this.initializeParamsFast(reset); - this.initializeParamsSlow(params); - } + // Get the repository root + const workspaceFolder = this._folderRepositoryManager.repository.rootUri; + const templatePath = vscode.Uri.joinPath(workspaceFolder, selected.label); - private async initializeParamsSlow(params: CreateParams): Promise { - if (!this.defaultCompareBranch) { - throw new DetachedHeadError(this._folderRepositoryManager.repository); - } - if (!params.defaultBaseRemote || !params.defaultCompareRemote) { - throw new Error('Create Pull Request view unable to initialize without default remotes.'); - } - const defaultOrigin = await this._folderRepositoryManager.getOrigin(this.defaultCompareBranch); - const viewerPermission = await defaultOrigin.getViewerPermission(); - commands.setContext(contexts.CREATE_PR_PERMISSIONS, viewerPermission); + // Default template content + const templateContent = `## Sample Pull Request Template Description - const branchesForRemote = await defaultOrigin.listBranches(this._pullRequestDefaults.owner, this._pullRequestDefaults.repo); - // Ensure default into branch is in the remotes list - if (!branchesForRemote.includes(this._pullRequestDefaults.base)) { - branchesForRemote.push(this._pullRequestDefaults.base); - branchesForRemote.sort(); - } +This is a sample pull request template. You can customize it to fit your project's needs. - let branchesForCompare = branchesForRemote; - if (params.defaultCompareRemote.owner !== params.defaultBaseRemote.owner) { - branchesForCompare = await defaultOrigin.listBranches( - params.defaultCompareRemote.owner, - params.defaultCompareRemote.repositoryName, - ); - } +Don't forget to commit your template file to the repository so that it can be used for future pull requests! +`; + + try { + // Ensure all parent directories exist by creating them step by step + const pathParts = selected.label.split('/'); + let currentPath = workspaceFolder; + + // Create each directory in the path (excluding the file name) + for (let i = 0; i < pathParts.length - 1; i++) { + currentPath = vscode.Uri.joinPath(currentPath, pathParts[i]); + try { + await vscode.workspace.fs.createDirectory(currentPath); + } catch (e) { + // Re-throw if it's not a FileSystemError about the directory already existing + if (e instanceof vscode.FileSystemError && e.code !== 'FileExists') { + throw e; + } + // Directory already exists, which is fine + } + } + + // Create the template file + const encoder = new TextEncoder(); + await vscode.workspace.fs.writeFile(templatePath, encoder.encode(templateContent)); - // Ensure default from branch is in the remotes list - if (this.defaultCompareBranch.name && !branchesForCompare.includes(this.defaultCompareBranch.name)) { - branchesForCompare.push(this.defaultCompareBranch.name); - branchesForCompare.sort(); + // Open the file for editing + const document = await vscode.workspace.openTextDocument(templatePath); + await vscode.window.showTextDocument(document); + + vscode.window.showInformationMessage( + vscode.l10n.t('Pull request template created at {0}', selected.label) + ); + } catch (error) { + vscode.window.showErrorMessage( + vscode.l10n.t('Failed to create pull request template: {0}', error instanceof Error ? error.message : String(error)) + ); } - params.branchesForRemote = branchesForRemote; - params.branchesForCompare = branchesForCompare; - this._postMessage({ - command: 'pr.initialize', - params, - }); } - private async initializeParamsFast(reset: boolean = false): Promise { - if (!this.defaultCompareBranch) { - throw new DetachedHeadError(this._folderRepositoryManager.repository); + protected async detectBaseMetadata(defaultCompareBranch: Branch): Promise { + const owner = this.model.compareOwner; + const repositoryName = this.model.repositoryName; + const settingValue = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'repositoryDefault' | 'createdFromBranch' | 'auto'>(CREATE_BASE_BRANCH); + if (!defaultCompareBranch.name || settingValue === 'repositoryDefault') { + return undefined; + } + const githubRepo = this._folderRepositoryManager.findRepo(repo => compareIgnoreCase(repo.remote.owner, owner) === 0 && compareIgnoreCase(repo.remote.repositoryName, repositoryName) === 0); + if (settingValue === 'auto' && (await githubRepo?.getMetadata())?.fork) { + return undefined; } - const defaultBaseRemote: RemoteInfo = { - owner: this._pullRequestDefaults.owner, - repositoryName: this._pullRequestDefaults.repo, - }; - - const defaultOrigin = await this._folderRepositoryManager.getOrigin(this.defaultCompareBranch); - const defaultCompareRemote: RemoteInfo = { - owner: defaultOrigin.remote.owner, - repositoryName: defaultOrigin.remote.repositoryName, - }; + try { + const baseFromProvider = await this._folderRepositoryManager.repository.getBranchBase(defaultCompareBranch.name); + if (baseFromProvider?.name) { + const repo = this._folderRepositoryManager.findRepo(repo => repo.remote.remoteName === baseFromProvider.remote); + if (repo) { + return { + branch: baseFromProvider.name, + owner: repo.remote.owner, + repositoryName: repo.remote.repositoryName + }; + } + } + } catch (e) { + // Not all providers will support `getBranchBase` + return undefined; + } + } - const defaultBaseBranch = this._pullRequestDefaults.base; - const [configuredGitHubRemotes, allGitHubRemotes, defaultTitleAndDescription, mergeConfiguration] = await Promise.all([ - this._folderRepositoryManager.getGitHubRemotes(), - this._folderRepositoryManager.getAllGitHubRemotes(), - this.getTitleAndDescription(this.defaultCompareBranch, defaultBaseBranch), - this.getMergeConfiguration(defaultBaseRemote.owner, defaultBaseRemote.repositoryName) - ]); + protected override async getCreateParams(): Promise { + const params = await super.getCreateParams(); + this.model.baseOwner = params.defaultBaseRemote!.owner; + this.model.baseBranch = params.defaultBaseBranch!; + return params; + } - const configuredRemotes: RemoteInfo[] = configuredGitHubRemotes.map(remote => { - return { - owner: remote.owner, - repositoryName: remote.repositoryName, - }; - }); - const allRemotes: RemoteInfo[] = allGitHubRemotes.map(remote => { + private async remotePicks(isBase: boolean): Promise<(vscode.QuickPickItem & { remote?: RemoteInfo })[]> { + const remotes = isBase ? await this._folderRepositoryManager.getActiveGitHubRemotes(await this._folderRepositoryManager.getGitHubRemotes()) : this._folderRepositoryManager.gitHubRepositories.map(repo => repo.remote); + return remotes.map(remote => { return { - owner: remote.owner, - repositoryName: remote.repositoryName, + iconPath: new vscode.ThemeIcon('repo'), + label: `${remote.owner}/${remote.repositoryName}`, + remote: { + owner: remote.owner, + repositoryName: remote.repositoryName, + } }; }); - const defaultCompareBranch = this.defaultCompareBranch.name ?? ''; - - const params: CreateParams = { - availableBaseRemotes: configuredRemotes, - availableCompareRemotes: allRemotes, - defaultBaseRemote, - defaultBaseBranch, - defaultCompareRemote, - defaultCompareBranch, - branchesForRemote: [defaultBaseBranch], // We'll populate the branches in the slow phase as they are less likely to be needed. - branchesForCompare: [defaultCompareBranch], - defaultTitle: defaultTitleAndDescription.title, - defaultDescription: defaultTitleAndDescription.description, - defaultMergeMethod: getDefaultMergeMethod(mergeConfiguration.mergeMethodsAvailability), - allowAutoMerge: mergeConfiguration.viewerCanAutoMerge, - mergeMethodsAvailability: mergeConfiguration.mergeMethodsAvailability, - createError: '', - labels: this.labels, - isDraft: vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get(CREATE_DRAFT, false), - isDarkTheme: vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark - }; + } - Logger.appendLine(`Initializing "create" view: ${JSON.stringify(params)}`, 'CreatePullRequestViewProvider'); + private async processRemoteAndBranchResult(githubRepository: GitHubRepository, result: { remote: RemoteInfo, branch: string }, isBase: boolean) { + const [defaultBranch, viewerPermission] = await Promise.all([githubRepository.getDefaultBranch(), githubRepository.getViewerPermission()]); - this._compareBranch = this.defaultCompareBranch.name ?? ''; - this._baseBranch = defaultBaseBranch; - this._baseRemote = defaultBaseRemote; + commands.setContext(contexts.CREATE_PR_PERMISSIONS, viewerPermission); + let chooseResult: ChooseBaseRemoteAndBranchResult | ChooseCompareRemoteAndBranchResult; + if (isBase) { + const baseRemoteChanged = this.model.baseOwner !== result.remote.owner; + const baseBranchChanged = baseRemoteChanged || this.model.baseBranch !== result.branch; + this.model.baseOwner = result.remote.owner; + this.model.baseBranch = result.branch; + + // Save the selected base branch to recently used branches + this.saveRecentlyUsedBranch(result.remote.owner, result.remote.repositoryName, result.branch); + + const compareBranch = await this._folderRepositoryManager.repository.getBranch(this.model.compareBranch); + const [mergeConfiguration, titleAndDescription, mergeQueueMethodForBranch] = await Promise.all([ + this.getMergeConfiguration(result.remote.owner, result.remote.repositoryName), + this.getTitleAndDescription(compareBranch, this.model.baseBranch), + this._folderRepositoryManager.mergeQueueMethodForBranch(this.model.baseBranch, this.model.baseOwner, this.model.repositoryName)]); + let autoMergeDefault = false; + if (mergeConfiguration.viewerCanAutoMerge) { + const defaultCreateOption = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'lastUsed' | 'create' | 'createDraft' | 'createAutoMerge'>(DEFAULT_CREATE_OPTION, 'lastUsed'); + const lastCreateMethod: { autoMerge: boolean, mergeMethod: MergeMethod | undefined, isDraft: boolean } | undefined = this._folderRepositoryManager.context.workspaceState.get<{ autoMerge: boolean, mergeMethod: MergeMethod, isDraft } | undefined>(PREVIOUS_CREATE_METHOD, undefined); + autoMergeDefault = (defaultCreateOption === 'lastUsed' && lastCreateMethod?.autoMerge) || (defaultCreateOption === 'createAutoMerge'); + } - this._postMessage({ - command: reset ? 'reset' : 'pr.initialize', - params, - }); - return params; + chooseResult = { + baseRemote: result.remote, + baseBranch: result.branch, + defaultBaseBranch: defaultBranch, + defaultMergeMethod: getDefaultMergeMethod(mergeConfiguration.mergeMethodsAvailability), + allowAutoMerge: mergeConfiguration.viewerCanAutoMerge, + baseHasMergeQueue: !!mergeQueueMethodForBranch, + mergeMethodsAvailability: mergeConfiguration.mergeMethodsAvailability, + autoMergeDefault, + defaultTitle: titleAndDescription.title, + defaultDescription: titleAndDescription.description + }; + if (baseRemoteChanged) { + /* __GDPR__ + "pr.create.changedBaseRemote" : {} + */ + this._folderRepositoryManager.telemetry.sendTelemetryEvent('pr.create.changedBaseRemote'); + } + if (baseBranchChanged) { + /* __GDPR__ + "pr.create.changedBaseBranch" : {} + */ + this._folderRepositoryManager.telemetry.sendTelemetryEvent('pr.create.changedBaseBranch'); + } + } else { + await this.changeBranch(result.branch, false); + chooseResult = { + compareRemote: result.remote, + compareBranch: result.branch, + defaultCompareBranch: defaultBranch + }; + /* __GDPR__ + "pr.create.changedCompare" : {} + */ + this._folderRepositoryManager.telemetry.sendTelemetryEvent('pr.create.changedCompare'); + } + return chooseResult; } - private async changeRemote( - message: IRequestMessage<{ owner: string; repositoryName: string }>, - isBase: boolean, - ): Promise { - const { owner, repositoryName } = message.args; - + private async changeRemoteAndBranch(message: IRequestMessage, isBase: boolean): Promise { + this.cancelGenerateTitleAndDescription(); + const quickPick = vscode.window.createQuickPick<(vscode.QuickPickItem & { remote?: RemoteInfo, branch?: string })>(); let githubRepository = this._folderRepositoryManager.findRepo( - repo => owner === repo.remote.owner && repositoryName === repo.remote.repositoryName, + repo => message.args.currentRemote?.owner === repo.remote.owner && message.args.currentRemote.repositoryName === repo.remote.repositoryName, ); - if (!githubRepository) { - githubRepository = await this._folderRepositoryManager.createGitHubRepositoryFromOwnerName(owner, repositoryName); + const chooseDifferentRemote = vscode.l10n.t('Change Repository...'); + const remotePlaceholder = vscode.l10n.t('Choose a remote'); + const branchPlaceholder = isBase ? vscode.l10n.t('Choose a base branch') : vscode.l10n.t('Choose a branch to merge'); + const repositoryPlaceholder = isBase ? vscode.l10n.t('Choose a base repository') : vscode.l10n.t('Choose a repository to merge from'); + + let updateCounter = 0; + const updateItems = async (githubRepository: GitHubRepository, prefix: string | undefined) => { + const currentUpdate = ++updateCounter; + quickPick.busy = true; + const items = await branchPicks(githubRepository, this._folderRepositoryManager, chooseDifferentRemote, isBase, prefix); + if (currentUpdate === updateCounter) { + quickPick.items = items; + quickPick.busy = false; + } + }; + const debounced = debounce(updateItems, 300); + let onDidChangeValueDisposable: vscode.Disposable | undefined; + const addValueChangeListener = () => { + if (githubRepository && !onDidChangeValueDisposable) { + onDidChangeValueDisposable = quickPick.onDidChangeValue(async value => { + return debounced(githubRepository!, value); + }); + } + }; + addValueChangeListener(); + + quickPick.placeholder = githubRepository ? branchPlaceholder : remotePlaceholder; + quickPick.show(); + quickPick.busy = true; + if (githubRepository) { + await updateItems(githubRepository, undefined); + } else { + quickPick.items = await this.remotePicks(isBase); } - if (!githubRepository) { - throw new Error('No matching GitHub repository found.'); + const activeItem = message.args.currentBranch ? quickPick.items.find(item => item.branch === message.args.currentBranch) : undefined; + quickPick.activeItems = activeItem ? [activeItem] : []; + quickPick.busy = false; + const remoteAndBranch: Promise<{ remote: RemoteInfo, branch: string } | undefined> = new Promise((resolve) => { + quickPick.onDidAccept(async () => { + if (quickPick.selectedItems.length === 0) { + return; + } + const selectedPick = quickPick.selectedItems[0]; + if (selectedPick.label === chooseDifferentRemote) { + quickPick.busy = true; + quickPick.items = await this.remotePicks(isBase); + quickPick.busy = false; + quickPick.placeholder = githubRepository ? repositoryPlaceholder : remotePlaceholder; + } else if ((selectedPick.branch === undefined) && selectedPick.remote) { + const selectedRemote = selectedPick as vscode.QuickPickItem & { remote: RemoteInfo }; + quickPick.busy = true; + githubRepository = this._folderRepositoryManager.findRepo(repo => repo.remote.owner === selectedRemote.remote.owner && repo.remote.repositoryName === selectedRemote.remote.repositoryName)!; + await updateItems(githubRepository, undefined); + addValueChangeListener(); + quickPick.placeholder = branchPlaceholder; + quickPick.busy = false; + } else if (selectedPick.branch && selectedPick.remote) { + const selectedBranch = selectedPick as vscode.QuickPickItem & { remote: RemoteInfo, branch: string }; + resolve({ remote: selectedBranch.remote, branch: selectedBranch.branch }); + } + }); + }); + const hidePromise = new Promise((resolve) => quickPick.onDidHide(() => resolve())); + const result = await Promise.race([remoteAndBranch, hidePromise]); + if (!result || !githubRepository) { + quickPick.hide(); + quickPick.dispose(); + onDidChangeValueDisposable?.dispose(); + return; } - const [defaultBranch, newBranches, viewerPermission] = await Promise.all([githubRepository.getDefaultBranch(), githubRepository.listBranches(owner, repositoryName), githubRepository.getViewerPermission()]); + quickPick.busy = true; + const chooseResult = await this.processRemoteAndBranchResult(githubRepository, result, isBase); - commands.setContext(contexts.CREATE_PR_PERMISSIONS, viewerPermission); + quickPick.hide(); + quickPick.dispose(); + onDidChangeValueDisposable?.dispose(); + return this._replyMessage(message, chooseResult); + } - if (!isBase && this.defaultCompareBranch?.name && !newBranches.includes(this.defaultCompareBranch.name)) { - newBranches.push(this.defaultCompareBranch.name); - newBranches.sort(); + private async findIssueContext(commits: string[]): Promise<{ content: string, reference: string }[] | undefined> { + const issues: Promise<{ content: string, reference: string } | undefined>[] = []; + for (const commit of commits) { + const tryParse = parseIssueExpressionOutput(commit.match(ISSUE_OR_URL_EXPRESSION)); + if (tryParse) { + const owner = tryParse.owner ?? this.model.baseOwner; + const name = tryParse.name ?? this.model.repositoryName; + issues.push(new Promise(resolve => { + this._folderRepositoryManager.resolveIssue(owner, name, tryParse.issueNumber).then(issue => { + if (issue) { + resolve({ content: `${issue.title}\n${issue.body}`, reference: getIssueNumberLabelFromParsed(tryParse) }); + } else { + resolve(undefined); + } + }).catch(() => resolve(undefined)); + })); + } } + if (issues.length) { + return (await Promise.all(issues)).filter(issue => !!issue) as { content: string, reference: string }[]; + } + return undefined; + } - let newBranch: string | undefined; - if (isBase) { - newBranch = defaultBranch; - this._baseBranch = defaultBranch; - this._baseRemote = { owner, repositoryName }; - this._onDidChangeBaseRemote.fire({ owner, repositoryName }); - this._onDidChangeBaseBranch.fire(defaultBranch); + private async getCommitsAndPatches(): Promise<{ commitMessages: string[], patches: { patch: string, fileUri: string, previousFileUri?: string }[] }> { + let commitMessages: string[]; + let patches: ({ patch: string, fileUri: string, previousFileUri?: string } | undefined)[] | undefined; + if (await this.model.getCompareHasUpstream()) { + [commitMessages, patches] = await Promise.all([ + this.model.gitHubCommits().then(rawCommits => rawCommits.map(commit => commit.commit.message)), + this.model.gitHubFiles().then(rawPatches => rawPatches?.map(file => { + if (!file.patch) { + return; + } + const fileUri = vscode.Uri.joinPath(this._folderRepositoryManager.repository.rootUri, file.filename).toString(); + const previousFileUri = file.previous_filename ? vscode.Uri.joinPath(this._folderRepositoryManager.repository.rootUri, file.previous_filename).toString() : undefined; + return { patch: file.patch, fileUri, previousFileUri }; + }))]); } else { - if (this.defaultCompareBranch?.name) { - newBranch = this.defaultCompareBranch?.name; - this._compareBranch = this.defaultCompareBranch?.name; - } - this._onDidChangeCompareRemote.fire({ owner, repositoryName }); + [commitMessages, patches] = await Promise.all([ + this.model.gitCommits().then(rawCommits => rawCommits.filter(commit => commit.parents.length === 1).map(commit => commit.message)), + Promise.all((await this.model.gitFiles()).map(async (file) => { + return { + patch: await this._folderRepositoryManager.repository.diffBetween(this.model.baseBranch, this.model.compareBranch, file.uri.fsPath), + fileUri: file.uri.toString(), + }; + }))]); } + const filteredPatches: { patch: string, fileUri: string, previousFileUri?: string }[] = + patches?.filter<{ patch: string, fileUri: string, previousFileUri?: string }>((patch): patch is { patch: string, fileUri: string, previousFileUri?: string } => !!patch) ?? []; + return { commitMessages, patches: filteredPatches }; + } - // TODO: if base is change need to update auto merge - return this._replyMessage(message, { branches: newBranches, defaultBranch: newBranch }); + private lastGeneratedTitleAndDescription: { title?: string, description?: string, providerTitle: string } | undefined; + 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, template }, token); + + if (provider) { + this.lastGeneratedTitleAndDescription = { ...result, providerTitle: provider.title }; + /* __GDPR__ + "pr.generatedTitleAndDescription" : { + "providerTitle" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this.telemetry.sendTelemetryEvent('pr.generatedTitleAndDescription', { providerTitle: provider?.title }); + } + return result; + } catch (e) { + Logger.error(`Error while generating title and description: ${e}`, CreatePullRequestViewProvider.ID); + return undefined; + } + }); } - private async autoAssign(pr: PullRequestModel): Promise { - const configuration = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get(ASSIGN_TO); - if (!configuration) { - return; - } - const resolved = await variableSubstitution(configuration, pr, undefined, (await this._folderRepositoryManager.getCurrentUser(pr.githubRepository))?.login); - if (!resolved) { - return; + private generatingCancellationToken: vscode.CancellationTokenSource | undefined; + private async generateTitleAndDescription(message: IRequestMessage): Promise { + if (this.generatingCancellationToken) { + this.generatingCancellationToken.cancel(); } - try { - await pr.addAssignees([resolved]); - } catch (e) { - Logger.error(`Unable to assign pull request to user ${resolved}.`); + this.generatingCancellationToken = new vscode.CancellationTokenSource(); + + + const result = await Promise.race([this.getTitleAndDescriptionFromProvider(this.generatingCancellationToken.token, message.args.useCopilot ? 'Copilot' : undefined), + new Promise(resolve => this.generatingCancellationToken?.token.onCancellationRequested(() => resolve(true)))]); + + this.generatingCancellationToken = undefined; + + const generated: { title: string | undefined, description: string | undefined } = { title: undefined, description: undefined }; + if (result !== true) { + generated.title = result?.title; + generated.description = result?.description; } + return this._replyMessage(message, { title: generated?.title, description: generated?.description }); } - private async enableAutoMerge(pr: PullRequestModel, autoMerge: boolean, automergeMethod: MergeMethod | undefined): Promise { - if (autoMerge && automergeMethod) { - return pr.enableAutoMerge(automergeMethod); + private async cancelGenerateTitleAndDescription(): Promise { + if (this.generatingCancellationToken) { + this.generatingCancellationToken.cancel(); } } - private async setLabels(pr: PullRequestModel, labels: ILabel[]): Promise { - if (labels.length > 0) { - await pr.setLabels(labels.map(label => label.name)); + private async getPreReviewFromProvider(token: vscode.CancellationToken): Promise { + const preReviewer = this._folderRepositoryManager.getAutoReviewer(); + if (!preReviewer) { + return; } + const { commitMessages, patches } = await this.getCommitsAndPatches(); + const result = await preReviewer.provider.provideReviewerComments({ repositoryRoot: this._folderRepositoryManager.repository.rootUri.fsPath, commitMessages, patches }, token); + return (result && result.succeeded && result.files.length > 0) ? PreReviewState.ReviewedWithComments : PreReviewState.ReviewedWithoutComments; } - private labels: ILabel[] = []; - public async addLabels(): Promise { - let newLabels: ILabel[] = []; + public async review(): Promise { + this._postMessage({ command: 'reviewing', params: { reviewing: true } }); + } - async function getLabelOptions( - folderRepoManager: FolderRepositoryManager, - labels: ILabel[], - base: RemoteInfo - ): Promise { - newLabels = await folderRepoManager.getLabels(undefined, { owner: base.owner, repo: base.repositoryName }); + private reviewingCancellationToken: vscode.CancellationTokenSource | undefined; + private async preReview(message: IRequestMessage): Promise { + return CreatePullRequestViewProvider.withProgress(async () => { + await commands.setContext('pr:preReviewing', true); - return newLabels.map(label => { - return { - label: label.name, - picked: labels.some(existingLabel => existingLabel.name === label.name) - }; - }); - } + if (this.reviewingCancellationToken) { + this.reviewingCancellationToken.cancel(); + } + this.reviewingCancellationToken = new vscode.CancellationTokenSource(); - const labelsToAdd = await vscode.window.showQuickPick( - getLabelOptions(this._folderRepositoryManager, this.labels, this._baseRemote), - { canPickMany: true }, - ); + const result = await Promise.race([this.getPreReviewFromProvider(this.reviewingCancellationToken.token), + new Promise(resolve => this.reviewingCancellationToken?.token.onCancellationRequested(() => resolve()))]); - if (labelsToAdd && labelsToAdd.length) { - const addedLabels: ILabel[] = labelsToAdd.map(label => newLabels.find(l => l.name === label.label)!); - this.labels = addedLabels; - this._postMessage({ - command: 'set-labels', - params: { labels: this.labels } - }); + this.reviewingCancellationToken = undefined; + await commands.setContext('pr:preReviewing', false); + + return this._replyMessage(message, result); + }); + } + + private async cancelPreReview(): Promise { + if (this.reviewingCancellationToken) { + this.reviewingCancellationToken.cancel(); } } @@ -466,23 +1306,112 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs }); if (pushRemote && createdPushRemote) { - Logger.appendLine(`Found push remote ${pushRemote.name} for ${compareOwner}/${compareRepositoryName} and branch ${compareBranchName}`, 'CreatePullRequestViewProvider'); - await this._folderRepositoryManager.repository.push(pushRemote.name, compareBranchName, true); - return { compareUpstream: createdPushRemote, repo: this._folderRepositoryManager.findRepo(byRemoteName(createdPushRemote.remoteName)) }; + Logger.appendLine(`Found push remote ${pushRemote.name} for ${compareOwner}/${compareRepositoryName} and branch ${compareBranchName}`, CreatePullRequestViewProvider.ID); + const actualPushRemote = await this._folderRepositoryManager.publishBranch(createdPushRemote, compareBranchName); + if (!actualPushRemote) { + return undefined; + } + return { compareUpstream: actualPushRemote, repo: this._folderRepositoryManager.findRepo(byRemoteName(actualPushRemote.remoteName)) }; + } + } + + private checkGeneratedTitleAndDescription(title: string, description: string) { + if (!this.lastGeneratedTitleAndDescription) { + return; + } + const usedGeneratedTitle: boolean = !!this.lastGeneratedTitleAndDescription.title && ((this.lastGeneratedTitleAndDescription.title === title) || this.lastGeneratedTitleAndDescription.title?.includes(title) || title?.includes(this.lastGeneratedTitleAndDescription.title)); + const usedGeneratedDescription: boolean = !!this.lastGeneratedTitleAndDescription.description && ((this.lastGeneratedTitleAndDescription.description === description) || this.lastGeneratedTitleAndDescription.description?.includes(description) || description?.includes(this.lastGeneratedTitleAndDescription.description)); + /* __GDPR__ + "pr.usedGeneratedTitleAndDescription" : { + "providerTitle" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "usedGeneratedTitle" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "usedGeneratedDescription" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this.telemetry.sendTelemetryEvent('pr.usedGeneratedTitleAndDescription', { providerTitle: this.lastGeneratedTitleAndDescription.providerTitle, usedGeneratedTitle: usedGeneratedTitle.toString(), usedGeneratedDescription: usedGeneratedDescription.toString() }); + } + + /** + * + * @returns true if the PR should be created immediately after + */ + private async checkForChanges(): Promise { + if (await this.model.filesHaveChanges()) { + const apply = vscode.l10n.t('Commit'); + const deleteChanges = vscode.l10n.t('Delete my changes'); + const result = await vscode.window.showWarningMessage(vscode.l10n.t('You have made changes to the files in this pull request. Do you want to commit these changes to the pull request before creating it?'), { modal: true }, apply, deleteChanges); + if (result === apply) { + const commitMessage = await vscode.window.showInputBox({ prompt: vscode.l10n.t('Commit message for your changes') }); + if (commitMessage) { + return this.model.applyChanges(commitMessage); + } + } else if (result !== deleteChanges) { + return false; + } } + return true; } - private async create(message: IRequestMessage): Promise { - vscode.window.withProgress({ location: { viewId: 'github:createPullRequest' } }, () => { + protected async create(message: IRequestMessage): Promise { + Logger.debug(`Creating pull request with args ${JSON.stringify(message.args)}`, CreatePullRequestViewProvider.ID); + + if (!(await this.checkForChanges())) { + Logger.debug('Not continuing past checking for file changes.', CreatePullRequestViewProvider.ID); + await this._replyMessage(message, {}); + return; + } + + // Save create method + const createMethod: { autoMerge: boolean, mergeMethod: MergeMethod | undefined, isDraft: boolean } = { autoMerge: message.args.autoMerge, mergeMethod: message.args.autoMergeMethod, isDraft: message.args.draft }; + this._folderRepositoryManager.context.workspaceState.update(PREVIOUS_CREATE_METHOD, createMethod); + + CreatePullRequestViewProvider.withProgress(() => { return vscode.window.withProgress({ location: vscode.ProgressLocation.Notification }, async progress => { + commands.setContext(contexts.CREATING, true); let totalIncrement = 0; progress.report({ message: vscode.l10n.t('Checking for upstream branch'), increment: totalIncrement }); + let createdPR: PullRequestModel | undefined = undefined; try { const compareOwner = message.args.compareOwner; const compareRepositoryName = message.args.compareRepo; const compareBranchName = message.args.compareBranch; const compareGithubRemoteName = `${compareOwner}/${compareRepositoryName}`; - const compareBranch = await this._folderRepositoryManager.repository.getBranch(compareBranchName); + let compareBranch = await this._folderRepositoryManager.repository.getBranch(compareBranchName); + + // Fetch upstream to get accurate ahead/behind count + if (compareBranch.upstream) { + await this._folderRepositoryManager.repository.fetch(compareBranch.upstream.remote, compareBranch.upstream.name); + // Re-fetch branch info after fetch to get accurate ahead count + compareBranch = await this._folderRepositoryManager.repository.getBranch(compareBranchName); + } + + // Check for unpushed commits when there's an upstream + if (compareBranch.upstream && compareBranch.ahead && compareBranch.ahead > 0) { + const pushCommits = vscode.l10n.t('Push Commits'); + const continueWithoutPushing = vscode.l10n.t('Continue Without Pushing'); + const commitCount = compareBranch.ahead; + const messageResult = await vscode.window.showInformationMessage( + vscode.l10n.t({ + message: 'You have {0} unpushed commit(s) on \'{1}\'.\n\nDo you want to push them before creating the pull request?', + comment: ['{0} is the number of commits, {1} is the branch name'], + args: [commitCount, compareBranchName] + }), + { modal: true }, + pushCommits, + continueWithoutPushing + ); + if (messageResult === pushCommits) { + progress.report({ message: vscode.l10n.t('Pushing commits'), increment: 10 }); + totalIncrement += 10; + await this._folderRepositoryManager.repository.push(compareBranch.upstream.remote, compareBranchName); + } else if (messageResult !== continueWithoutPushing) { + // User cancelled (clicked X or pressed Escape) + progress.report({ message: vscode.l10n.t('Pull request cancelled'), increment: 100 - totalIncrement }); + return; + } + // If continueWithoutPushing was selected, just continue with PR creation + } + let headRepo = compareBranch.upstream ? this._folderRepositoryManager.findRepo((githubRepo) => { return (githubRepo.remote.owner === compareOwner) && (githubRepo.remote.repositoryName === compareRepositoryName); }) : undefined; @@ -495,15 +1424,18 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs // We assume this happens only when the compare branch is based on the current branch. const alwaysPublish = vscode.l10n.t('Always Publish Branch'); const publish = vscode.l10n.t('Publish Branch'); - const pushBranchSetting = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get(PUSH_BRANCH) === 'always'; + const pushBranchSetting = + vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(PUSH_BRANCH) === 'always'; const messageResult = !pushBranchSetting ? await vscode.window.showInformationMessage( - vscode.l10n.t('There is no upstream branch for \'{0}\'.\n\nDo you want to publish it and then create the pull request?', compareBranchName), + vscode.l10n.t('There is no remote branch on {0}/{1} for \'{2}\'.\n\nDo you want to publish it and then create the pull request?', compareOwner, compareRepositoryName, compareBranchName), { modal: true }, publish, alwaysPublish) : publish; if (messageResult === alwaysPublish) { - await vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).update(PUSH_BRANCH, 'always', vscode.ConfigurationTarget.Global); + await vscode.workspace + .getConfiguration(PR_SETTINGS_NAMESPACE) + .update(PUSH_BRANCH, 'always', vscode.ConfigurationTarget.Global); } if ((messageResult === alwaysPublish) || (messageResult === publish)) { progress.report({ message: vscode.l10n.t('Pushing branch'), increment: 10 }); @@ -519,7 +1451,7 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs } } if (!existingCompareUpstream) { - this._throwError(message, vscode.l10n.t('No upstream for the compare branch.')); + this._throwError(message, vscode.l10n.t('No remote branch on {0}/{1} for the merge branch.', compareOwner, compareRepositoryName)); progress.report({ message: vscode.l10n.t('Pull request cancelled'), increment: 100 - totalIncrement }); return; } @@ -531,105 +1463,99 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs progress.report({ message: vscode.l10n.t('Creating pull request'), increment: 70 - totalIncrement }); totalIncrement += 70 - totalIncrement; const head = `${headRepo.remote.owner}:${compareBranchName}`; - const createdPR = await this._folderRepositoryManager.createPullRequest({ ...message.args, head }); + this.checkGeneratedTitleAndDescription(message.args.title, message.args.body); + createdPR = await this._folderRepositoryManager.createPullRequest({ ...message.args, head }); // Create was cancelled if (!createdPR) { this._throwError(message, vscode.l10n.t('There must be a difference in commits to create a pull request.')); } else { - await Promise.all([ - this.setLabels(createdPR, message.args.labels), - this.enableAutoMerge(createdPR, message.args.autoMerge, message.args.autoMergeMethod), - this.autoAssign(createdPR)]); - await this._replyMessage(message, {}); - this._onDone.fire(createdPR); + // Save the base branch to recently used branches after successful PR creation + this.saveRecentlyUsedBranch(message.args.owner, message.args.repo, message.args.base); + await this.postCreate(message, createdPR); } } catch (e) { - this._throwError(message, e.message); + if (!createdPR) { + let errorMessage: string = e.message; + if (errorMessage.startsWith('GraphQL error: ')) { + errorMessage = errorMessage.substring('GraphQL error: '.length); + } + this._throwError(message, errorMessage); + } else { + if ((e as Error).message === 'GraphQL error: ["Pull request Pull request is in unstable status"]') { + // This error can happen if the PR isn't fully created by the time we try to set properties on it. Try again. + await this.postCreate(message, createdPR); + } + // All of these errors occur after the PR is created, so the error is not critical. + vscode.window.showErrorMessage(vscode.l10n.t('There was an error creating the pull request: {0}', (e as Error).message)); + } } finally { - progress.report({ message: vscode.l10n.t('Pull request created'), increment: 100 - totalIncrement }); + commands.setContext(contexts.CREATING, false); + + let completeMessage: string; + if (createdPR) { + this._onDone.fire(createdPR); + completeMessage = vscode.l10n.t('Pull request created'); + } else { + await this._replyMessage(message, {}); + completeMessage = vscode.l10n.t('Unable to create pull request'); + } + progress.report({ message: completeMessage, increment: 100 - totalIncrement }); } }); }); } - private async changeBranch(message: IRequestMessage, isBase: boolean): Promise { - const newBranch = (typeof message.args === 'string') ? message.args : message.args.name; + private async changeBranch(newBranch: string, isBase: boolean): Promise<{ title: string, description: string }> { let compareBranch: Branch | undefined; if (isBase) { - this._baseBranch = newBranch; - this._onDidChangeBaseBranch.fire(newBranch); + this.model.baseBranch = newBranch; } else { try { compareBranch = await this._folderRepositoryManager.repository.getBranch(newBranch); - this._onDidChangeCompareBranch.fire(compareBranch.name!); } catch (e) { vscode.window.showErrorMessage(vscode.l10n.t('Branch does not exist locally.')); } + if (compareBranch) { + await this.model.setCompareBranch(newBranch); + } } - compareBranch = compareBranch ?? await this._folderRepositoryManager.repository.getBranch(this._compareBranch); - const titleAndDescription = await this.getTitleAndDescription(compareBranch, this._baseBranch); - return this._replyMessage(message, { title: titleAndDescription.title, description: titleAndDescription.description }); - } - - private async cancel(message: IRequestMessage) { - vscode.commands.executeCommand('setContext', 'github:createPullRequest', false); - this._onDone.fire(undefined); - // Re-fetch the automerge info so that it's updated for next time. - await this.getMergeConfiguration(message.args.owner, message.args.repo, true); - return this._replyMessage(message, undefined); + compareBranch = compareBranch ?? await this._folderRepositoryManager.repository.getBranch(this.model.compareBranch); + return this.getTitleAndDescription(compareBranch, this.model.baseBranch); } - protected async _onDidReceiveMessage(message: IRequestMessage) { + protected override async _onDidReceiveMessage(message: IRequestMessage) { const result = await super._onDidReceiveMessage(message); if (result !== this.MESSAGE_UNHANDLED) { return; } switch (message.command) { - case 'pr.cancelCreate': - return this.cancel(message); + case 'pr.changeBaseRemoteAndBranch': + return this.changeRemoteAndBranch(message, true); - case 'pr.create': - return this.create(message); + case 'pr.changeCompareRemoteAndBranch': + return this.changeRemoteAndBranch(message, false); - case 'pr.changeBaseRemote': - return this.changeRemote(message, true); + case 'pr.generateTitleAndDescription': + return this.generateTitleAndDescription(message); - case 'pr.changeBaseBranch': - return this.changeBranch(message, true); + case 'pr.cancelGenerateTitleAndDescription': + return this.cancelGenerateTitleAndDescription(); - case 'pr.changeCompareRemote': - return this.changeRemote(message, false); + case 'pr.changeTemplate': + return this.changeTemplate(message); - case 'pr.changeCompareBranch': - return this.changeBranch(message, false); + case 'pr.preReview': + return this.preReview(message); + + case 'pr.cancelPreReview': + return this.cancelPreReview(); default: // Log error vscode.window.showErrorMessage('Unsupported webview message'); } } - - private _getHtmlForWebview() { - const nonce = getNonce(); - - const uri = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview-create-pr-view.js'); - - return ` - - - - - - - Create Pull Request - - -
- - -`; - } } diff --git a/src/github/credentials.ts b/src/github/credentials.ts index 9e0a64dbde..ce2f62b102 100644 --- a/src/github/credentials.ts +++ b/src/github/credentials.ts @@ -9,15 +9,18 @@ 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'; import Logger from '../common/logger'; import * as PersistentState from '../common/persistentState'; +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 defaultSchema from './queries.gql'; -import { getEnterpriseUri, hasEnterpriseUri } from './utils'; const TRY_AGAIN = vscode.l10n.t('Try again?'); const CANCEL = vscode.l10n.t('Cancel'); @@ -28,68 +31,143 @@ const PROMPT_FOR_SIGN_IN_SCOPE = vscode.l10n.t('prompt for sign in'); const PROMPT_FOR_SIGN_IN_STORAGE_KEY = 'login'; // If the scopes are changed, make sure to notify all interested parties to make sure this won't cause problems. -const SCOPES_OLD = ['read:user', 'user:email', 'repo']; -export const SCOPES = ['read:user', 'user:email', 'repo', 'workflow']; +const SCOPES_OLDEST = ['read:user', 'user:email', 'repo']; +const SCOPES_OLD = ['read:user', 'user:email', 'repo', 'workflow']; +const SCOPES_WITH_ADDITIONAL = ['read:user', 'user:email', 'repo', 'workflow', 'project', 'read:org']; + +const LAST_USED_SCOPES_GITHUB_KEY = 'githubPullRequest.lastUsedScopes'; +const LAST_USED_SCOPES_ENTERPRISE_KEY = 'githubPullRequest.lastUsedScopesEnterprise'; export interface GitHub { octokit: LoggingOctokit; graphql: LoggingApolloClient; currentUser?: Promise; + isEmu?: Promise; } -export class CredentialStore implements vscode.Disposable { +interface AuthResult { + canceled: boolean; +} + +export class CredentialStore extends Disposable { + private static readonly ID = 'Authentication'; private _githubAPI: GitHub | undefined; private _sessionId: string | undefined; private _githubEnterpriseAPI: GitHub | undefined; private _enterpriseSessionId: string | undefined; - private _disposables: vscode.Disposable[]; + private _isInitialized: boolean = false; private _onDidInitialize: vscode.EventEmitter = new vscode.EventEmitter(); public readonly onDidInitialize: vscode.Event = this._onDidInitialize.event; + private _scopes: string[] = SCOPES_OLD; + private _scopesEnterprise: string[] = SCOPES_OLD; + private _isSamling: boolean = false; + + private _onDidChangeSessions: vscode.EventEmitter = new vscode.EventEmitter(); + public readonly onDidChangeSessions = this._onDidChangeSessions.event; private _onDidGetSession: vscode.EventEmitter = new vscode.EventEmitter(); public readonly onDidGetSession = this._onDidGetSession.event; - constructor(private readonly _telemetry: ITelemetry, private readonly _context: vscode.ExtensionContext) { - this._disposables = []; - this._disposables.push( - vscode.authentication.onDidChangeSessions(async () => { - const promises: Promise[] = []; - if (!this.isAuthenticated(AuthProvider.github)) { - promises.push(this.initialize(AuthProvider.github)); - } + private _onDidUpgradeSession: vscode.EventEmitter = new vscode.EventEmitter(); + public readonly onDidUpgradeSession = this._onDidUpgradeSession.event; - if (!this.isAuthenticated(AuthProvider['github-enterprise']) && hasEnterpriseUri()) { - promises.push(this.initialize(AuthProvider['github-enterprise'])); - } + constructor(private readonly _telemetry: ITelemetry, private readonly context: vscode.ExtensionContext) { + super(); + this.setScopesFromState(); - await Promise.all(promises); - if (this.isAnyAuthenticated()) { - this._onDidGetSession.fire(); - } - }), - ); + this._register(vscode.authentication.onDidChangeSessions((e) => this.handlOnDidChangeSessions(e))); } - private async initialize(authProviderId: AuthProvider, getAuthSessionOptions: vscode.AuthenticationGetSessionOptions = {}): Promise { - if (authProviderId === AuthProvider['github-enterprise']) { + private async handlOnDidChangeSessions(e: vscode.AuthenticationSessionsChangeEvent) { + const currentProvider = (e.provider.id === AuthProvider.github && this._githubAPI) ? AuthProvider.github : ((e.provider.id === AuthProvider.githubEnterprise && this._githubEnterpriseAPI) ? AuthProvider.githubEnterprise : undefined); + if ((this._githubAPI || this._githubEnterpriseAPI) && !currentProvider) { + return; + } + if (currentProvider) { + const newSession = await this.getSession(currentProvider, { silent: true }, currentProvider === AuthProvider.github ? this._scopes : this._scopesEnterprise, true); + if (newSession.session?.id === this._sessionId) { + return; + } + if (currentProvider === AuthProvider.github) { + this._githubAPI = undefined; + this._sessionId = undefined; + } else { + this._githubEnterpriseAPI = undefined; + this._enterpriseSessionId = undefined; + } + } + const promises: Promise[] = []; + if (!this.isAuthenticated(AuthProvider.github)) { + promises.push(this.initialize(AuthProvider.github)); + } + + if (!this.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { + promises.push(this.initialize(AuthProvider.githubEnterprise)); + } + + await Promise.all(promises); + if (this.isAnyAuthenticated()) { + this._onDidGetSession.fire(); + } else if (!this._isSamling) { + this._onDidChangeSessions.fire(e); + } + } + + private allScopesIncluded(actualScopes: string[], requiredScopes: string[]) { + return requiredScopes.every(scope => actualScopes.includes(scope)); + } + + private setScopesFromState() { + this._scopes = this.context.globalState.get(LAST_USED_SCOPES_GITHUB_KEY, SCOPES_OLD); + this._scopesEnterprise = this.context.globalState.get(LAST_USED_SCOPES_ENTERPRISE_KEY, SCOPES_OLD); + } + + get scopes() { + return this._scopes; + } + + private async saveScopesInState() { + await this.context.globalState.update(LAST_USED_SCOPES_GITHUB_KEY, this._scopes); + await this.context.globalState.update(LAST_USED_SCOPES_ENTERPRISE_KEY, this._scopesEnterprise); + } + private async initialize(authProviderId: AuthProvider, getAuthSessionOptions: vscode.AuthenticationGetSessionOptions = {}, scopes: string[] = (!isEnterprise(authProviderId) ? this._scopes : this._scopesEnterprise), requireScopes?: boolean): Promise { + Logger.debug(`Initializing GitHub${getGitHubSuffix(authProviderId)} authentication provider.`, 'Authentication'); + if (isEnterprise(authProviderId)) { if (!hasEnterpriseUri()) { Logger.debug(`GitHub Enterprise provider selected without URI.`, 'Authentication'); - return; + return { canceled: false }; } } - if (getAuthSessionOptions.createIfNone === undefined) { + if (getAuthSessionOptions.createIfNone === undefined && getAuthSessionOptions.forceNewSession === undefined) { getAuthSessionOptions.createIfNone = false; } let session: vscode.AuthenticationSession | undefined = undefined; let isNew: boolean = false; + let usedScopes: string[] | undefined = SCOPES_OLD; + const oldScopes = this._scopes; + const oldEnterpriseScopes = this._scopesEnterprise; + const authResult: AuthResult = { canceled: false }; try { - const result = await this.getSession(authProviderId, getAuthSessionOptions); + // Set scopes before getting the session to prevent new session events from using the old scopes. + if (!isEnterprise(authProviderId)) { + this._scopes = scopes; + } else { + this._scopesEnterprise = scopes; + } + const result = await this.getSession(authProviderId, getAuthSessionOptions, scopes, !!requireScopes); + usedScopes = result.scopes; session = result.session; isNew = result.isNew; } catch (e) { - if (getAuthSessionOptions.forceNewSession && (e.message === 'User did not consent to login.')) { + this._scopes = oldScopes; + this._scopesEnterprise = oldEnterpriseScopes; + const userCanceld = (e.message === 'User did not consent to login.'); + if (userCanceld) { + authResult.canceled = true; + } + if (getAuthSessionOptions.forceNewSession && userCanceld) { // There are cases where a forced login may not be 100% needed, so just continue as usual if // the user didn't consent to the login prompt. } else { @@ -98,7 +176,7 @@ export class CredentialStore implements vscode.Disposable { } if (session) { - if (authProviderId === AuthProvider.github) { + if (!isEnterprise(authProviderId)) { this._sessionId = session.id; } else { this._enterpriseSessionId = session.id; @@ -108,37 +186,71 @@ export class CredentialStore implements vscode.Disposable { github = await this.createHub(session.accessToken, authProviderId); } catch (e) { if ((e.message === 'Bad credentials') && !getAuthSessionOptions.forceNewSession) { + Logger.debug(`Creating hub failed ${e.message}`, CredentialStore.ID); getAuthSessionOptions.forceNewSession = true; getAuthSessionOptions.silent = false; - return this.initialize(authProviderId, getAuthSessionOptions); + return this.initialize(authProviderId, getAuthSessionOptions, scopes, requireScopes); + } else { + // console.log because we need to see if we can learn more from the error object. + console.log(e); + Logger.error(`Creating hub failed ${e.message}`, CredentialStore.ID); + vscode.window.showErrorMessage(vscode.l10n.t('Unable to sign in with the provided credentials')); } } - if (authProviderId === AuthProvider.github) { + if (!isEnterprise(authProviderId)) { + Logger.debug('Setting hub and scopes', CredentialStore.ID); this._githubAPI = github; + this._scopes = usedScopes; } else { + Logger.debug('Setting enterprise hub and scopes', CredentialStore.ID); this._githubEnterpriseAPI = github; + this._scopesEnterprise = usedScopes; } + await this.saveScopesInState(); - if (!(getAuthSessionOptions.createIfNone || getAuthSessionOptions.forceNewSession) || isNew) { + if (!this._isInitialized || (isNew && !this._isSamling)) { + this._isInitialized = true; this._onDidInitialize.fire(); } + if (isNew) { + /* __GDPR__ + "auth.session" : {} + */ + this._telemetry.sendTelemetryEvent('auth.session'); + } + return authResult; } else { - Logger.debug(`No GitHub${getGitHubSuffix(authProviderId)} token found.`, 'Authentication'); + Logger.debug(`No GitHub${getGitHubSuffix(authProviderId)} token found.`, CredentialStore.ID); + return authResult; } } - private async doCreate(options: vscode.AuthenticationGetSessionOptions) { - await this.initialize(AuthProvider.github, options); + private async doCreate(options: vscode.AuthenticationGetSessionOptions, additionalScopes: boolean = false): Promise { + let enterprise: AuthResult | undefined; + const initializeEnterprise = async () => { + enterprise = await this.initialize(AuthProvider.githubEnterprise, options, additionalScopes ? SCOPES_WITH_ADDITIONAL : undefined, additionalScopes); + }; if (hasEnterpriseUri()) { - await this.initialize(AuthProvider['github-enterprise'], options); + await initializeEnterprise(); + } else { + // Listen for changes to the enterprise URI and try again if it changes. + initBasedOnSettingChange(GITHUB_ENTERPRISE, URI, hasEnterpriseUri, initializeEnterprise, this.context.subscriptions); + } + const githubOptions = { ...options }; + if (enterprise && !enterprise.canceled) { + githubOptions.silent = true; } + const github = await this.initialize(AuthProvider.github, githubOptions, additionalScopes ? SCOPES_WITH_ADDITIONAL : undefined, additionalScopes); + return { + canceled: github.canceled || !!(enterprise && enterprise.canceled) + }; } - public async create(options: vscode.AuthenticationGetSessionOptions = {}) { - return this.doCreate(options); + public async create(options: vscode.AuthenticationGetSessionOptions = {}, additionalScopes: boolean = false) { + return this.doCreate(options, additionalScopes); } - public async recreate(reason?: string) { + public async recreate(reason?: string): Promise { return this.doCreate({ forceNewSession: reason ? { detail: reason } : true }); } @@ -149,25 +261,82 @@ export class CredentialStore implements vscode.Disposable { } public isAnyAuthenticated() { - return this.isAuthenticated(AuthProvider.github) || this.isAuthenticated(AuthProvider['github-enterprise']); + return this.isAuthenticated(AuthProvider.github) || this.isAuthenticated(AuthProvider.githubEnterprise); } public isAuthenticated(authProviderId: AuthProvider): boolean { - if (authProviderId === AuthProvider.github) { + if (!isEnterprise(authProviderId)) { return !!this._githubAPI; } return !!this._githubEnterpriseAPI; } + public isAuthenticatedWithAdditionalScopes(authProviderId: AuthProvider): boolean { + if (!isEnterprise(authProviderId)) { + return !!this._githubAPI && this.allScopesIncluded(this._scopes, SCOPES_WITH_ADDITIONAL); + } + return !!this._githubEnterpriseAPI && this.allScopesIncluded(this._scopesEnterprise, SCOPES_WITH_ADDITIONAL); + } + public getHub(authProviderId: AuthProvider): GitHub | undefined { - if (authProviderId === AuthProvider.github) { + if (!isEnterprise(authProviderId)) { return this._githubAPI; } return this._githubEnterpriseAPI; } + public areScopesOld(authProviderId: AuthProvider): boolean { + if (!isEnterprise(authProviderId)) { + return !this.allScopesIncluded(this._scopes, SCOPES_OLD); + } + return !this.allScopesIncluded(this._scopesEnterprise, SCOPES_OLD); + } + + async tryPromptForCopilotAuth(): Promise { + if (this.isAnyAuthenticated()) { + return true; + } + + const chatSetupResult = await commands.executeCommand(commands.CHAT_SETUP_ACTION_ID, 'agent', { additionalScopes: this.scopes }); + if (!chatSetupResult) { + return false; + } + + const result = await this.create({ createIfNone: { detail: vscode.l10n.t('Sign in to start delegating tasks to the GitHub coding agent.') } }); + + /* __GDPR__ + "remoteAgent.command.auth" : { + "succeeded" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this._telemetry.sendTelemetryEvent('remoteAgent.command.auth', { + succeeded: result.canceled ? 'false' : 'true' + }); + + if (result.canceled) { + return false; + } + return true; + } + + public areScopesExtra(authProviderId: AuthProvider): boolean { + if (!isEnterprise(authProviderId)) { + return this.allScopesIncluded(this._scopes, SCOPES_WITH_ADDITIONAL); + } + return this.allScopesIncluded(this._scopesEnterprise, SCOPES_WITH_ADDITIONAL); + } + + public async getHubEnsureAdditionalScopes(authProviderId: AuthProvider): Promise { + const hasScopesAlready = this.isAuthenticatedWithAdditionalScopes(authProviderId); + await this.initialize(authProviderId, { createIfNone: !hasScopesAlready }, SCOPES_WITH_ADDITIONAL, true); + if (!hasScopesAlready) { + this._onDidUpgradeSession.fire(); + } + return this.getHub(authProviderId); + } + public async getHubOrLogin(authProviderId: AuthProvider): Promise { - if (authProviderId === AuthProvider.github) { + if (!isEnterprise(authProviderId)) { return this._githubAPI ?? (await this.login(authProviderId)); } return this._githubEnterpriseAPI ?? (await this.login(authProviderId)); @@ -207,23 +376,27 @@ export class CredentialStore implements vscode.Disposable { let retry: boolean = true; let octokit: GitHub | undefined = undefined; const sessionOptions: vscode.AuthenticationGetSessionOptions = { createIfNone: true }; - + let isCanceled: boolean = false; while (retry) { try { await this.initialize(authProviderId, sessionOptions); } catch (e) { - Logger.error(`${errorPrefix}: ${e}`); + Logger.error(`Login error: ${errorPrefix}: ${e}`, CredentialStore.ID); if (e instanceof Error && e.stack) { - Logger.error(e.stack); + Logger.error(e.stack, CredentialStore.ID); + } + if (e.message === 'Cancelled') { + isCanceled = true; } } octokit = this.getHub(authProviderId); - if (octokit) { + if (octokit || isCanceled) { retry = false; } else { retry = (await vscode.window.showErrorMessage(errorPrefix, TRY_AGAIN, CANCEL)) === TRY_AGAIN; if (retry) { sessionOptions.forceNewSession = true; + sessionOptions.createIfNone = undefined; } } } @@ -243,12 +416,21 @@ export class CredentialStore implements vscode.Disposable { return octokit; } - public async showSamlMessageAndAuth() { - return this.recreate(vscode.l10n.t('GitHub Pull Requests and Issues requires that you provide SAML access to your organization when you sign in.')); + public async showSamlMessageAndAuth(organizations: string[]): Promise { + this._isSamling = true; + const result = await this.recreate(vscode.l10n.t('GitHub Pull Requests requires that you provide SAML access to your organization ({0}) when you sign in.', organizations.join(', '))); + this._isSamling = false; + return result; } - public async isCurrentUser(username: string): Promise { - return (await this._githubAPI?.currentUser)?.login === username || (await this._githubEnterpriseAPI?.currentUser)?.login == username; + public async isCurrentUser(authProviderId: AuthProvider, username: string): Promise { + const api = authProviderId === AuthProvider.github ? this._githubAPI : this._githubEnterpriseAPI; + return (await api?.currentUser)?.login === username; + } + + public async getIsEmu(authProviderId: AuthProvider): Promise { + const github = this.getHub(authProviderId); + return !!(await github?.isEmu); } public getCurrentUser(authProviderId: AuthProvider): Promise { @@ -258,55 +440,57 @@ export class CredentialStore implements vscode.Disposable { } private setCurrentUser(github: GitHub): void { - github.currentUser = new Promise(resolve => { - github.graphql.query({ query: (defaultSchema as any).Viewer }).then(result => { - resolve(result.data.viewer); + const getUser: ReturnType = new Promise((resolve, reject) => { + Logger.debug('Getting current user', CredentialStore.ID); + github.octokit.call(github.octokit.api.users.getAuthenticated, {}).then(result => { + Logger.debug(`Got current user ${result.data.login}`, CredentialStore.ID); + resolve(result); + }).catch(e => { + Logger.error(`Failed to get current user: ${e}, ${e.message}`, CredentialStore.ID); + reject(e); }); }); + github.currentUser = getUser.then(result => convertRESTUserToAccount(result.data)); + github.isEmu = getUser.then(result => result.data.plan?.name === 'emu_user'); } - private async getSession(authProviderId: AuthProvider, getAuthSessionOptions: vscode.AuthenticationGetSessionOptions): Promise<{ session: vscode.AuthenticationSession | undefined, isNew: boolean }> { - let session: vscode.AuthenticationSession | undefined = await vscode.authentication.getSession(authProviderId, SCOPES, { silent: true }); - if (session) { - return { session, isNew: false }; + private async getSession(authProviderId: AuthProvider, getAuthSessionOptions: vscode.AuthenticationGetSessionOptions, scopes: string[], requireScopes: boolean): Promise<{ session: vscode.AuthenticationSession | undefined, isNew: boolean, scopes: string[] }> { + const existingSession = (getAuthSessionOptions.forceNewSession || requireScopes) ? undefined : await this.findExistingScopes(authProviderId); + if (existingSession?.session) { + return { session: existingSession.session, isNew: false, scopes: existingSession.scopes }; } - if (getAuthSessionOptions.createIfNone) { - const silent = getAuthSessionOptions.silent; - getAuthSessionOptions.createIfNone = false; - getAuthSessionOptions.silent = true; - session = await vscode.authentication.getSession(authProviderId, SCOPES_OLD, getAuthSessionOptions); - if (!session) { - getAuthSessionOptions.createIfNone = true; - getAuthSessionOptions.silent = silent; - session = await vscode.authentication.getSession(authProviderId, SCOPES, getAuthSessionOptions); - } - } else { - session = await vscode.authentication.getSession(authProviderId, SCOPES_OLD, getAuthSessionOptions); - } - - return { session, isNew: !!session }; + const session = await vscode.authentication.getSession(authProviderId, requireScopes ? scopes : SCOPES_OLD, getAuthSessionOptions); + return { session, isNew: !!session, scopes: requireScopes ? scopes : SCOPES_OLD }; } - private async getSessionOrLogin(authProviderId: AuthProvider): Promise { - const session = (await this.getSession(authProviderId, { createIfNone: true })).session!; - if (authProviderId === AuthProvider.github) { - this._sessionId = session.id; - } else { - this._enterpriseSessionId = session.id; + private async findExistingScopes(authProviderId: AuthProvider): Promise<{ session: vscode.AuthenticationSession, scopes: string[] } | undefined> { + const scopesInPreferenceOrder = [SCOPES_WITH_ADDITIONAL, SCOPES_OLD, SCOPES_OLDEST]; + for (const scopes of scopesInPreferenceOrder) { + const session = await vscode.authentication.getSession(authProviderId, scopes, { silent: true }); + if (session) { + return { session, scopes }; + } } - return session.accessToken; } private async createHub(token: string, authProviderId: AuthProvider): Promise { let baseUrl = 'https://api.github.com'; let enterpriseServerUri: vscode.Uri | undefined; - if (authProviderId === AuthProvider['github-enterprise']) { + Logger.appendLine(`Creating hub for ${isEnterprise(authProviderId) ? 'enterprise' : '.com'}`, CredentialStore.ID); + if (isEnterprise(authProviderId)) { enterpriseServerUri = getEnterpriseUri(); } + const isGhe = enterpriseServerUri?.authority.endsWith('ghe.com'); + if (enterpriseServerUri) { - baseUrl = `${enterpriseServerUri.scheme}://${enterpriseServerUri.authority}/api/v3`; + Logger.appendLine(`Enterprise server authority ${enterpriseServerUri.authority}`, CredentialStore.ID); + if (isGhe) { + baseUrl = `${enterpriseServerUri.scheme}://api.${enterpriseServerUri.authority}`; + } else { + baseUrl = `${enterpriseServerUri.scheme}://${enterpriseServerUri.authority}/api/v3`; + } } let fetchCore: ((url: string, options: { headers?: Record }) => any) | undefined; @@ -331,12 +515,13 @@ export class CredentialStore implements vscode.Disposable { baseUrl: baseUrl, }); - if (enterpriseServerUri) { - baseUrl = `${enterpriseServerUri.scheme}://${enterpriseServerUri.authority}/api`; + let graphQLBaseUrl = baseUrl; + if (enterpriseServerUri && !isGhe) { + graphQLBaseUrl = `${enterpriseServerUri.scheme}://${enterpriseServerUri.authority}/api`; } const graphql = new ApolloClient({ - link: link(baseUrl, token || ''), + link: link(graphQLBaseUrl, token || ''), cache: new InMemoryCache(), defaultOptions: { query: { @@ -345,7 +530,7 @@ export class CredentialStore implements vscode.Disposable { }, }); - const rateLogger = new RateLogger(this._context); + const rateLogger = new RateLogger(this._telemetry, isEnterprise(authProviderId)); const github: GitHub = { octokit: new LoggingOctokit(octokit, rateLogger), graphql: new LoggingApolloClient(graphql, rateLogger), @@ -353,10 +538,6 @@ export class CredentialStore implements vscode.Disposable { this.setCurrentUser(github); return github; } - - dispose() { - this._disposables.forEach(disposable => disposable.dispose()); - } } const link = (url: string, token: string) => @@ -370,10 +551,10 @@ 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), }), ); function getGitHubSuffix(authProviderId: AuthProvider) { - return authProviderId === AuthProvider.github ? '' : ' Enterprise'; + return !isEnterprise(authProviderId) ? '' : ' Enterprise'; } 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 ff86f53183..b62f22994b 100644 --- a/src/github/folderRepositoryManager.ts +++ b/src/github/folderRepositoryManager.ts @@ -1,2389 +1,3337 @@ -/*--------------------------------------------------------------------------------------------- - * 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'; -import type { Branch, Repository, UpstreamRef } from '../api/api'; -import { GitApiImpl, GitErrorCodes } from '../api/api1'; -import { GitHubManager } from '../authentication/githubServer'; -import { AuthProvider, GitHubServerType } from '../common/authentication'; -import { commands, contexts } from '../common/executeCommands'; -import Logger from '../common/logger'; -import { Protocol, ProtocolType } from '../common/protocol'; -import { GitHubRemote, parseRepositoryRemotes, Remote } from '../common/remote'; -import { PULL_BRANCH } from '../common/settingKeys'; -import { ITelemetry } from '../common/telemetry'; -import { EventType, TimelineEvent } from '../common/timelineEvent'; -import { fromPRUri, Schemes } from '../common/uri'; -import { compareIgnoreCase, formatError, Predicate } from '../common/utils'; -import { PULL_REQUEST_OVERVIEW_VIEW_TYPE } from '../common/webview'; -import { EXTENSION_ID } from '../constants'; -import { NEVER_SHOW_PULL_NOTIFICATION, REPO_KEYS, ReposState } from '../extensionState'; -import { git } from '../gitProviders/gitCommands'; -import { UserCompletion, userMarkdown } from '../issues/util'; -import { OctokitCommon } from './common'; -import { CredentialStore } from './credentials'; -import { GitHubRepository, ItemsData, PullRequestData, ViewerPermission } from './githubRepository'; -import { PullRequestState, UserResponse } from './graphql'; -import { IAccount, ILabel, IMilestone, IPullRequestsPagingOptions, PRType, RepoAccessAndMergeMethods, User } from './interface'; -import { IssueModel } from './issueModel'; -import { MilestoneModel } from './milestoneModel'; -import { PullRequestGitHelper, PullRequestMetadata } from './pullRequestGitHelper'; -import { IResolvedPullRequestModel, PullRequestModel } from './pullRequestModel'; -import { - convertRESTIssueToRawPullRequest, - convertRESTPullRequestToRawPullRequest, - getOverrideBranch, - getRelatedUsersFromTimelineEvents, - loginComparator, - parseGraphQLUser, - variableSubstitution, -} from './utils'; - -interface PageInformation { - pullRequestPage: number; - hasMorePages: boolean | null; -} - -export interface ItemsResponseResult { - items: T[]; - hasMorePages: boolean; - hasUnsearchedRepositories: boolean; -} - -export class NoGitHubReposError extends Error { - constructor(public repository: Repository) { - super(); - } - - get message() { - return vscode.l10n.t('{0} has no GitHub remotes', this.repository.rootUri.toString()); - } -} - -export class DetachedHeadError extends Error { - constructor(public repository: Repository) { - super(); - } - - get message() { - return vscode.l10n.t('{0} has a detached HEAD (create a branch first', this.repository.rootUri.toString()); - } -} - -export class BadUpstreamError extends Error { - constructor(public branchName: string, public upstreamRef: UpstreamRef, public problem: string) { - super(); - } - - get message() { - const { - upstreamRef: { remote, name }, - branchName, - problem, - } = this; - return vscode.l10n.t('The upstream ref {0} for branch {1} {2}.', `${remote}/${name}`, branchName, problem); - } -} - -export const SETTINGS_NAMESPACE = 'githubPullRequests'; -export const REMOTES_SETTING = 'remotes'; - -export const ReposManagerStateContext: string = 'ReposManagerStateContext'; - -export enum ReposManagerState { - Initializing = 'Initializing', - NeedsAuthentication = 'NeedsAuthentication', - RepositoriesLoaded = 'RepositoriesLoaded', -} - -export interface PullRequestDefaults { - owner: string; - repo: string; - base: string; -} - -export const NO_MILESTONE: string = 'No Milestone'; - -enum PagedDataType { - PullRequest, - Milestones, - IssuesWithoutMilestone, - IssueSearch, -} - -export class FolderRepositoryManager implements vscode.Disposable { - static ID = 'FolderRepositoryManager'; - - private _subs: vscode.Disposable[]; - private _activePullRequest?: PullRequestModel; - private _activeIssue?: IssueModel; - private _githubRepositories: GitHubRepository[]; - private _allGitHubRemotes: GitHubRemote[] = []; - private _mentionableUsers?: { [key: string]: IAccount[] }; - private _fetchMentionableUsersPromise?: Promise<{ [key: string]: IAccount[] }>; - private _assignableUsers?: { [key: string]: IAccount[] }; - private _fetchAssignableUsersPromise?: Promise<{ [key: string]: IAccount[] }>; - private _gitBlameCache: { [key: string]: string } = {}; - private _githubManager: GitHubManager; - private _repositoryPageInformation: Map = new Map(); - - private _onDidMergePullRequest = new vscode.EventEmitter(); - readonly onDidMergePullRequest = this._onDidMergePullRequest.event; - - private _onDidChangeActivePullRequest = new vscode.EventEmitter(); - readonly onDidChangeActivePullRequest: vscode.Event = this._onDidChangeActivePullRequest.event; - private _onDidChangeActiveIssue = new vscode.EventEmitter(); - readonly onDidChangeActiveIssue: vscode.Event = this._onDidChangeActiveIssue.event; - - private _onDidLoadRepositories = new vscode.EventEmitter(); - readonly onDidLoadRepositories: vscode.Event = this._onDidLoadRepositories.event; - - private _onDidChangeRepositories = new vscode.EventEmitter(); - readonly onDidChangeRepositories: vscode.Event = this._onDidChangeRepositories.event; - - private _onDidChangeAssignableUsers = new vscode.EventEmitter(); - readonly onDidChangeAssignableUsers: vscode.Event = this._onDidChangeAssignableUsers.event; - - private _onDidChangeGithubRepositories = new vscode.EventEmitter(); - readonly onDidChangeGithubRepositories: vscode.Event = this._onDidChangeGithubRepositories.event; - - constructor( - public context: vscode.ExtensionContext, - private _repository: Repository, - public readonly telemetry: ITelemetry, - private _git: GitApiImpl, - private _credentialStore: CredentialStore, - ) { - this._subs = []; - this._githubRepositories = []; - this._githubManager = new GitHubManager(); - - this._subs.push( - vscode.workspace.onDidChangeConfiguration(async e => { - if (e.affectsConfiguration(`${SETTINGS_NAMESPACE}.${REMOTES_SETTING}`)) { - await this.updateRepositories(); - } - }), - ); - - this._subs.push(_credentialStore.onDidInitialize(() => this.updateRepositories())); - - this.setUpCompletionItemProvider(); - - this.cleanStoredRepoState(); - } - - private cleanStoredRepoState() { - const deleteDate: number = new Date().valueOf() - 30 /*days*/ * 86400000 /*milliseconds in a day*/; - const reposState = this.context.globalState.get(REPO_KEYS); - if (reposState?.repos) { - let keysChanged = false; - Object.keys(reposState.repos).forEach(repo => { - const repoState = reposState.repos[repo]; - if ((repoState.stateModifiedTime ?? 0) < deleteDate) { - keysChanged = true; - delete reposState.repos[repo]; - } - }); - if (keysChanged) { - this.context.globalState.update(REPO_KEYS, reposState); - } - } - } - - get gitHubRepositories(): GitHubRepository[] { - return this._githubRepositories; - } - - public async computeAllUnknownRemotes(): Promise { - const remotes = parseRepositoryRemotes(this.repository); - const potentialRemotes = remotes.filter(remote => remote.host); - const serverTypes = await Promise.all( - potentialRemotes.map(remote => this._githubManager.isGitHub(remote.gitProtocol.normalizeUri()!)), - ).catch(e => { - Logger.error(`Resolving GitHub remotes failed: ${e}`); - vscode.window.showErrorMessage(vscode.l10n.t('Resolving GitHub remotes failed: {0}', formatError(e))); - return []; - }); - const unknownRemotes: Remote[] = []; - let i = 0; - for (const potentialRemote of potentialRemotes) { - if (serverTypes[i] === GitHubServerType.None) { - unknownRemotes.push(potentialRemote); - } - i++; - } - return unknownRemotes; - } - - public async computeAllGitHubRemotes(): Promise { - const remotes = parseRepositoryRemotes(this.repository); - const potentialRemotes = remotes.filter(remote => remote.host); - const serverTypes = await Promise.all( - potentialRemotes.map(remote => this._githubManager.isGitHub(remote.gitProtocol.normalizeUri()!)), - ).catch(e => { - Logger.error(`Resolving GitHub remotes failed: ${e}`); - vscode.window.showErrorMessage(vscode.l10n.t('Resolving GitHub remotes failed: {0}', formatError(e))); - return []; - }); - const githubRemotes: GitHubRemote[] = []; - let i = 0; - for (const potentialRemote of potentialRemotes) { - if (serverTypes[i] !== GitHubServerType.None) { - githubRemotes.push(GitHubRemote.remoteAsGitHub(potentialRemote, serverTypes[i])); - } - i++; - } - return githubRemotes; - } - - public async getActiveGitHubRemotes(allGitHubRemotes: GitHubRemote[]): Promise { - const remotesSetting = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get(REMOTES_SETTING); - - if (!remotesSetting) { - Logger.error(`Unable to read remotes setting`); - return Promise.resolve([]); - } - - const missingRemotes = remotesSetting.filter(remote => { - return !allGitHubRemotes.some(repo => repo.remoteName === remote); - }); - - if (missingRemotes.length === remotesSetting.length) { - Logger.warn(`No remotes found. The following remotes are missing: ${missingRemotes.join(', ')}`); - } else { - Logger.debug(`Not all remotes found. The following remotes are missing: ${missingRemotes.join(', ')}`, FolderRepositoryManager.ID); - } - - Logger.debug(`Displaying configured remotes: ${remotesSetting.join(', ')}`, FolderRepositoryManager.ID); - - return remotesSetting - .map(remote => allGitHubRemotes.find(repo => repo.remoteName === remote)) - .filter((repo: GitHubRemote | undefined): repo is GitHubRemote => !!repo); - } - - public setUpCompletionItemProvider() { - let lastPullRequest: PullRequestModel | undefined = undefined; - let lastPullRequestTimelineEvents: TimelineEvent[] = []; - let cachedUsers: UserCompletion[] = []; - - vscode.languages.registerCompletionItemProvider( - { scheme: 'comment' }, - { - provideCompletionItems: async (document, position, _token) => { - try { - const query = JSON.parse(document.uri.query); - if (compareIgnoreCase(query.extensionId, EXTENSION_ID) !== 0) { - return; - } - - const wordRange = document.getWordRangeAtPosition( - position, - /@([a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38})?/i, - ); - if (!wordRange || wordRange.isEmpty) { - return; - } - - let prRelatedusers: { login: string; name?: string }[] = []; - const fileRelatedUsersNames: { [key: string]: boolean } = {}; - let mentionableUsers: { [key: string]: { login: string; name?: string }[] } = {}; - let prNumber: number | undefined; - let remoteName: string | undefined; - - const activeTextEditors = vscode.window.visibleTextEditors; - if (activeTextEditors.length) { - const visiblePREditor = activeTextEditors.find( - editor => editor.document.uri.scheme === Schemes.Pr, - ); - - if (visiblePREditor) { - const params = fromPRUri(visiblePREditor.document.uri); - prNumber = params!.prNumber; - remoteName = params!.remoteName; - } else if (this._activePullRequest) { - prNumber = this._activePullRequest.number; - remoteName = this._activePullRequest.remote.remoteName; - } - - if (lastPullRequest && prNumber && prNumber === lastPullRequest.number) { - return cachedUsers; - } - } - - const prRelatedUsersPromise = new Promise(async resolve => { - if (prNumber && remoteName) { - Logger.debug('get Timeline Events and parse users', FolderRepositoryManager.ID); - if (lastPullRequest && lastPullRequest.number === prNumber) { - return lastPullRequestTimelineEvents; - } - - const githubRepo = this._githubRepositories.find( - repo => repo.remote.remoteName === remoteName, - ); - - if (githubRepo) { - lastPullRequest = await githubRepo.getPullRequest(prNumber); - lastPullRequestTimelineEvents = await lastPullRequest!.getTimelineEvents(); - } - - prRelatedusers = getRelatedUsersFromTimelineEvents(lastPullRequestTimelineEvents); - resolve(); - } - - resolve(); - }); - - const fileRelatedUsersNamesPromise = new Promise(async resolve => { - if (activeTextEditors.length) { - try { - Logger.debug('git blame and parse users', FolderRepositoryManager.ID); - const fsPath = path.resolve(activeTextEditors[0].document.uri.fsPath); - let blames: string | undefined; - if (this._gitBlameCache[fsPath]) { - blames = this._gitBlameCache[fsPath]; - } else { - blames = await this.repository.blame(fsPath); - this._gitBlameCache[fsPath] = blames; - } - - const blameLines = blames.split('\n'); - - for (const line of blameLines) { - const matches = /^\w{11} \S*\s*\((.*)\s*\d{4}\-/.exec(line); - - if (matches && matches.length === 2) { - const name = matches[1].trim(); - fileRelatedUsersNames[name] = true; - } - } - } catch (err) { - Logger.debug(err, FolderRepositoryManager.ID); - } - } - - resolve(); - }); - - const getMentionableUsersPromise = new Promise(async resolve => { - Logger.debug('get mentionable users', FolderRepositoryManager.ID); - mentionableUsers = await this.getMentionableUsers(); - resolve(); - }); - - await Promise.all([ - prRelatedUsersPromise, - fileRelatedUsersNamesPromise, - getMentionableUsersPromise, - ]); - - cachedUsers = []; - const prRelatedUsersMap: { [key: string]: { login: string; name?: string } } = {}; - Logger.debug('prepare user suggestions', FolderRepositoryManager.ID); - - prRelatedusers.forEach(user => { - if (!prRelatedUsersMap[user.login]) { - prRelatedUsersMap[user.login] = user; - } - }); - - const secondMap: { [key: string]: boolean } = {}; - - for (const mentionableUserGroup in mentionableUsers) { - for (const user of mentionableUsers[mentionableUserGroup]) { - if (!prRelatedUsersMap[user.login] && !secondMap[user.login]) { - secondMap[user.login] = true; - - let priority = 2; - if ( - fileRelatedUsersNames[user.login] || - (user.name && fileRelatedUsersNames[user.name]) - ) { - priority = 1; - } - - if (prRelatedUsersMap[user.login]) { - priority = 0; - } - - cachedUsers.push({ - label: user.login, - insertText: user.login, - filterText: - `${user.login}` + - (user.name && user.name !== user.login - ? `_${user.name.toLowerCase().replace(' ', '_')}` - : ''), - sortText: `${priority}_${user.login}`, - detail: user.name, - kind: vscode.CompletionItemKind.User, - login: user.login, - uri: this.repository.rootUri, - }); - } - } - } - - for (const user in prRelatedUsersMap) { - if (!secondMap[user]) { - // if the mentionable api call fails partially, we should still populate related users from timeline events into the completion list - cachedUsers.push({ - label: prRelatedUsersMap[user].login, - insertText: `${prRelatedUsersMap[user].login}`, - filterText: - `${prRelatedUsersMap[user].login}` + - (prRelatedUsersMap[user].name && - prRelatedUsersMap[user].name !== prRelatedUsersMap[user].login - ? `_${prRelatedUsersMap[user].name!.toLowerCase().replace(' ', '_')}` - : ''), - sortText: `0_${prRelatedUsersMap[user].login}`, - detail: prRelatedUsersMap[user].name, - kind: vscode.CompletionItemKind.User, - login: prRelatedUsersMap[user].login, - uri: this.repository.rootUri, - }); - } - } - - Logger.debug('done', FolderRepositoryManager.ID); - return cachedUsers; - } catch (e) { - return []; - } - }, - resolveCompletionItem: async (item: vscode.CompletionItem, _token: vscode.CancellationToken) => { - try { - const repo = await this.getPullRequestDefaults(); - const user: User | undefined = await this.resolveUser(repo.owner, repo.repo, (typeof item.label === 'string') ? item.label : item.label.label); - if (user) { - item.documentation = userMarkdown(repo, user); - } - } catch (e) { - // The user might not be resolvable in the repo, since users from outside the repo are included in the list. - } - return item; - }, - }, - '@', - ); - } - - get activeIssue(): IssueModel | undefined { - return this._activeIssue; - } - - set activeIssue(issue: IssueModel | undefined) { - this._activeIssue = issue; - this._onDidChangeActiveIssue.fire(); - } - - get activePullRequest(): PullRequestModel | undefined { - return this._activePullRequest; - } - - set activePullRequest(pullRequest: PullRequestModel | undefined) { - if (this._activePullRequest) { - this._activePullRequest.isActive = false; - } - - if (pullRequest) { - pullRequest.isActive = true; - } - - this._activePullRequest = pullRequest; - this._onDidChangeActivePullRequest.fire(); - } - - get repository(): Repository { - return this._repository; - } - - set repository(repository: Repository) { - this._repository = repository; - } - - get credentialStore(): CredentialStore { - return this._credentialStore; - } - - /** - * Using these contexts is fragile in a multi-root workspace where multiple PRs are checked out. - * If you have two active PRs that have the same file path relative to their rootdir, then these context can get confused. - */ - public setFileViewedContext() { - const states = this.activePullRequest?.getViewedFileStates(); - if (states) { - commands.setContext(contexts.VIEWED_FILES, Array.from(states.viewed)); - commands.setContext(contexts.UNVIEWED_FILES, Array.from(states.unviewed)); - } else { - this.clearFileViewedContext(); - } - } - - private clearFileViewedContext() { - commands.setContext(contexts.VIEWED_FILES, []); - commands.setContext(contexts.UNVIEWED_FILES, []); - } - - public async loginAndUpdate() { - if (!this._credentialStore.isAnyAuthenticated()) { - const waitForRepos = new Promise(c => { - const onReposChange = this.onDidChangeRepositories(() => { - onReposChange.dispose(); - c(); - }); - }); - await this._credentialStore.login(AuthProvider.github); - await waitForRepos; - } - } - - private async getActiveRemotes(): Promise { - this._allGitHubRemotes = await this.computeAllGitHubRemotes(); - const activeRemotes = await this.getActiveGitHubRemotes(this._allGitHubRemotes); - - if (activeRemotes.length) { - await vscode.commands.executeCommand('setContext', 'github:hasGitHubRemotes', true); - Logger.appendLine(`Found GitHub remote for folder ${this.repository.rootUri.fsPath}`); - } else { - Logger.appendLine(`No GitHub remotes found for folder ${this.repository.rootUri.fsPath}`); - } - - return activeRemotes; - } - - private _updatingRepositories: Promise | undefined; - async updateRepositories(silent: boolean = false): Promise { - if (this._updatingRepositories) { - await this._updatingRepositories; - } - this._updatingRepositories = this.doUpdateRepositories(silent); - return this._updatingRepositories; - } - - private checkForAuthMatch(activeRemotes: GitHubRemote[]): boolean { - // Check that our auth matches the remote. - let dotComCount = 0; - let enterpriseCount = 0; - for (const remote of activeRemotes) { - if (remote.githubServerType === GitHubServerType.GitHubDotCom) { - dotComCount++; - } else if (remote.githubServerType === GitHubServerType.Enterprise) { - enterpriseCount++; - } - } - - let isAuthenticated = this._credentialStore.isAuthenticated(AuthProvider.github) || this._credentialStore.isAuthenticated(AuthProvider['github-enterprise']); - if ((dotComCount > 0) && this._credentialStore.isAuthenticated(AuthProvider.github)) { - // good - } else if ((enterpriseCount > 0) && this._credentialStore.isAuthenticated(AuthProvider['github-enterprise'])) { - // also good - } else if (isAuthenticated) { - // Not good. We have a mismatch between auth type and server type. - isAuthenticated = false; - } - vscode.commands.executeCommand('setContext', 'github:authenticated', isAuthenticated); - return isAuthenticated; - } - - private async doUpdateRepositories(silent: boolean): Promise { - if (this._git.state === 'uninitialized') { - Logger.appendLine('Cannot updates repositories as git is uninitialized'); - - return; - } - - 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) { - this._onDidLoadRepositories.fire(ReposManagerState.RepositoriesLoaded); - return; - } - } - const repositories: GitHubRepository[] = []; - const resolveRemotePromises: Promise[] = []; - const oldRepositories: GitHubRepository[] = []; - this._githubRepositories.forEach(repo => oldRepositories.push(repo)); - - const authenticatedRemotes = activeRemotes.filter(remote => this._credentialStore.isAuthenticated(remote.authProviderId)); - for (const remote of authenticatedRemotes) { - const repository = await this.createGitHubRepository(remote, this._credentialStore); - resolveRemotePromises.push(repository.resolveRemote()); - repositories.push(repository); - } - - return Promise.all(resolveRemotePromises).then(async (remoteResults: boolean[]) => { - if (remoteResults.some(value => !value)) { - this._credentialStore.showSamlMessageAndAuth(); - } - - this._githubRepositories = repositories; - oldRepositories.filter(old => this._githubRepositories.indexOf(old) < 0).forEach(repo => repo.dispose()); - - const repositoriesChanged = - oldRepositories.length !== this._githubRepositories.length || - !oldRepositories.every(oldRepo => - this._githubRepositories.some(newRepo => newRepo.remote.equals(oldRepo.remote)), - ); - - if (repositoriesChanged) { - this._onDidChangeGithubRepositories.fire(this._githubRepositories); - } - - if (this._githubRepositories.length && repositoriesChanged) { - if (await this.checkIfMissingUpstream()) { - this.updateRepositories(silent); - return; - } - } - - if (this.activePullRequest) { - this.getMentionableUsers(repositoriesChanged); - } - - this.getAssignableUsers(repositoriesChanged); - if (isAuthenticated && activeRemotes.length) { - this._onDidLoadRepositories.fire(ReposManagerState.RepositoriesLoaded); - } else if (!isAuthenticated) { - this._onDidLoadRepositories.fire(ReposManagerState.NeedsAuthentication); - } - if (!silent) { - this._onDidChangeRepositories.fire(); - } - return; - }); - } - - private async checkIfMissingUpstream(): Promise { - try { - const origin = await this.getOrigin(); - const metadata = await origin.getMetadata(); - if (metadata.fork && metadata.parent) { - const parentUrl = new Protocol(metadata.parent.git_url); - const missingParentRemote = !this._githubRepositories.some( - repo => - repo.remote.owner === parentUrl.owner && - repo.remote.repositoryName === parentUrl.repositoryName, - ); - - if (missingParentRemote) { - const upstreamAvailable = !this.repository.state.remotes.some(remote => remote.name === 'upstream'); - const remoteName = upstreamAvailable ? 'upstream' : metadata.parent.owner?.login; - if (remoteName) { - // check the remotes to see what protocol is being used - const isSSH = this.gitHubRepositories[0].remote.gitProtocol.type === ProtocolType.SSH; - if (isSSH) { - await this.repository.addRemote(remoteName, metadata.parent.ssh_url); - } else { - await this.repository.addRemote(remoteName, metadata.parent.clone_url); - } - return true; - } - } - } - } catch (e) { - Logger.appendLine(`Missing upstream check failed: ${e}`); - // ignore - } - return false; - } - - getAllAssignableUsers(): IAccount[] | undefined { - if (this._assignableUsers) { - const allAssignableUsers: IAccount[] = []; - Object.keys(this._assignableUsers).forEach(k => { - allAssignableUsers.push(...this._assignableUsers![k]); - }); - - return allAssignableUsers; - } - - return undefined; - } - - private async getMentionableUsersFromGlobalState(): Promise<{ [key: string]: IAccount[] } | undefined> { - Logger.appendLine('Trying to use globalState for mentionable users.'); - - const mentionableUsersCacheLocation = vscode.Uri.joinPath(this.context.globalStorageUri, 'mentionableUsers'); - let mentionableUsersCacheExists; - try { - mentionableUsersCacheExists = await vscode.workspace.fs.stat(mentionableUsersCacheLocation); - } catch (e) { - // file doesn't exit - } - if (!mentionableUsersCacheExists) { - return undefined; - } - - const cache: { [key: string]: IAccount[] } = {}; - const hasAllRepos = (await Promise.all(this._githubRepositories.map(async (repo) => { - const key = `${repo.remote.owner}/${repo.remote.repositoryName}.json`; - const repoSpecificFile = vscode.Uri.joinPath(mentionableUsersCacheLocation, key); - let repoSpecificCache; - try { - repoSpecificCache = await vscode.workspace.fs.readFile(repoSpecificFile); - } catch (e) { - // file doesn't exist - } - if (repoSpecificCache && repoSpecificCache.toString()) { - cache[repo.remote.repositoryName] = JSON.parse(repoSpecificCache.toString()) ?? []; - return true; - } - }))).every(value => value); - if (hasAllRepos) { - Logger.appendLine(`Using globalState mentionable users for ${Object.keys(cache).length}.`); - return cache; - } - - Logger.appendLine(`No globalState for mentionable users.`); - return undefined; - } - - private createFetchMentionableUsersPromise(): Promise<{ [key: string]: IAccount[] }> { - const cache: { [key: string]: IAccount[] } = {}; - return new Promise<{ [key: string]: IAccount[] }>(resolve => { - const promises = this._githubRepositories.map(async githubRepository => { - const data = await githubRepository.getMentionableUsers(); - cache[githubRepository.remote.remoteName] = data; - return; - }); - - Promise.all(promises).then(() => { - this._mentionableUsers = cache; - this._fetchMentionableUsersPromise = undefined; - const mentionableUsersCacheLocation = vscode.Uri.joinPath(this.context.globalStorageUri, 'mentionableUsers'); - Promise.all(this._githubRepositories.map(async (repo) => { - const key = `${repo.remote.owner}/${repo.remote.repositoryName}.json`; - const repoSpecificFile = vscode.Uri.joinPath(mentionableUsersCacheLocation, key); - await vscode.workspace.fs.writeFile(repoSpecificFile, new TextEncoder().encode(JSON.stringify(cache[repo.remote.remoteName]))); - })) - .then(() => resolve(cache)); - }); - }); - } - - async getMentionableUsers(clearCache?: boolean): Promise<{ [key: string]: IAccount[] }> { - if (clearCache) { - delete this._mentionableUsers; - } - - if (this._mentionableUsers) { - return this._mentionableUsers; - } - - const globalStateMentionableUsers = await this.getMentionableUsersFromGlobalState(); - - if (!this._fetchMentionableUsersPromise) { - this._fetchMentionableUsersPromise = this.createFetchMentionableUsersPromise(); - return globalStateMentionableUsers ?? this._fetchMentionableUsersPromise; - } - - return this._fetchMentionableUsersPromise; - } - - async getAssignableUsers(clearCache?: boolean): Promise<{ [key: string]: IAccount[] }> { - if (clearCache) { - delete this._assignableUsers; - } - - if (this._assignableUsers) { - return this._assignableUsers; - } - - if (!this._fetchAssignableUsersPromise) { - const cache: { [key: string]: IAccount[] } = {}; - const allAssignableUsers: IAccount[] = []; - return (this._fetchAssignableUsersPromise = new Promise(resolve => { - const promises = this._githubRepositories.map(async githubRepository => { - const data = await githubRepository.getAssignableUsers(); - cache[githubRepository.remote.remoteName] = data.sort(loginComparator); - allAssignableUsers.push(...data); - return; - }); - - Promise.all(promises).then(() => { - this._assignableUsers = cache; - this._fetchAssignableUsersPromise = undefined; - resolve(cache); - this._onDidChangeAssignableUsers.fire(allAssignableUsers); - }); - })); - } - - return this._fetchAssignableUsersPromise; - } - - async getPullRequestParticipants(githubRepository: GitHubRepository, pullRequestNumber: number): Promise<{ participants: IAccount[], viewer: IAccount }> { - return { - participants: await githubRepository.getPullRequestParticipants(pullRequestNumber), - viewer: await this.getCurrentUser(githubRepository) - }; - } - - /** - * Returns the remotes that are currently active, which is those that are important by convention (origin, upstream), - * or the remotes configured by the setting githubPullRequests.remotes - */ - async getGitHubRemotes(): Promise { - const githubRepositories = this._githubRepositories; - - if (!githubRepositories || !githubRepositories.length) { - return []; - } - - const remotes = githubRepositories.map(repo => repo.remote).flat(); - - const serverTypes = await Promise.all( - remotes.map(remote => this._githubManager.isGitHub(remote.gitProtocol.normalizeUri()!)), - ).catch(e => { - Logger.error(`Resolving GitHub remotes failed: ${e}`); - vscode.window.showErrorMessage(vscode.l10n.t('Resolving GitHub remotes failed: {0}', formatError(e))); - return []; - }); - - const githubRemotes = remotes.map((remote, index) => GitHubRemote.remoteAsGitHub(remote, serverTypes[index])); - if (this.checkForAuthMatch(githubRemotes)) { - return githubRemotes; - } - return []; - } - - /** - * Returns all remotes from the repository. - */ - async getAllGitHubRemotes(): Promise { - return await this.computeAllGitHubRemotes(); - } - - async getLocalPullRequests(): Promise { - const githubRepositories = this._githubRepositories; - - if (!githubRepositories || !githubRepositories.length) { - return []; - } - - const localBranches = (await this.repository.getRefs({ pattern: 'refs/heads/' })) - .filter(r => r.name !== undefined) - .map(r => r.name!); - - const promises = localBranches.map(async localBranchName => { - const matchingPRMetadata = await PullRequestGitHelper.getMatchingPullRequestMetadataForBranch( - this.repository, - localBranchName, - ); - - if (matchingPRMetadata) { - const { owner, prNumber } = matchingPRMetadata; - const githubRepo = githubRepositories.find( - repo => repo.remote.owner.toLocaleLowerCase() === owner.toLocaleLowerCase(), - ); - - if (githubRepo) { - const pullRequest: PullRequestModel | undefined = await githubRepo.getPullRequest(prNumber); - - if (pullRequest) { - pullRequest.localBranchName = localBranchName; - return pullRequest; - } - } - } - - return Promise.resolve(null); - }); - - return Promise.all(promises).then(values => { - return values.filter(value => value !== null) as PullRequestModel[]; - }); - } - - async getLabels(issue?: IssueModel, repoInfo?: { owner: string; repo: string }): Promise { - const repo = issue - ? issue.githubRepository - : this._githubRepositories.find( - r => r.remote.owner === repoInfo?.owner && r.remote.repositoryName === repoInfo?.repo, - ); - if (!repo) { - throw new Error(`No matching repository found for getting labels.`); - } - - const { remote, octokit } = await repo.ensure(); - let hasNextPage = false; - let page = 1; - let results: ILabel[] = []; - - do { - const result = await octokit.call(octokit.api.issues.listLabelsForRepo, { - owner: remote.owner, - repo: remote.repositoryName, - per_page: 100, - page, - }); - - results = results.concat( - result.data.map(label => { - return { - name: label.name, - color: label.color, - }; - }), - ); - - results = results.sort((a, b) => a.name.localeCompare(b.name)); - - hasNextPage = !!result.headers.link && result.headers.link.indexOf('rel="next"') > -1; - page += 1; - } while (hasNextPage); - - return results; - } - - async deleteLocalPullRequest(pullRequest: PullRequestModel, force?: boolean): Promise { - if (!pullRequest.localBranchName) { - return; - } - await this.repository.deleteBranch(pullRequest.localBranchName, force); - - let remoteName: string | undefined = undefined; - try { - remoteName = await this.repository.getConfig(`branch.${pullRequest.localBranchName}.remote`); - } catch (e) { } - - if (!remoteName) { - return; - } - - // If the extension created a remote for the branch, remove it if there are no other branches associated with it - const isPRRemote = await PullRequestGitHelper.isRemoteCreatedForPullRequest(this.repository, remoteName); - if (isPRRemote) { - const configs = await this.repository.getConfigs(); - const hasOtherAssociatedBranches = configs.some( - ({ key, value }) => /^branch.*\.remote$/.test(key) && value === remoteName, - ); - - if (!hasOtherAssociatedBranches) { - await this.repository.removeRemote(remoteName); - } - } - - /* __GDPR__ - "branch.delete" : {} - */ - this.telemetry.sendTelemetryEvent('branch.delete'); - } - - // Keep track of how many pages we've fetched for each query, so when we reload we pull the same ones. - private totalFetchedPages = new Map(); - - /** - * This method works in three different ways: - * 1) Initialize: fetch the first page of the first remote that has pages - * 2) Fetch Next: fetch the next page from this remote, or if it has no more pages, the first page from the next remote that does have pages - * 3) Restore: fetch all the pages you previously have fetched - * - * When `options.fetchNextPage === false`, we are in case 2. - * Otherwise: - * If `this.totalFetchQueries[queryId] === 0`, we are in case 1. - * Otherwise, we're in case 3. - */ - private async fetchPagedData( - options: IPullRequestsPagingOptions = { fetchNextPage: false }, - queryId: string, - pagedDataType: PagedDataType = PagedDataType.PullRequest, - type: PRType = PRType.All, - query?: string, - ): Promise> { - if (!this._githubRepositories || !this._githubRepositories.length) { - return { - items: [], - hasMorePages: false, - hasUnsearchedRepositories: false, - }; - } - - const getTotalFetchedPages = () => this.totalFetchedPages.get(queryId) || 0; - const setTotalFetchedPages = (numPages: number) => this.totalFetchedPages.set(queryId, numPages); - - for (const repository of this._githubRepositories) { - const remoteId = repository.remote.url.toString() + queryId; - if (!this._repositoryPageInformation.get(remoteId)) { - this._repositoryPageInformation.set(remoteId, { - pullRequestPage: 0, - hasMorePages: null, - }); - } - } - - let pagesFetched = 0; - const itemData: ItemsData = { hasMorePages: false, items: [] }; - const addPage = (page: PullRequestData | undefined) => { - pagesFetched++; - if (page) { - itemData.items = itemData.items.concat(page.items); - itemData.hasMorePages = page.hasMorePages; - } - }; - - const githubRepositories = this._githubRepositories.filter(repo => { - 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); - }); - - for (let i = 0; i < githubRepositories.length; i++) { - const githubRepository = githubRepositories[i]; - const remoteId = githubRepository.remote.url.toString() + queryId; - let storedPageInfo = this._repositoryPageInformation.get(remoteId); - if (!storedPageInfo) { - Logger.warn(`No page information for ${remoteId}`); - storedPageInfo = { pullRequestPage: 0, hasMorePages: null }; - this._repositoryPageInformation.set(remoteId, storedPageInfo); - } - const pageInformation = storedPageInfo; - - const fetchPage = async ( - pageNumber: number, - ): Promise<{ items: any[]; hasMorePages: boolean } | undefined> => { - // Resolve variables in the query with each repo - const resolvedQuery = query ? await variableSubstitution(query, undefined, - { base: await githubRepository.getDefaultBranch(), owner: githubRepository.remote.owner, repo: githubRepository.remote.repositoryName }) : undefined; - switch (pagedDataType) { - case PagedDataType.PullRequest: { - if (type === PRType.All) { - return githubRepository.getAllPullRequests(pageNumber); - } else { - return githubRepository.getPullRequestsForCategory(resolvedQuery || '', pageNumber); - } - } - case PagedDataType.Milestones: { - return githubRepository.getIssuesForUserByMilestone(pageInformation.pullRequestPage); - } - case PagedDataType.IssuesWithoutMilestone: { - return githubRepository.getIssuesWithoutMilestone(pageInformation.pullRequestPage); - } - case PagedDataType.IssueSearch: { - return githubRepository.getIssues(pageInformation.pullRequestPage, resolvedQuery); - } - } - }; - - if (options.fetchNextPage) { - // Case 2. Fetch a single new page, and increment the global number of pages fetched for this query. - pageInformation.pullRequestPage++; - addPage(await fetchPage(pageInformation.pullRequestPage)); - setTotalFetchedPages(getTotalFetchedPages() + 1); - } else { - // Case 1&3. Fetch all the pages we have fetched in the past, or in case 1, just a single page. - - if (pageInformation.pullRequestPage === 0) { - // Case 1. Pretend we have previously fetched the first page, then hand off to the case 3 machinery to "fetch all pages we have fetched in the past" - pageInformation.pullRequestPage = 1; - } - - const pages = await Promise.all( - Array.from({ length: pageInformation.pullRequestPage }).map((_, j) => fetchPage(j + 1)), - ); - pages.forEach(page => addPage(page)); - } - - pageInformation.hasMorePages = itemData.hasMorePages; - - // Break early if - // 1) we've received data AND - // 2) either we're fetching just the next page (case 2) - // OR we're fetching all (cases 1&3), and we've fetched as far as we had previously (or further, in case 1). - if ( - itemData.items.length && - (options.fetchNextPage || - ((options.fetchNextPage === false) && !options.fetchOnePagePerRepo && (pagesFetched >= getTotalFetchedPages()))) - ) { - if (getTotalFetchedPages() === 0) { - // We're in case 1, manually set number of pages we looked through until we found first results. - setTotalFetchedPages(pagesFetched); - } - - return { - items: itemData.items, - hasMorePages: pageInformation.hasMorePages, - hasUnsearchedRepositories: i < githubRepositories.length - 1, - }; - } - } - - return { - items: itemData.items, - hasMorePages: false, - hasUnsearchedRepositories: false, - }; - } - - async getPullRequests( - type: PRType, - options: IPullRequestsPagingOptions = { fetchNextPage: false }, - query?: string, - ): Promise> { - const queryId = type.toString() + (query || ''); - return this.fetchPagedData(options, queryId, PagedDataType.PullRequest, type, query); - } - - async getMilestoneIssues( - options: IPullRequestsPagingOptions = { fetchNextPage: false }, - includeIssuesWithoutMilestone: boolean = false, - query?: string, - ): Promise> { - const milestones: ItemsResponseResult = await this.fetchPagedData( - options, - 'issuesKey', - PagedDataType.Milestones, - PRType.All, - query, - ); - if (includeIssuesWithoutMilestone) { - const additionalIssues: ItemsResponseResult = await this.fetchPagedData( - options, - 'issuesKey', - PagedDataType.IssuesWithoutMilestone, - PRType.All, - query, - ); - milestones.items.push({ - milestone: { - createdAt: new Date(0).toDateString(), - id: '', - title: NO_MILESTONE, - }, - issues: additionalIssues.items, - }); - } - return milestones; - } - - async createMilestone(repository: GitHubRepository, milestoneTitle: string): Promise { - try { - const { data } = await repository.octokit.call(repository.octokit.api.issues.createMilestone, { - owner: repository.remote.owner, - repo: repository.remote.repositoryName, - title: milestoneTitle - }); - return { - title: data.title, - dueOn: data.due_on, - createdAt: data.created_at, - id: data.node_id, - }; - } - catch (e) { - vscode.window.showErrorMessage(vscode.l10n.t('Failed to create a milestone\n{0}', formatError(e))); - return undefined; - } - } - - /** - * Pull request defaults in the query, like owner and repository variables, will be resolved. - */ - async getIssues( - options: IPullRequestsPagingOptions = { fetchNextPage: false, fetchOnePagePerRepo: true }, - query?: string, - ): Promise> { - return this.fetchPagedData(options, 'issuesKey', PagedDataType.IssueSearch, PRType.All, query); - } - - async getMaxIssue(): Promise { - const maxIssues = await Promise.all( - this._githubRepositories.map(repository => { - return repository.getMaxIssue(); - }), - ); - let max: number = 0; - for (const issueNumber of maxIssues) { - if (issueNumber !== undefined) { - max = Math.max(max, issueNumber); - } - } - return max; - } - - async getPullRequestTemplates(): Promise { - /** - * Places a PR template can be: - * - At the root, the docs folder, or the.github folder, named pull_request_template.md or PULL_REQUEST_TEMPLATE.md - * - At the same folder locations under a PULL_REQUEST_TEMPLATE folder with any name - */ - const pattern1 = '{pull_request_template,PULL_REQUEST_TEMPLATE}.{md,txt}'; - const templatesPattern1 = vscode.workspace.findFiles( - new vscode.RelativePattern(this._repository.rootUri, pattern1) - ); - - const pattern2 = '{docs,.github}/{pull_request_template,PULL_REQUEST_TEMPLATE}.{md,txt}'; - const templatesPattern2 = vscode.workspace.findFiles( - new vscode.RelativePattern(this._repository.rootUri, pattern2), null - ); - - const pattern3 = '{pull_request_template,PULL_REQUEST_TEMPLATE}'; - const templatesPattern3 = vscode.workspace.findFiles( - new vscode.RelativePattern(this._repository.rootUri, pattern3) - ); - - const pattern4 = '{docs,.github}/{pull_request_template,PULL_REQUEST_TEMPLATE}'; - const templatesPattern4 = vscode.workspace.findFiles( - new vscode.RelativePattern(this._repository.rootUri, pattern4), null - ); - - const pattern5 = 'PULL_REQUEST_TEMPLATE/*.md'; - const templatesPattern5 = vscode.workspace.findFiles( - new vscode.RelativePattern(this._repository.rootUri, pattern5) - ); - - const pattern6 = '{docs,.github}/PULL_REQUEST_TEMPLATE/*.md'; - const templatesPattern6 = vscode.workspace.findFiles( - new vscode.RelativePattern(this._repository.rootUri, pattern6), null - ); - - const allResults = await Promise.all([templatesPattern1, templatesPattern2, templatesPattern3, templatesPattern4, templatesPattern5, templatesPattern6]); - - return [...allResults[0], ...allResults[1], ...allResults[2], ...allResults[3], ...allResults[4], ...allResults[5]]; - } - - async getPullRequestDefaults(branch?: Branch): Promise { - if (!branch && !this.repository.state.HEAD) { - throw new DetachedHeadError(this.repository); - } - - const origin = await this.getOrigin(branch); - const meta = await origin.getMetadata(); - const remotesSettingDefault = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).inspect(REMOTES_SETTING)?.defaultValue; - const remotesSettingSetValue = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get(REMOTES_SETTING); - const settingsEqual = (!remotesSettingSetValue || remotesSettingDefault?.every((value, index) => remotesSettingSetValue[index] === value)); - const parent = (meta.fork && meta.parent && settingsEqual) - ? meta.parent - : await (this.findRepo(byRemoteName('upstream')) || origin).getMetadata(); - - return { - owner: parent.owner!.login, - repo: parent.name, - base: getOverrideBranch() ?? parent.default_branch, - }; - } - - async getMetadata(remote: string): Promise { - const repo = this.findRepo(byRemoteName(remote)); - return repo && repo.getMetadata(); - } - - async getHeadCommitMessage(): Promise { - const { repository } = this; - if (repository.state.HEAD && repository.state.HEAD.commit) { - const { message } = await repository.getCommit(repository.state.HEAD.commit); - return message; - } - - return ''; - } - - async getTipCommitMessage(branch: string): Promise { - const { repository } = this; - const { commit } = await repository.getBranch(branch); - if (commit) { - const { message } = await repository.getCommit(commit); - return message; - } - - return ''; - } - - async getOrigin(branch?: Branch): Promise { - if (!this._githubRepositories.length) { - throw new NoGitHubReposError(this.repository); - } - - const upstreamRef = branch ? branch.upstream : this.upstreamRef; - if (upstreamRef) { - // If our current branch has an upstream ref set, find its GitHubRepository. - const upstream = this.findRepo(byRemoteName(upstreamRef.remote)); - - // If the upstream wasn't listed in the remotes setting, create a GitHubRepository - // object for it if is does point to GitHub. - if (!upstream) { - const remote = (await this.getAllGitHubRemotes()).find(r => r.remoteName === upstreamRef.remote); - if (remote) { - return this.createAndAddGitHubRepository(remote, this._credentialStore); - } - - Logger.error(`The remote '${upstreamRef.remote}' is not a GitHub repository.`); - - // No GitHubRepository? We currently won't try pushing elsewhere, - // so fail. - throw new BadUpstreamError(this.repository.state.HEAD!.name!, upstreamRef, 'is not a GitHub repo'); - } - - // Otherwise, we'll push upstream. - return upstream; - } - - // If no upstream is set, let's go digging. - const [first, ...rest] = this._githubRepositories; - return !rest.length // Is there only one GitHub remote? - ? 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 - first; // out of raw desperation - } - - findRepo(where: Predicate): GitHubRepository | undefined { - return this._githubRepositories.filter(where)[0]; - } - - get upstreamRef(): UpstreamRef | undefined { - const { HEAD } = this.repository.state; - return HEAD && HEAD.upstream; - } - - async createPullRequest(params: OctokitCommon.PullsCreateParams): Promise { - const repo = this._githubRepositories.find( - r => r.remote.owner === params.owner && r.remote.repositoryName === params.repo, - ); - if (!repo) { - throw new Error(`No matching repository ${params.repo} found for ${params.owner}`); - } - - try { - const pullRequestModel = await repo.createPullRequest(params); - - const branchNameSeparatorIndex = params.head.indexOf(':'); - const branchName = params.head.slice(branchNameSeparatorIndex + 1); - await PullRequestGitHelper.associateBranchWithPullRequest(this._repository, pullRequestModel, branchName); - - /* __GDPR__ - "pr.create.success" : { - "isDraft" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetry.sendTelemetryEvent('pr.create.success', { isDraft: (params.draft || '').toString() }); - return pullRequestModel; - } catch (e) { - if (e.message.indexOf('No commits between ') > -1) { - // There are unpushed commits - if (this._repository.state.HEAD?.ahead) { - // Offer to push changes - const pushCommits = vscode.l10n.t({ message: 'Push Commits', comment: 'Pushes the local commits to the remote.' }); - const shouldPush = await vscode.window.showInformationMessage( - vscode.l10n.t('There are no commits between \'{0}\' and \'{1}\'.\n\nDo you want to push your local commits and create the pull request?', params.base, params.head), - { modal: true }, - pushCommits, - ); - if (shouldPush === pushCommits) { - await this._repository.push(); - return this.createPullRequest(params); - } else { - return; - } - } - - // There are uncommitted changes - if (this._repository.state.workingTreeChanges.length || this._repository.state.indexChanges.length) { - const commitChanges = vscode.l10n.t('Commit Changes'); - const shouldCommit = await vscode.window.showInformationMessage( - vscode.l10n.t('There are no commits between \'{0}\' and \'{1}\'.\n\nDo you want to commit your changes and create the pull request?', params.base, params.head), - { modal: true }, - commitChanges, - ); - if (shouldCommit === commitChanges) { - await this._repository.add(this._repository.state.indexChanges.map(change => change.uri.fsPath)); - await this.repository.commit(`${params.title}${params.body ? `\n${params.body}` : ''}`); - await this._repository.push(); - return this.createPullRequest(params); - } else { - return; - } - } - } - - if (!this._repository.state.HEAD?.upstream) { - const publishBranch = vscode.l10n.t('Publish Branch'); - const shouldPushUpstream = await vscode.window.showInformationMessage( - vscode.l10n.t('There is no upstream branch for \'{0}\'.\n\nDo you want to publish it and create the pull request?', params.base), - { modal: true }, - publishBranch - ); - if (shouldPushUpstream === publishBranch) { - await this._repository.push(repo.remote.remoteName, params.base, true); - return this.createPullRequest(params); - } else { - return; - } - } - - Logger.error(`Creating pull requests failed: ${e}`, FolderRepositoryManager.ID); - - /* __GDPR__ - "pr.create.failure" : { - "isDraft" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetry.sendTelemetryErrorEvent('pr.create.failure', { - isDraft: (params.draft || '').toString(), - }); - - throw new Error(formatError(e)); - } - } - - async createIssue(params: OctokitCommon.IssuesCreateParams): Promise { - try { - const repo = this._githubRepositories.find( - r => r.remote.owner === params.owner && r.remote.repositoryName === params.repo, - ); - if (!repo) { - throw new Error(`No matching repository ${params.repo} found for ${params.owner}`); - } - - await repo.ensure(); - - // Create PR - const { data } = await repo.octokit.call(repo.octokit.api.issues.create, params); - const item = convertRESTIssueToRawPullRequest(data, repo); - const issueModel = new IssueModel(repo, repo.remote, item); - - /* __GDPR__ - "issue.create.success" : { - } - */ - this.telemetry.sendTelemetryEvent('issue.create.success'); - return issueModel; - } catch (e) { - Logger.error(` Creating issue failed: ${e}`, FolderRepositoryManager.ID); - - /* __GDPR__ - "issue.create.failure" : {} - */ - this.telemetry.sendTelemetryErrorEvent('issue.create.failure'); - vscode.window.showWarningMessage(vscode.l10n.t('Creating issue failed: {0}', formatError(e))); - } - - return undefined; - } - - async assignIssue(issue: IssueModel, login: string): Promise { - try { - const repo = this._githubRepositories.find( - r => r.remote.owner === issue.remote.owner && r.remote.repositoryName === issue.remote.repositoryName, - ); - if (!repo) { - throw new Error( - `No matching repository ${issue.remote.repositoryName} found for ${issue.remote.owner}`, - ); - } - - await repo.ensure(); - - const param: OctokitCommon.IssuesAssignParams = { - assignees: [login], - owner: issue.remote.owner, - repo: issue.remote.repositoryName, - issue_number: issue.number, - }; - await repo.octokit.call(repo.octokit.api.issues.addAssignees, param); - - /* __GDPR__ - "issue.assign.success" : { - } - */ - this.telemetry.sendTelemetryEvent('issue.assign.success'); - } catch (e) { - Logger.error(`Assigning issue failed: ${e}`, FolderRepositoryManager.ID); - - /* __GDPR__ - "issue.assign.failure" : { - } - */ - this.telemetry.sendTelemetryErrorEvent('issue.assign.failure'); - vscode.window.showWarningMessage(vscode.l10n.t('Assigning issue failed: {0}', formatError(e))); - } - } - - getCurrentUser(githubRepository?: GitHubRepository): Promise { - if (!githubRepository) { - githubRepository = this.gitHubRepositories[0]; - } - return this._credentialStore.getCurrentUser(githubRepository.remote.authProviderId); - } - - async mergePullRequest( - pullRequest: PullRequestModel, - title?: string, - description?: string, - method?: 'merge' | 'squash' | 'rebase', - ): Promise { - const { octokit, remote } = 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; - - if (activePRSHA === mergingPRSHA) { - // We're on the branch of the pr being merged. - - 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 PR branch.\n\nWould you like to proceed anyway?', ahead ?? 'unknown'); - const singularMessage = vscode.l10n.t('You have 1 unpushed commit on this PR 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: '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 PR branch.\n\n Would you like to proceed anyway?'), - { modal: true }, - vscode.l10n.t('Yes'), - )) === undefined - ) { - return { - merged: false, - message: 'uncommitted changes', - }; - } - } - } - - return await octokit.call(octokit.api.pulls.merge, { - commit_message: description, - commit_title: title, - merge_method: - method || - vscode.workspace - .getConfiguration('githubPullRequests') - .get<'merge' | 'squash' | 'rebase'>('defaultMergeMethod'), - owner: remote.owner, - repo: remote.repositoryName, - pull_number: pullRequest.number, - }) - .then(x => { - /* __GDPR__ - "pr.merge.success" : {} - */ - this.telemetry.sendTelemetryEvent('pr.merge.success'); - this._onDidMergePullRequest.fire(); - return x.data; - }) - .catch(e => { - /* __GDPR__ - "pr.merge.failure" : {} - */ - this.telemetry.sendTelemetryErrorEvent('pr.merge.failure'); - throw e; - }); - } - - async deleteBranch(pullRequest: PullRequestModel) { - await pullRequest.githubRepository.deleteBranch(pullRequest); - } - - private async getBranchDeletionItems() { - const allConfigs = await this.repository.getConfigs(); - const branchInfos: Map = new Map(); - - allConfigs.forEach(config => { - const key = config.key; - const matches = /^branch\.(.*)\.(.*)$/.exec(key); - - if (matches && matches.length === 3) { - const branchName = matches[1]; - - if (!branchInfos.has(branchName)) { - branchInfos.set(branchName, {}); - } - - const value = branchInfos.get(branchName); - if (matches[2] === 'remote') { - value!['remote'] = config.value; - } - - if (matches[2] === 'github-pr-owner-number') { - const metadata = PullRequestGitHelper.parsePullRequestMetadata(config.value); - value!['metadata'] = metadata; - } - - branchInfos.set(branchName, value!); - } - }); - - const actions: (vscode.QuickPickItem & { metadata: PullRequestMetadata; legacy?: boolean })[] = []; - branchInfos.forEach((value, key) => { - if (value.metadata) { - const activePRUrl = this.activePullRequest && this.activePullRequest.base.repositoryCloneUrl; - const matchesActiveBranch = activePRUrl - ? activePRUrl.owner === value.metadata.owner && - activePRUrl.repositoryName === value.metadata.repositoryName && - this.activePullRequest && - this.activePullRequest.number === value.metadata.prNumber - : false; - - if (!matchesActiveBranch) { - actions.push({ - label: `${key}`, - description: `${value.metadata!.repositoryName}/${value.metadata!.owner} #${value.metadata.prNumber - }`, - picked: false, - metadata: value.metadata!, - }); - } - } - }); - - const results = await Promise.all( - actions.map(async action => { - const metadata = action.metadata; - const githubRepo = this._githubRepositories.find( - repo => - repo.remote.owner.toLowerCase() === metadata!.owner.toLowerCase() && - repo.remote.repositoryName.toLowerCase() === metadata!.repositoryName.toLowerCase(), - ); - - if (!githubRepo) { - return action; - } - - const { remote, query, schema } = await githubRepo.ensure(); - try { - const { data } = await query({ - query: schema.PullRequestState, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: metadata!.prNumber, - }, - }); - - action.legacy = data.repository.pullRequest.state !== 'OPEN'; - } catch { } - - return action; - }), - ); - - results.forEach(result => { - if (result.legacy) { - result.picked = true; - } else { - result.description = vscode.l10n.t('{0} is still Open', result.description!); - } - }); - - return results; - } - - public async cleanupAfterPullRequest(branchName: string, pullRequest: PullRequestModel) { - const defaults = await this.getPullRequestDefaults(); - if (branchName === defaults.base) { - Logger.debug('Not cleaning up default branch.', FolderRepositoryManager.ID); - return; - } - if (pullRequest.author.login === (await this.getCurrentUser()).login) { - Logger.debug('Not cleaning up user\'s branch.', FolderRepositoryManager.ID); - return; - } - const branch = await this.repository.getBranch(branchName); - const remote = branch.upstream?.remote; - try { - Logger.debug(`Cleaning up branch ${branchName}`, FolderRepositoryManager.ID); - await this.repository.deleteBranch(branchName); - } catch (e) { - // The branch probably had unpushed changes and cannot be deleted. - return; - } - if (!remote) { - return; - } - const remotes = await this.getDeleatableRemotes(undefined); - if (remotes.has(remote) && remotes.get(remote)!.createdForPullRequest) { - Logger.debug(`Cleaning up remote ${remote}`, FolderRepositoryManager.ID); - this.repository.removeRemote(remote); - } - } - - private async getDeleatableRemotes(nonExistantBranches?: Set) { - const newConfigs = await this.repository.getConfigs(); - const remoteInfos: Map< - string, - { branches: Set; url?: string; createdForPullRequest?: boolean } - > = new Map(); - - newConfigs.forEach(config => { - const key = config.key; - let matches = /^branch\.(.*)\.(.*)$/.exec(key); - - if (matches && matches.length === 3) { - const branchName = matches[1]; - - if (matches[2] === 'remote') { - const remoteName = config.value; - - if (!remoteInfos.has(remoteName)) { - remoteInfos.set(remoteName, { branches: new Set() }); - } - - if (!nonExistantBranches?.has(branchName)) { - const value = remoteInfos.get(remoteName); - value!.branches.add(branchName); - } - } - } - - matches = /^remote\.(.*)\.(.*)$/.exec(key); - - if (matches && matches.length === 3) { - const remoteName = matches[1]; - - if (!remoteInfos.has(remoteName)) { - remoteInfos.set(remoteName, { branches: new Set() }); - } - - const value = remoteInfos.get(remoteName); - - if (matches[2] === 'github-pr-remote') { - value!.createdForPullRequest = config.value === 'true'; - } - - if (matches[2] === 'url') { - value!.url = config.value; - } - } - }); - return remoteInfos; - } - - private async getRemoteDeletionItems(nonExistantBranches: Set) { - // check if there are remotes that should be cleaned - const remoteInfos = await this.getDeleatableRemotes(nonExistantBranches); - const remoteItems: (vscode.QuickPickItem & { remote: string })[] = []; - - remoteInfos.forEach((value, key) => { - if (value.branches.size === 0) { - let description = value.createdForPullRequest ? '' : vscode.l10n.t('Not created by GitHub Pull Request extension'); - if (value.url) { - description = description ? `${description} ${value.url}` : value.url; - } - - remoteItems.push({ - label: key, - description: description, - picked: value.createdForPullRequest, - remote: key, - }); - } - }); - - return remoteItems; - } - - async deleteLocalBranchesNRemotes() { - return new Promise(async resolve => { - const quickPick = vscode.window.createQuickPick(); - quickPick.canSelectMany = true; - quickPick.ignoreFocusOut = true; - quickPick.placeholder = vscode.l10n.t('Choose local branches you want to delete permanently'); - quickPick.show(); - quickPick.busy = true; - - // Check local branches - const results = await this.getBranchDeletionItems(); - const defaults = await this.getPullRequestDefaults(); - quickPick.items = results; - quickPick.selectedItems = results.filter(result => { - // Do not pick the default branch for the repo. - return result.picked && !((result.label === defaults.base) && (result.metadata.owner === defaults.owner) && (result.metadata.repositoryName === defaults.repo)); - }); - quickPick.busy = false; - - let firstStep = true; - quickPick.onDidAccept(async () => { - quickPick.busy = true; - - if (firstStep) { - const picks = quickPick.selectedItems; - const nonExistantBranches = new Set(); - if (picks.length) { - try { - await Promise.all( - picks.map(async pick => { - try { - await this.repository.deleteBranch(pick.label, true); - } catch (e) { - if ((typeof e.stderr === 'string') && (e.stderr as string).includes('not found')) { - // TODO: The git extension API doesn't support removing configs - // If that support is added we should remove the config as it is no longer useful. - nonExistantBranches.add(pick.label); - } else { - throw e; - } - } - })); - } catch (e) { - quickPick.hide(); - vscode.window.showErrorMessage(vscode.l10n.t('Deleting branches failed: {0} {1}', e.message, e.stderr)); - } - } - - firstStep = false; - const remoteItems = await this.getRemoteDeletionItems(nonExistantBranches); - - if (remoteItems && remoteItems.length) { - quickPick.placeholder = vscode.l10n.t('Choose remotes you want to delete permanently'); - quickPick.items = remoteItems; - quickPick.selectedItems = remoteItems.filter(item => item.picked); - } else { - quickPick.hide(); - } - } else { - // delete remotes - const picks = quickPick.selectedItems; - if (picks.length) { - await Promise.all( - picks.map(async pick => { - await this.repository.removeRemote(pick.label); - }), - ); - } - quickPick.hide(); - } - quickPick.busy = false; - }); - - quickPick.onDidHide(() => { - resolve(); - }); - }); - } - - async getPullRequestRepositoryDefaultBranch(issue: IssueModel): Promise { - const branch = await issue.githubRepository.getDefaultBranch(); - return branch; - } - - async getPullRequestRepositoryAccessAndMergeMethods( - pullRequest: PullRequestModel, - ): Promise { - const mergeOptions = await pullRequest.githubRepository.getRepoAccessAndMergeMethods(); - return mergeOptions; - } - - async fulfillPullRequestMissingInfo(pullRequest: PullRequestModel): Promise { - try { - if (!pullRequest.isResolved()) { - return; - } - - Logger.debug(`Fulfill pull request missing info - start`, FolderRepositoryManager.ID); - const githubRepository = pullRequest.githubRepository; - const { octokit, remote } = await githubRepository.ensure(); - - if (!pullRequest.base) { - const { data } = await octokit.call(octokit.api.pulls.get, { - owner: remote.owner, - repo: remote.repositoryName, - pull_number: pullRequest.number, - }); - pullRequest.update(convertRESTPullRequestToRawPullRequest(data, githubRepository)); - } - - if (!pullRequest.mergeBase) { - const { data } = await octokit.call(octokit.api.repos.compareCommits, { - repo: remote.repositoryName, - owner: remote.owner, - base: `${pullRequest.base.repositoryCloneUrl.owner}:${pullRequest.base.ref}`, - head: `${pullRequest.head.repositoryCloneUrl.owner}:${pullRequest.head.ref}`, - }); - - pullRequest.mergeBase = data.merge_base_commit.sha; - } - } catch (e) { - vscode.window.showErrorMessage(vscode.l10n.t('Fetching Pull Request merge base failed: {0}', formatError(e))); - } - Logger.debug(`Fulfill pull request missing info - done`, FolderRepositoryManager.ID); - } - - //#region Git related APIs - - private async resolveItem(owner: string, repositoryName: string): Promise { - let githubRepo = this._githubRepositories.find(repo => { - const ret = - repo.remote.owner.toLowerCase() === owner.toLowerCase() && - repo.remote.repositoryName.toLowerCase() === repositoryName.toLowerCase(); - return ret; - }); - - if (!githubRepo) { - // try to create the repository - githubRepo = await this.createGitHubRepositoryFromOwnerName(owner, repositoryName); - } - return githubRepo; - } - - async resolvePullRequest( - owner: string, - repositoryName: string, - pullRequestNumber: number, - ): Promise { - const githubRepo = await this.resolveItem(owner, repositoryName); - if (githubRepo) { - return githubRepo.getPullRequest(pullRequestNumber); - } - return undefined; - } - - async resolveIssue( - owner: string, - repositoryName: string, - pullRequestNumber: number, - withComments: boolean = false, - ): Promise { - const githubRepo = await this.resolveItem(owner, repositoryName); - if (githubRepo) { - return githubRepo.getIssue(pullRequestNumber, withComments); - } - return undefined; - } - - async resolveUser(owner: string, repositoryName: string, login: string): Promise { - Logger.debug(`Fetch user ${login}`, FolderRepositoryManager.ID); - const githubRepository = await this.createGitHubRepositoryFromOwnerName(owner, repositoryName); - const { query, schema } = await githubRepository.ensure(); - - try { - const { data } = await query({ - query: schema.GetUser, - variables: { - login, - }, - }); - return parseGraphQLUser(data, githubRepository); - } catch (e) { - // Ignore cases where the user doesn't exist - if (!(e.message as (string | undefined))?.startsWith('GraphQL error: Could not resolve to a User with the login of')) { - Logger.warn(e.message); - } - } - return undefined; - } - - async getMatchingPullRequestMetadataForBranch() { - if (!this.repository || !this.repository.state.HEAD || !this.repository.state.HEAD.name) { - return null; - } - - const matchingPullRequestMetadata = await PullRequestGitHelper.getMatchingPullRequestMetadataForBranch( - this.repository, - this.repository.state.HEAD.name, - ); - return matchingPullRequestMetadata; - } - - async getMatchingPullRequestMetadataFromGitHub(remoteName?: string, upstreamBranchName?: string): Promise< - (PullRequestMetadata & { model: PullRequestModel }) | null - > { - if (!remoteName || !upstreamBranchName) { - return null; - } - - const headGitHubRepo = this.gitHubRepositories.find( - repo => repo.remote.remoteName === remoteName, - ); - - // Search through each github repo to see if it has a PR with this head branch. - for (const repo of this.gitHubRepositories) { - const matchingPullRequest = await repo.getPullRequestForBranch(upstreamBranchName); - if (matchingPullRequest && (matchingPullRequest.head?.owner === headGitHubRepo?.remote.owner)) { - return { - owner: repo.remote.owner, - repositoryName: repo.remote.repositoryName, - prNumber: matchingPullRequest.number, - model: matchingPullRequest, - }; - } - } - return null; - } - - async checkoutExistingPullRequestBranch(pullRequest: PullRequestModel, progress: vscode.Progress<{ message?: string; increment?: number }>): Promise { - return await PullRequestGitHelper.checkoutExistingPullRequestBranch(this.repository, pullRequest, progress); - } - - async getBranchNameForPullRequest(pullRequest: PullRequestModel) { - return await PullRequestGitHelper.getBranchNRemoteForPullRequest(this.repository, pullRequest); - } - - async fetchAndCheckout(pullRequest: PullRequestModel, progress: vscode.Progress<{ message?: string; increment?: number }>): Promise { - await PullRequestGitHelper.fetchAndCheckout(this.repository, this._allGitHubRemotes, pullRequest, progress); - } - - async checkout(branchName: string): Promise { - return this.repository.checkout(branchName); - } - - async fetchById(githubRepo: GitHubRepository, id: number): Promise { - const pullRequest = await githubRepo.getPullRequest(id); - if (pullRequest) { - return pullRequest; - } else { - vscode.window.showErrorMessage(vscode.l10n.t('Pull request number {0} does not exist in {1}', id, `${githubRepo.remote.owner}/${githubRepo.remote.repositoryName}`), { modal: true }); - } - } - - public async checkoutDefaultBranch(branch: string): Promise { - let branchObj: Branch | undefined; - try { - branchObj = await this.repository.getBranch(branch); - - const currentBranch = this.repository.state.HEAD?.name; - if (currentBranch === branchObj.name) { - const chooseABranch = vscode.l10n.t('Choose a Branch'); - vscode.window.showInformationMessage(vscode.l10n.t('The default branch is already checked out.'), chooseABranch).then(choice => { - if (choice === chooseABranch) { - return git.checkout(); - } - }); - return; - } - - if (branchObj.upstream && branch === branchObj.upstream.name) { - await this.repository.checkout(branch); - } else { - await git.checkout(); - } - - const fileClose: Thenable[] = []; - // Close the PR description and any open review scheme files. - for (const tabGroup of vscode.window.tabGroups.all) { - for (const tab of tabGroup.tabs) { - let uri: vscode.Uri | string | undefined; - if (tab.input instanceof vscode.TabInputText) { - uri = tab.input.uri; - } else if (tab.input instanceof vscode.TabInputTextDiff) { - uri = tab.input.original; - } else if (tab.input instanceof vscode.TabInputWebview) { - uri = tab.input.viewType; - } - if ((uri instanceof vscode.Uri && uri.scheme === Schemes.Review) || (typeof uri === 'string' && uri.endsWith(PULL_REQUEST_OVERVIEW_VIEW_TYPE))) { - fileClose.push(vscode.window.tabGroups.close(tab)); - } - } - } - await Promise.all(fileClose); - } catch (e) { - if (e.gitErrorCode) { - // for known git errors, we should provide actions for users to continue. - if (e.gitErrorCode === GitErrorCodes.DirtyWorkTree) { - vscode.window.showErrorMessage( - vscode.l10n.t('Your local changes would be overwritten by checkout, please commit your changes or stash them before you switch branches'), - ); - return; - } - } - Logger.error(`Exiting failed: ${e}. Target branch ${branch} used to find branch ${branchObj?.name ?? 'unknown'} with upstream ${branchObj?.upstream ?? 'unknown'}.`); - vscode.window.showErrorMessage(`Exiting failed: ${e}`); - } - } - - private async pullBranchConfiguration(): Promise<'never' | 'prompt' | 'always'> { - const neverShowPullNotification = this.context.globalState.get(NEVER_SHOW_PULL_NOTIFICATION, false); - if (neverShowPullNotification) { - this.context.globalState.update(NEVER_SHOW_PULL_NOTIFICATION, false); - await vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).update(PULL_BRANCH, 'never', vscode.ConfigurationTarget.Global); - } - return vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get<'never' | 'prompt' | 'always'>(PULL_BRANCH, 'prompt'); - } - - private async pullBranch(branch: Branch) { - if (this._repository.state.HEAD?.name === branch.name) { - await this._repository.pull(); - } - } - - private async promptPullBrach(pr: PullRequestModel, branch: Branch, autoStashSetting?: boolean) { - if (!this._updateMessageShown || autoStashSetting) { - this._updateMessageShown = true; - const pull = vscode.l10n.t('Pull'); - const always = vscode.l10n.t('Always Pull'); - const never = vscode.l10n.t('Never Show Again'); - const options = [pull]; - if (!autoStashSetting) { - options.push(always, never); - } - const result = await vscode.window.showInformationMessage( - vscode.l10n.t('There are updates available for pull request {0}.', `${pr.number}: ${pr.title}`), - {}, - ...options - ); - - if (result === pull) { - await this.pullBranch(branch); - this._updateMessageShown = false; - } else if (never) { - await vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).update(PULL_BRANCH, 'never', vscode.ConfigurationTarget.Global); - } else if (always) { - await vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).update(PULL_BRANCH, 'always', vscode.ConfigurationTarget.Global); - await this.pullBranch(branch); - } - } - } - - private _updateMessageShown: boolean = false; - public async checkBranchUpToDate(pr: PullRequestModel & IResolvedPullRequestModel, shouldFetch: boolean): Promise { - if (this.activePullRequest?.id !== pr.id) { - return; - } - const branch = this._repository.state.HEAD; - if (branch) { - const remote = branch.upstream ? branch.upstream.remote : null; - const remoteBranch = branch.upstream ? branch.upstream.name : branch.name; - if (remote) { - try { - if (shouldFetch) { - await this._repository.fetch(remote, remoteBranch); - } - } catch (e) { - if (e.stderr) { - if ((e.stderr as string).startsWith('fatal: couldn\'t find remote ref')) { - // We've managed to check out the PR, but the remote has been deleted. This is fine, but we can't fetch now. - } else { - vscode.window.showErrorMessage(vscode.l10n.t('An error occurred when fetching the repository: {0}', e.stderr)); - } - } - Logger.error(`Error when fetching: ${e.stderr ?? e}`, FolderRepositoryManager.ID); - } - const pullBranchConfiguration = await this.pullBranchConfiguration(); - if (branch.behind !== undefined && branch.behind > 0) { - switch (pullBranchConfiguration) { - case 'always': { - const autoStash = vscode.workspace.getConfiguration('git').get('autoStash', false); - if (autoStash) { - return this.promptPullBrach(pr, branch, autoStash); - } else { - return this.pullBranch(branch); - } - } - case 'prompt': { - return this.promptPullBrach(pr, branch); - } - case 'never': return; - } - } - - } - } - } - - private findExistingGitHubRepository(remote: { owner: string, repositoryName: string, remoteName?: string }): GitHubRepository | undefined { - return this._githubRepositories.find( - r => - (r.remote.owner.toLowerCase() === remote.owner.toLowerCase()) - && (r.remote.repositoryName.toLowerCase() === remote.repositoryName.toLowerCase()) - && (!remote.remoteName || (r.remote.remoteName === remote.remoteName)), - ); - } - - private async createAndAddGitHubRepository(remote: Remote, credentialStore: CredentialStore) { - const repo = new GitHubRepository(GitHubRemote.remoteAsGitHub(remote, await this._githubManager.isGitHub(remote.gitProtocol.normalizeUri()!)), this.repository.rootUri, credentialStore, this.telemetry); - this._githubRepositories.push(repo); - return repo; - } - - async createGitHubRepository(remote: Remote, credentialStore: CredentialStore): Promise { - return this.findExistingGitHubRepository(remote) ?? - this.createAndAddGitHubRepository(remote, credentialStore); - } - - async createGitHubRepositoryFromOwnerName(owner: string, repositoryName: string): Promise { - const existing = this.findExistingGitHubRepository({ owner, repositoryName }); - if (existing) { - return existing; - } - const uri = `https://github.com/${owner}/${repositoryName}`; - const gitRemotes = parseRepositoryRemotes(this.repository); - const gitRemote = gitRemotes.find(r => r.owner === owner && r.repositoryName === repositoryName); - return this.createAndAddGitHubRepository(new Remote(gitRemote?.remoteName ?? repositoryName, uri, new Protocol(uri)), this._credentialStore); - } - - async findUpstreamForItem(item: { - remote: Remote; - githubRepository: GitHubRepository; - }): Promise<{ needsFork: boolean; upstream?: GitHubRepository; remote?: Remote }> { - let upstream: GitHubRepository | undefined; - let existingForkRemote: Remote | undefined; - for (const githubRepo of this.gitHubRepositories) { - if ( - !upstream && - githubRepo.remote.owner === item.remote.owner && - githubRepo.remote.repositoryName === item.remote.repositoryName - ) { - upstream = githubRepo; - continue; - } - const forkDetails = await githubRepo.getRepositoryForkDetails(); - if ( - forkDetails && - forkDetails.isFork && - forkDetails.parent.owner.login === item.remote.owner && - forkDetails.parent.name === item.remote.repositoryName - ) { - const foundforkPermission = await githubRepo.getViewerPermission(); - if ( - foundforkPermission === ViewerPermission.Admin || - foundforkPermission === ViewerPermission.Maintain || - foundforkPermission === ViewerPermission.Write - ) { - existingForkRemote = githubRepo.remote; - break; - } - } - } - let needsFork = false; - if (upstream && !existingForkRemote) { - const permission = await item.githubRepository.getViewerPermission(); - if ( - permission === ViewerPermission.Read || - permission === ViewerPermission.Triage || - permission === ViewerPermission.Unknown - ) { - needsFork = true; - } - } - return { needsFork, upstream, remote: existingForkRemote }; - } - - async forkWithProgress( - progress: vscode.Progress<{ message?: string; increment?: number }>, - githubRepository: GitHubRepository, - repoString: string, - matchingRepo: Repository, - ): Promise { - progress.report({ message: vscode.l10n.t('Forking {0}...', repoString) }); - const result = await githubRepository.fork(); - progress.report({ increment: 50 }); - if (!result) { - vscode.window.showErrorMessage( - vscode.l10n.t('Unable to create a fork of {0}. Check that your GitHub credentials are correct.', repoString), - ); - return; - } - - const workingRemoteName: string = - matchingRepo.state.remotes.length > 1 ? 'origin' : matchingRepo.state.remotes[0].name; - progress.report({ message: vscode.l10n.t('Adding remotes. This may take a few moments.') }); - await matchingRepo.renameRemote(workingRemoteName, 'upstream'); - await matchingRepo.addRemote(workingRemoteName, result); - // Now the extension is responding to all the git changes. - await new Promise(resolve => { - if (this.gitHubRepositories.length === 0) { - const disposable = this.onDidChangeRepositories(() => { - if (this.gitHubRepositories.length > 0) { - disposable.dispose(); - resolve(); - } - }); - } else { - resolve(); - } - }); - progress.report({ increment: 50 }); - return workingRemoteName; - } - - async doFork( - githubRepository: GitHubRepository, - repoString: string, - matchingRepo: Repository, - ): Promise { - return vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Creating Fork') }, - async progress => { - try { - return this.forkWithProgress(progress, githubRepository, repoString, matchingRepo); - } catch (e) { - vscode.window.showErrorMessage(`Creating fork failed: ${e}`); - } - return undefined; - }, - ); - } - - async tryOfferToFork(githubRepository: GitHubRepository): Promise { - const repoString = `${githubRepository.remote.owner}/${githubRepository.remote.repositoryName}`; - - const fork = vscode.l10n.t('Fork'); - const dontFork = vscode.l10n.t('Don\'t Fork'); - const response = await vscode.window.showInformationMessage( - vscode.l10n.t('You don\'t have permission to push to {0}. Do you want to fork {0}? This will modify your git remotes to set \`origin\` to the fork, and \`upstream\` to {0}.', repoString), - { modal: true }, - fork, - dontFork, - ); - switch (response) { - case fork: { - return this.doFork(githubRepository, repoString, this.repository); - } - case dontFork: - return false; - default: - return undefined; - } - } - - dispose() { - this._subs.forEach(sub => sub.dispose()); - } -} - -export function getEventType(text: string) { - switch (text) { - case 'committed': - return EventType.Committed; - case 'mentioned': - return EventType.Mentioned; - case 'subscribed': - return EventType.Subscribed; - case 'commented': - return EventType.Commented; - case 'reviewed': - return EventType.Reviewed; - default: - return EventType.Other; - } -} - -const ownedByMe: Predicate = repo => { - const { currentUser = null } = repo.octokit as any; - return currentUser && repo.remote.owner === currentUser.login; -}; - -export const byRemoteName = (name: string): Predicate => ({ remote: { remoteName } }) => - remoteName === name; - -export const titleAndBodyFrom = (message: string): { title: string; body: string } => { - const idxLineBreak = message.indexOf('\n'); - return { - title: idxLineBreak === -1 ? message : message.substr(0, idxLineBreak), - - body: idxLineBreak === -1 ? '' : message.slice(idxLineBreak + 1).trim(), - }; -}; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +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'; +import { AuthProvider, GitHubServerType } from '../common/authentication'; +import { commands, contexts } from '../common/executeCommands'; +import { InMemFileChange, SlimFileChange } from '../common/file'; +import { findLocalRepoRemoteFromGitHubRef } from '../common/githubRef'; +import { Disposable, disposeAll } from '../common/lifecycle'; +import Logger from '../common/logger'; +import { Protocol, ProtocolType } from '../common/protocol'; +import { GitHubRemote, parseRemote, parseRepositoryRemotes, parseRepositoryRemotesAsync, Remote } from '../common/remote'; +import { + ALLOW_FETCH, + AUTO_STASH, + CHAT_SETTINGS_NAMESPACE, + CHECKOUT_DEFAULT_BRANCH, + CHECKOUT_PULL_REQUEST_BASE_BRANCH, + DISABLE_AI_FEATURES, + GIT, + POST_DONE, + PR_SETTINGS_NAMESPACE, + PULL_BEFORE_CHECKOUT, + PULL_BRANCH, + REMOTES, + UPSTREAM_REMOTE, +} from '../common/settingKeys'; +import { ITelemetry } from '../common/telemetry'; +import { EventType } from '../common/timelineEvent'; +import { Schemes } from '../common/uri'; +import { AsyncPredicate, batchPromiseAll, compareIgnoreCase, formatError, Predicate } from '../common/utils'; +import { PULL_REQUEST_OVERVIEW_VIEW_TYPE } from '../common/webview'; +import { BRANCHES_ASSOCIATED_WITH_PRS, 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'; + +async function createConflictResolutionModel(pullRequest: PullRequestModel): Promise { + const head = pullRequest.head; + if (!head) { + throw new Error('No head found for pull request'); + } + const baseCommitSha = await pullRequest.getLatestBaseCommitSha(); + const prBaseOwner = pullRequest.base.owner; + const prHeadOwner = head.owner; + const prHeadRef = head.ref; + const repositoryName = (await pullRequest.githubRepository.ensure()).remote.repositoryName; + const potentialMergeConflicts: Conflict[] = []; + if (pullRequest.item.mergeable === PullRequestMergeability.Conflict) { + const mergeBaseIntoPrCompareData = await pullRequest.compareBaseBranchForMerge(prHeadOwner, prHeadRef, prBaseOwner, baseCommitSha); + if ((pullRequest.item.mergeable === PullRequestMergeability.Conflict) && (mergeBaseIntoPrCompareData.length >= 300)) { + // API limitation: it only returns the first 300 files + return undefined; + } + + const previousFilenames: Map = new Map(); + // We must also check all the previous file names of the files in the PR. Assemble a map with this info + for (const fileChange of pullRequest.fileChanges.values()) { + if (fileChange.previousFileName) { + previousFilenames.set(fileChange.previousFileName, fileChange); + } + } + const knownConflicts = new Set(pullRequest.conflicts); + for (const mergeFile of mergeBaseIntoPrCompareData) { + const fileChange = pullRequest.fileChanges.get(mergeFile.filename) ?? previousFilenames.get(mergeFile.filename); + if (fileChange && (knownConflicts.size === 0 || knownConflicts.has(fileChange.fileName))) { + const prHeadFilePath = fileChange.fileName; + let contentsConflict = false; + let filePathConflict = false; + let modeConflict = false; + if (mergeFile.status === 'modified') { + contentsConflict = true; + } + if (mergeFile.previous_filename || fileChange.previousFileName) { + filePathConflict = true; + } + potentialMergeConflicts.push({ prHeadFilePath, contentsConflict, filePathConflict, modeConflict }); + } + } + } + return new ConflictResolutionModel(potentialMergeConflicts, repositoryName, prBaseOwner, baseCommitSha, prHeadOwner, prHeadRef, + pullRequest.base.ref, pullRequest.mergeBase!); +} + +interface PageInformation { + pullRequestPage: number; + hasMorePages: boolean | null; +} + +export interface ItemsResponseResult { + items: T[]; + hasMorePages: boolean; + hasUnsearchedRepositories: boolean; + totalCount?: number; +} + +export class NoGitHubReposError extends Error { + constructor(public readonly repository: Repository) { + super(); + } + + override get message() { + return vscode.l10n.t('{0} has no GitHub remotes', this.repository.rootUri.toString()); + } +} + +export class DetachedHeadError extends Error { + constructor(public readonly repository: Repository) { + super(); + } + + override get message() { + return vscode.l10n.t('{0} has a detached HEAD (create a branch first)', this.repository.rootUri.toString()); + } +} + +export class BadUpstreamError extends Error { + constructor(public readonly branchName: string, public readonly upstreamRef: UpstreamRef, public readonly problem: string) { + super(); + } + + override get message() { + const { + upstreamRef: { remote, name }, + branchName, + problem, + } = this; + return vscode.l10n.t('The upstream ref {0} for branch {1} {2}.', `${remote}/${name}`, branchName, problem); + } +} + +export const ReposManagerStateContext: string = 'ReposManagerStateContext'; + +export enum ReposManagerState { + Initializing = 'Initializing', + NeedsAuthentication = 'NeedsAuthentication', + RepositoriesLoaded = 'RepositoriesLoaded', +} + +export interface PullRequestDefaults { + owner: string; + repo: string; + base: string; +} + +enum PagedDataType { + PullRequest, + IssueSearch, +} + +const CACHED_TEMPLATE_BODY = 'templateBody'; + +export class FolderRepositoryManager extends Disposable { + static ID = 'FolderRepositoryManager'; + + private _state: ReposManagerState = ReposManagerState.Initializing; + private _activePullRequest?: PullRequestModel; + private _activeIssue?: IssueModel; + private _githubRepositories: GitHubRepository[]; + private _allGitHubRemotes: GitHubRemote[] = []; + private _mentionableUsers?: { [key: string]: IAccount[] }; + private _fetchMentionableUsersPromise?: Promise<{ [key: string]: IAccount[] }>; + private _assignableUsers?: { [key: string]: IAccount[] }; + private _teamReviewers?: { [key: string]: ITeam[] }; + private _fetchAssignableUsersPromise?: Promise<{ [key: string]: IAccount[] }>; + private _fetchTeamReviewersPromise?: Promise<{ [key: string]: ITeam[] }>; + private _gitBlameCache: { [key: string]: string } = {}; + private _githubManager: GitHubManager; + private _repositoryPageInformation: Map = new Map(); + private _addedUpstreamCount: number = 0; + + 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()); + readonly onDidChangeActiveIssue: vscode.Event = this._onDidChangeActiveIssue.event; + + private _onDidLoadRepositories = this._register(new vscode.EventEmitter()); + readonly onDidLoadRepositories: vscode.Event = this._onDidLoadRepositories.event; + + private _onDidChangeRepositories = this._register(new vscode.EventEmitter<{ added: boolean }>()); + readonly onDidChangeRepositories: vscode.Event<{ added: boolean }> = this._onDidChangeRepositories.event; + + private _onDidChangeAssignableUsers = this._register(new vscode.EventEmitter()); + readonly onDidChangeAssignableUsers: vscode.Event = this._onDidChangeAssignableUsers.event; + + private _onDidChangeGithubRepositories = this._register(new vscode.EventEmitter()); + readonly onDidChangeGithubRepositories: vscode.Event = this._onDidChangeGithubRepositories.event; + + private _onDidChangePullRequestsEvents: vscode.Disposable[] = []; + private readonly _onDidChangeAnyPullRequests = this._register(new vscode.EventEmitter()); + readonly onDidChangeAnyPullRequests: vscode.Event = this._onDidChangeAnyPullRequests.event; + private readonly _onDidAddPullRequest = this._register(new vscode.EventEmitter()); + readonly onDidAddPullRequest: vscode.Event = this._onDidAddPullRequest.event; + + private _onDidDispose = this._register(new vscode.EventEmitter()); + readonly onDidDispose: vscode.Event = this._onDidDispose.event; + + private _sessionIgnoredRemoteNames: Set = new Set(); + + constructor( + private readonly _id: number, + public readonly context: vscode.ExtensionContext, + private _repository: Repository, + public readonly telemetry: ITelemetry, + private readonly _git: GitApiImpl, + private readonly _credentialStore: CredentialStore, + public readonly createPullRequestHelper: CreatePullRequestHelper, + public readonly themeWatcher: IThemeWatcher + ) { + super(); + this._githubRepositories = []; + this._githubManager = new GitHubManager(); + + this._register( + vscode.workspace.onDidChangeConfiguration(async e => { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${REMOTES}`)) { + await this.updateRepositories(); + } + }), + ); + + this._register(_credentialStore.onDidInitialize(() => this.updateRepositories())); + this._register({ dispose: () => disposeAll(this._onDidChangePullRequestsEvents) }); + + this.cleanStoredRepoState(); + } + + private cleanStoredRepoState() { + const deleteDate: number = new Date().valueOf() - 30 /*days*/ * 86400000 /*milliseconds in a day*/; + const reposState = this.context.globalState.get(REPO_KEYS); + if (reposState?.repos) { + let keysChanged = false; + Object.keys(reposState.repos).forEach(repo => { + const repoState = reposState.repos[repo]; + if ((repoState.stateModifiedTime ?? 0) < deleteDate) { + keysChanged = true; + delete reposState.repos[repo]; + } + }); + if (keysChanged) { + this.context.globalState.update(REPO_KEYS, reposState); + } + } + } + + private get id(): string { + return `${FolderRepositoryManager.ID}+${this._id}`; + } + + get gitHubRepositories(): GitHubRepository[] { + return this._githubRepositories; + } + + public async computeAllUnknownRemotes(): Promise { + const remotes = await parseRepositoryRemotesAsync(this.repository); + const potentialRemotes = remotes.filter(remote => remote.host); + const serverTypes = await Promise.all( + potentialRemotes.map(remote => this._githubManager.isGitHub(remote.gitProtocol.normalizeUri()!)), + ).catch(e => { + Logger.error(`Resolving GitHub remotes failed: ${e}`, this.id); + vscode.window.showErrorMessage(vscode.l10n.t('Resolving GitHub remotes failed: {0}', formatError(e))); + return []; + }); + const unknownRemotes: Remote[] = []; + let i = 0; + for (const potentialRemote of potentialRemotes) { + if (serverTypes[i] === GitHubServerType.None) { + unknownRemotes.push(potentialRemote); + } + i++; + } + return unknownRemotes; + } + + public async computeAllGitHubRemotes(): Promise { + const remotes = await parseRepositoryRemotesAsync(this.repository); + const potentialRemotes = remotes.filter(remote => remote.host); + const serverTypes = await Promise.all( + potentialRemotes.map(remote => this._githubManager.isGitHub(remote.gitProtocol.normalizeUri()!)), + ).catch(e => { + Logger.error(`Resolving GitHub remotes failed: ${e}`, this.id); + vscode.window.showErrorMessage(vscode.l10n.t('Resolving GitHub remotes failed: {0}', formatError(e))); + return []; + }); + const githubRemotes: GitHubRemote[] = []; + let i = 0; + for (const potentialRemote of potentialRemotes) { + if (serverTypes[i] !== GitHubServerType.None) { + githubRemotes.push(GitHubRemote.remoteAsGitHub(potentialRemote, serverTypes[i])); + } + i++; + } + return githubRemotes; + } + + public async getActiveGitHubRemotes(allGitHubRemotes: GitHubRemote[]): Promise { + const remotesSetting = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(REMOTES); + + if (!remotesSetting) { + Logger.error(`Unable to read remotes setting`, this.id); + return Promise.resolve([]); + } + + const missingRemotes = remotesSetting.filter(remote => { + return !allGitHubRemotes.some(repo => repo.remoteName === remote); + }); + + if (missingRemotes.length === remotesSetting.length) { + Logger.warn(`No remotes found. The following remotes are missing: ${missingRemotes.join(', ')}`); + } else { + Logger.debug(`Not all remotes found. The following remotes are missing: ${missingRemotes.join(', ')}`, this.id); + } + + Logger.debug(`Displaying configured remotes: ${remotesSetting.join(', ')}`, this.id); + + return remotesSetting + .map(remote => allGitHubRemotes.find(repo => repo.remoteName === remote)) + .filter((repo: GitHubRemote | undefined): repo is GitHubRemote => !!repo && !this._sessionIgnoredRemoteNames.has(repo.remoteName)); + } + + get activeIssue(): IssueModel | undefined { + return this._activeIssue; + } + + set activeIssue(issue: IssueModel | undefined) { + this._activeIssue = issue; + this._onDidChangeActiveIssue.fire(); + } + + get activePullRequest(): PullRequestModel | undefined { + return this._activePullRequest; + } + + set activePullRequest(pullRequest: PullRequestModel | undefined) { + if (pullRequest === this._activePullRequest) { + return; + } + const oldPR = this._activePullRequest; + if (this._activePullRequest) { + this._activePullRequest.isActive = false; + } + + if (pullRequest) { + pullRequest.isActive = true; + pullRequest.githubRepository.commentsHandler?.unregisterCommentController(pullRequest.number); + } + + this._activePullRequest = pullRequest; + this._onDidChangeActivePullRequest.fire({ old: oldPR, new: pullRequest }); + } + + get repository(): Repository { + return this._repository; + } + + set repository(repository: Repository) { + this._repository = repository; + } + + get credentialStore(): CredentialStore { + return this._credentialStore; + } + + /** + * Using these contexts is fragile in a multi-root workspace where multiple PRs are checked out. + * If you have two active PRs that have the same file path relative to their rootdir, then these context can get confused. + */ + public setFileViewedContext() { + const states = this.activePullRequest?.getViewedFileStates(); + if (states) { + commands.setContext(contexts.VIEWED_FILES, Array.from(states.viewed)); + commands.setContext(contexts.UNVIEWED_FILES, Array.from(states.unviewed)); + } else { + this.clearFileViewedContext(); + } + } + + private clearFileViewedContext() { + commands.setContext(contexts.VIEWED_FILES, []); + commands.setContext(contexts.UNVIEWED_FILES, []); + } + + public async loginAndUpdate() { + if (!this._credentialStore.isAnyAuthenticated()) { + const waitForRepos = new Promise(c => { + const onReposChange = this.onDidChangeRepositories(() => { + onReposChange.dispose(); + c(); + }); + }); + await this._credentialStore.login(AuthProvider.github); + await waitForRepos; + } + } + + private async getActiveRemotes(): Promise { + this._allGitHubRemotes = await this.computeAllGitHubRemotes(); + const activeRemotes = await this.getActiveGitHubRemotes(this._allGitHubRemotes); + + if (activeRemotes.length) { + await vscode.commands.executeCommand('setContext', 'github:hasGitHubRemotes', true); + Logger.appendLine(`Found GitHub remote for folder ${this.repository.rootUri.fsPath}`, this.id); + if (this._allGitHubRemotes.length > 1) { + await vscode.commands.executeCommand('setContext', 'github:hasMultipleGitHubRemotes', true); + } + } else { + Logger.appendLine(`No GitHub remotes found for folder ${this.repository.rootUri.fsPath}`, this.id); + } + + return activeRemotes; + } + + private _updatingRepositories: Promise | undefined; + async updateRepositories(silent: boolean = false): Promise { + if (this._updatingRepositories) { + await this._updatingRepositories; + } + this._updatingRepositories = this.doUpdateRepositories(silent); + return this._updatingRepositories; + } + + private checkForAuthMatch(activeRemotes: GitHubRemote[]): boolean { + // Check that our auth matches the remote. + let dotComCount = 0; + let enterpriseCount = 0; + for (const remote of activeRemotes) { + if (remote.githubServerType === GitHubServerType.GitHubDotCom) { + dotComCount++; + } else if (remote.githubServerType === GitHubServerType.Enterprise) { + enterpriseCount++; + } + } + + let isAuthenticated = this._credentialStore.isAuthenticated(AuthProvider.github) || this._credentialStore.isAuthenticated(AuthProvider.githubEnterprise); + if ((dotComCount > 0) && this._credentialStore.isAuthenticated(AuthProvider.github)) { + // good + } else if ((enterpriseCount > 0) && this._credentialStore.isAuthenticated(AuthProvider.githubEnterprise)) { + // also good + } else if (isAuthenticated && ((dotComCount > 0) || (enterpriseCount > 0))) { + // Not good. We have a mismatch between auth type and server type. + isAuthenticated = false; + } + vscode.commands.executeCommand('setContext', 'github:authenticated', isAuthenticated); + return isAuthenticated; + } + + get state(): ReposManagerState { + return this._state; + } + + private set state(state: ReposManagerState) { + if (state !== this._state) { + this._state = state; + this._onDidLoadRepositories.fire(state); + } + } + + private async doUpdateRepositories(silent: boolean): Promise { + if (this._git.state === 'uninitialized') { + Logger.appendLine('Cannot updates repositories as git is uninitialized', this.id); + + return false; + } + + const activeRemotes = await this.getActiveRemotes(); + const isAuthenticated = this.checkForAuthMatch(activeRemotes); + if (this.credentialStore.isAnyAuthenticated() && (activeRemotes.length === 0)) { + 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; + } + } + const repositories: GitHubRepository[] = []; + const resolveRemotePromises: Promise[] = []; + const oldRepositories: GitHubRepository[] = []; + this._githubRepositories.forEach(repo => oldRepositories.push(repo)); + + const authenticatedRemotes = activeRemotes.filter(remote => this._credentialStore.isAuthenticated(remote.authProviderId)); + for (const remote of authenticatedRemotes) { + const repository = await this.createGitHubRepository(remote, this._credentialStore); + resolveRemotePromises.push(repository.resolveRemote()); + repositories.push(repository); + } + + const cleanUpMissingSaml = async (missingSaml: GitHubRepository[]) => { + for (const missing of missingSaml) { + this._sessionIgnoredRemoteNames.add(missing.remote.remoteName); + this.removeGitHubRepository(missing.remote); + const index = repositories.indexOf(missing); + if (index > -1) { + repositories.splice(index, 1); + } + } + }; + + return Promise.all(resolveRemotePromises).then(async (remoteResults: boolean[]) => { + const missingSaml: GitHubRepository[] = []; + for (let i = 0; i < remoteResults.length; i++) { + if (!remoteResults[i]) { + missingSaml.push(repositories[i]); + } + } + if (missingSaml.length > 0) { + const result = await this._credentialStore.showSamlMessageAndAuth(missingSaml.map(repo => repo.remote.owner)); + // Make a test call to see if the user has SAML enabled. + const samlTest = result.canceled ? [] : await Promise.all(missingSaml.map(repo => repo.resolveRemote())); + const stillMissing = result.canceled ? missingSaml : samlTest.map((result, index) => !result ? missingSaml[index] : undefined).filter((repo): repo is GitHubRepository => !!repo); + // Make a test call to see if the user has SAML enabled. + if (stillMissing.length > 0) { + if (stillMissing.length === repositories.length) { + await vscode.window.showErrorMessage(vscode.l10n.t('SAML access was not provided. GitHub Pull Requests will not work.'), { modal: true }); + this.dispose(); + return true; + } + await vscode.window.showErrorMessage(vscode.l10n.t('SAML access was not provided. Some GitHub repositories will not be available.'), { modal: true }); + cleanUpMissingSaml(stillMissing); + } + } + + disposeAll(this._onDidChangePullRequestsEvents); + this._githubRepositories = repositories; + for (const repo of this._githubRepositories) { + this._onDidChangePullRequestsEvents.push(repo.onDidChangePullRequests(e => this._onDidChangeAnyPullRequests.fire(e))); + this._onDidChangePullRequestsEvents.push(repo.onDidAddPullRequest(e => this._onDidAddPullRequest.fire(e))); + } + oldRepositories.filter(old => this._githubRepositories.indexOf(old) < 0).forEach(repo => repo.dispose()); + + const repositoriesAdded = + oldRepositories.length !== this._githubRepositories.length ? + this.gitHubRepositories.filter(repo => + !oldRepositories.some(oldRepo => oldRepo.remote.equals(repo.remote)), + ) : []; + + if (repositoriesAdded.length > 0) { + this._onDidChangeGithubRepositories.fire(this._githubRepositories); + } + + if (this._githubRepositories.length && repositoriesAdded.length > 0) { + if (await this.checkIfMissingUpstream()) { + this.updateRepositories(silent); + return true; + } + } + + if (this.activePullRequest) { + this.getMentionableUsers(repositoriesAdded.length > 0); + } + + this.getAssignableUsers(repositoriesAdded.length > 0); + if (isAuthenticated && activeRemotes.length) { + this.state = ReposManagerState.RepositoriesLoaded; + // On first activation, associate local branches with PRs + // Do this asynchronously to not block the main flow + this.associateLocalBranchesWithPRsOnFirstActivation().catch(e => { + Logger.error(`Failed to associate branches with PRs: ${e}`, this.id); + }); + } else if (!isAuthenticated) { + this.state = ReposManagerState.NeedsAuthentication; + } + if (!silent) { + this._onDidChangeRepositories.fire({ added: repositoriesAdded.length > 0 }); + } + return true; + }); + } + + private async checkIfMissingUpstream(): Promise { + try { + const origin = await this.getOrigin(); + const metadata = await origin.getMetadata(); + const configuration = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE); + if (metadata.fork && metadata.parent && (configuration.get<'add' | 'never'>(UPSTREAM_REMOTE, 'add') === 'add')) { + const parentUrl = new Protocol(metadata.parent.git_url); + const missingParentRemote = !this._githubRepositories.some( + repo => + (compareIgnoreCase(repo.remote.owner, parentUrl.owner) === 0) && + (compareIgnoreCase(repo.remote.repositoryName, parentUrl.repositoryName) === 0), + ); + + if (missingParentRemote) { + const upstreamAvailable = !this.repository.state.remotes.some(remote => remote.name === 'upstream'); + const remoteName = upstreamAvailable ? 'upstream' : metadata.parent.owner?.login; + if (remoteName) { + // check the remotes to see what protocol is being used + const isSSH = this.gitHubRepositories[0].remote.gitProtocol.type === ProtocolType.SSH; + if (isSSH) { + await this.repository.addRemote(remoteName, metadata.parent.ssh_url); + } else { + await this.repository.addRemote(remoteName, metadata.parent.clone_url); + } + this._addedUpstreamCount++; + if (this._addedUpstreamCount > 1) { + // We've already added this remote, which means the user likely removed it. Let the user know they can disable this feature. + const neverOption = vscode.l10n.t('Set to `never`'); + vscode.window.showInformationMessage(vscode.l10n.t('An `upstream` remote has been added for this repository. You can disable this feature by setting `githubPullRequests.upstreamRemote` to `never`.'), neverOption) + .then(choice => { + if (choice === neverOption) { + configuration.update(UPSTREAM_REMOTE, 'never', vscode.ConfigurationTarget.Global); + } + }); + } + return true; + } + } + } + } catch (e) { + Logger.appendLine(`Missing upstream check failed: ${e}`, this.id); + // ignore + } + return false; + } + + getAllAssignableUsers(): IAccount[] | undefined { + if (this._assignableUsers) { + const allAssignableUsers: IAccount[] = []; + Object.keys(this._assignableUsers).forEach(k => { + allAssignableUsers.push(...this._assignableUsers![k]); + }); + + return allAssignableUsers; + } + + return undefined; + } + + private async getCachedFromGlobalState(userKind: 'assignableUsers' | 'teamReviewers' | 'mentionableUsers' | 'orgProjects'): Promise<{ [key: string]: T[] } | undefined> { + Logger.appendLine(`Trying to use globalState for ${userKind}.`, this.id); + + const usersCacheLocation = vscode.Uri.joinPath(this.context.globalStorageUri, userKind); + let usersCacheExists; + try { + usersCacheExists = await vscode.workspace.fs.stat(usersCacheLocation); + } catch (e) { + // file doesn't exit + } + if (!usersCacheExists) { + Logger.appendLine(`GlobalState does not exist for ${userKind}.`, this.id); + return undefined; + } + + const cache: { [key: string]: T[] } = {}; + const hasAllRepos = (await Promise.all(this._githubRepositories.map(async (repo) => { + const key = `${repo.remote.owner}/${repo.remote.repositoryName}.json`; + const repoSpecificFile = vscode.Uri.joinPath(usersCacheLocation, key); + let repoSpecificCache; + let cacheAsJson; + try { + repoSpecificCache = await vscode.workspace.fs.readFile(repoSpecificFile); + cacheAsJson = JSON.parse(repoSpecificCache.toString()); + } catch (e) { + if (e instanceof Error && e.message.includes('Unexpected non-whitespace character after JSON')) { + Logger.error(`Error parsing ${userKind} cache for ${repo.remote.remoteName}.`, this.id); + } + // file doesn't exist + } + if (repoSpecificCache && repoSpecificCache.toString()) { + cache[repo.remote.remoteName] = cacheAsJson ?? []; + return true; + } + }))).every(value => value); + if (hasAllRepos) { + Logger.appendLine(`Using globalState ${userKind} for ${Object.keys(cache).length}.`, this.id); + return cache; + } + + Logger.appendLine(`No globalState for ${userKind}.`, this.id); + return undefined; + } + + private async saveInGlobalState(userKind: 'assignableUsers' | 'teamReviewers' | 'mentionableUsers' | 'orgProjects', cache: { [key: string]: T[] }): Promise { + const cacheLocation = vscode.Uri.joinPath(this.context.globalStorageUri, userKind); + await Promise.all(this._githubRepositories.map(async (repo) => { + const key = `${repo.remote.owner}/${repo.remote.repositoryName}.json`; + const repoSpecificFile = vscode.Uri.joinPath(cacheLocation, key); + await vscode.workspace.fs.writeFile(repoSpecificFile, new TextEncoder().encode(JSON.stringify(cache[repo.remote.remoteName]))); + })); + } + + private createFetchMentionableUsersPromise(): Promise<{ [key: string]: IAccount[] }> { + const cache: { [key: string]: IAccount[] } = {}; + return new Promise<{ [key: string]: IAccount[] }>(resolve => { + const promises = this._githubRepositories.map(async githubRepository => { + const data = await githubRepository.getMentionableUsers(); + cache[githubRepository.remote.remoteName] = data; + return; + }); + + Promise.all(promises).then(() => { + this._mentionableUsers = cache; + this._fetchMentionableUsersPromise = undefined; + this.saveInGlobalState('mentionableUsers', cache) + .then(() => resolve(cache)); + }); + }); + } + + async getMentionableUsers(clearCache?: boolean): Promise<{ [key: string]: IAccount[] }> { + if (clearCache) { + delete this._mentionableUsers; + } + + if (this._mentionableUsers) { + Logger.appendLine('Using in-memory cached mentionable users.', this.id); + return this._mentionableUsers; + } + + const globalStateMentionableUsers = await this.getCachedFromGlobalState('mentionableUsers'); + + if (!this._fetchMentionableUsersPromise) { + this._fetchMentionableUsersPromise = this.createFetchMentionableUsersPromise(); + return globalStateMentionableUsers ?? this._fetchMentionableUsersPromise; + } + + return this._fetchMentionableUsersPromise; + } + + async getAssignableUsers(clearCache?: boolean): Promise<{ [key: string]: IAccount[] }> { + if (clearCache) { + delete this._assignableUsers; + } + + if (this._assignableUsers) { + Logger.appendLine('Using in-memory cached assignable users.', this.id); + return this._assignableUsers; + } + + const globalStateAssignableUsers = await this.getCachedFromGlobalState('assignableUsers'); + + if (!this._fetchAssignableUsersPromise) { + const cache: { [key: string]: IAccount[] } = {}; + const allAssignableUsers: IAccount[] = []; + this._fetchAssignableUsersPromise = new Promise(resolve => { + const promises = this._githubRepositories.map(async githubRepository => { + const data = await githubRepository.getAssignableUsers(); + cache[githubRepository.remote.remoteName] = data.sort(loginComparator); + allAssignableUsers.push(...data); + return; + }); + + Promise.all(promises).then(() => { + this._assignableUsers = cache; + this._fetchAssignableUsersPromise = undefined; + this.saveInGlobalState('assignableUsers', cache); + resolve(cache); + this._onDidChangeAssignableUsers.fire(allAssignableUsers); + }); + }); + return globalStateAssignableUsers ?? this._fetchAssignableUsersPromise; + } + + return this._fetchAssignableUsersPromise; + } + + async getTeamReviewers(refreshKind: TeamReviewerRefreshKind): Promise<{ [key: string]: ITeam[] }> { + if (refreshKind === TeamReviewerRefreshKind.Force) { + delete this._teamReviewers; + } + + if (this._teamReviewers) { + Logger.appendLine('Using in-memory cached team reviewers.', this.id); + return this._teamReviewers; + } + + const globalStateTeamReviewers = (refreshKind === TeamReviewerRefreshKind.Force) ? undefined : await this.getCachedFromGlobalState('teamReviewers'); + if (globalStateTeamReviewers) { + this._teamReviewers = globalStateTeamReviewers; + return globalStateTeamReviewers || {}; + } + + if (!this._fetchTeamReviewersPromise) { + const cache: { [key: string]: ITeam[] } = {}; + return (this._fetchTeamReviewersPromise = new Promise(async (resolve) => { + // Keep track of the org teams we have already gotten so we don't make duplicate calls + const orgTeams: Map = new Map(); + // Go through one github repo at a time so that we don't make overlapping auth calls + for (const githubRepository of this._githubRepositories) { + if (!orgTeams.has(githubRepository.remote.owner)) { + try { + const data = await githubRepository.getOrgTeams(refreshKind); + orgTeams.set(githubRepository.remote.owner, data); + } catch (e) { + break; + } + } + const allTeamsForOrg = orgTeams.get(githubRepository.remote.owner) ?? []; + cache[githubRepository.remote.remoteName] = allTeamsForOrg.filter(team => team.repositoryNames.includes(githubRepository.remote.repositoryName)).sort(teamComparator); + } + + this._teamReviewers = cache; + this._fetchTeamReviewersPromise = undefined; + this.saveInGlobalState('teamReviewers', cache); + resolve(cache); + })); + } + + return this._fetchTeamReviewersPromise; + } + + private createFetchOrgProjectsPromise(): Promise<{ [key: string]: IProject[] }> { + const cache: { [key: string]: IProject[] } = {}; + return new Promise<{ [key: string]: IProject[] }>(async resolve => { + // Keep track of the org teams we have already gotten so we don't make duplicate calls + const orgProjects: Map = new Map(); + // Go through one github repo at a time so that we don't make overlapping auth calls + for (const githubRepository of this._githubRepositories) { + if (!orgProjects.has(githubRepository.remote.owner)) { + try { + const data = await githubRepository.getOrgProjects(); + orgProjects.set(githubRepository.remote.owner, data); + } catch (e) { + break; + } + } + cache[githubRepository.remote.remoteName] = orgProjects.get(githubRepository.remote.owner) ?? []; + } + + await this.saveInGlobalState('orgProjects', cache); + resolve(cache); + }); + } + + async getOrgProjects(clearCache?: boolean): Promise<{ [key: string]: IProject[] }> { + if (clearCache) { + return this.createFetchOrgProjectsPromise(); + } + + const globalStateProjects = await this.getCachedFromGlobalState('orgProjects'); + return globalStateProjects ?? this.createFetchOrgProjectsPromise(); + } + + async getAllProjects(githubRepository: GitHubRepository, clearOrgCache?: boolean): Promise { + const isInOrganization = !!(await githubRepository.getMetadata()).organization; + const [repoProjects, orgProjects] = (await Promise.all([githubRepository.getProjects(), (isInOrganization ? this.getOrgProjects(clearOrgCache) : undefined)])); + return [...(repoProjects ?? []), ...(orgProjects ? orgProjects[githubRepository.remote.remoteName] : [])]; + } + + async getOrgTeamsCount(repository: GitHubRepository): Promise { + if ((await repository.getMetadata()).organization) { + return repository.getOrgTeamsCount(); + } + return 0; + } + + async getPullRequestParticipants(githubRepository: GitHubRepository, pullRequestNumber: number): Promise<{ participants: IAccount[], viewer: IAccount }> { + return { + participants: await githubRepository.getPullRequestParticipants(pullRequestNumber), + viewer: await this.getCurrentUser(githubRepository) + }; + } + + /** + * Returns the remotes that are currently active, which is those that are important by convention (origin, upstream), + * or the remotes configured by the setting githubPullRequests.remotes + */ + async getGitHubRemotes(): Promise { + const githubRepositories = this._githubRepositories; + + if (!githubRepositories || !githubRepositories.length) { + return []; + } + + const remotes = githubRepositories.map(repo => repo.remote).flat(); + + const serverTypes = await Promise.all( + remotes.map(remote => this._githubManager.isGitHub(remote.gitProtocol.normalizeUri()!)), + ).catch(e => { + Logger.error(`Resolving GitHub remotes failed: ${e}`, this.id); + vscode.window.showErrorMessage(vscode.l10n.t('Resolving GitHub remotes failed: {0}', formatError(e))); + return []; + }); + + const githubRemotes = remotes.map((remote, index) => GitHubRemote.remoteAsGitHub(remote, serverTypes[index])); + if (this.checkForAuthMatch(githubRemotes)) { + return githubRemotes; + } + return []; + } + + /** + * Returns all remotes from the repository. + */ + async getAllGitHubRemotes(): Promise { + return await this.computeAllGitHubRemotes(); + } + + async getLocalPullRequests(): Promise { + const githubRepositories = this._githubRepositories; + + if (!githubRepositories || !githubRepositories.length || !this.repository.getRefs) { + return []; + } + + const localBranches = (await this.repository.getRefs({ pattern: 'refs/heads/' })) + .filter(r => r.name !== undefined) + .map(r => r.name!); + + // Chunk localBranches into chunks of 100 to avoid hitting the GitHub API rate limit + const chunkedLocalBranches: string[][] = []; + const chunkSize = 100; + for (let i = 0; i < localBranches.length; i += chunkSize) { + const chunk = localBranches.slice(i, i + chunkSize); + chunkedLocalBranches.push(chunk); + } + + const models: (PullRequestModel | undefined)[] = []; + for (const chunk of chunkedLocalBranches) { + models.push(...await Promise.all(chunk.map(async localBranchName => { + const matchingPRMetadata = await PullRequestGitHelper.getMatchingPullRequestMetadataForBranch( + this.repository, + localBranchName, + ); + + if (matchingPRMetadata) { + const { owner, prNumber } = matchingPRMetadata; + const githubRepo = githubRepositories.find( + repo => repo.remote.owner.toLocaleLowerCase() === owner.toLocaleLowerCase(), + ); + + if (githubRepo) { + const pullRequest: PullRequestModel | undefined = await githubRepo.getPullRequest(prNumber); + + if (pullRequest) { + pullRequest.localBranchName = localBranchName; + return pullRequest; + } + } + } + }))); + } + + return models.filter(value => value !== undefined) as PullRequestModel[]; + } + + /** + * On first activation, iterate through local branches and associate them with PRs if they match. + * This helps discover PRs that were created before the extension was installed or in other ways. + */ + private async associateLocalBranchesWithPRsOnFirstActivation(): Promise { + const stateKey = `${BRANCHES_ASSOCIATED_WITH_PRS}.${this.repository.rootUri.fsPath}`; + const hasRun = this.context.globalState.get(stateKey, false); + + if (hasRun) { + Logger.debug('Branch association has already run for this workspace folder', this.id); + return; + } + + Logger.appendLine('First activation: associating local branches with PRs', this.id); + + const githubRepositories = this._githubRepositories; + if (!githubRepositories || !githubRepositories.length || !this.repository.getRefs) { + Logger.debug('No GitHub repositories or getRefs not available, skipping branch association', this.id); + await this.context.globalState.update(stateKey, true); + return; + } + + try { + // Only check the 3 most recently used branches to minimize API calls + const localBranches = (await this.repository.getRefs({ + pattern: 'refs/heads/', + sort: 'committerdate', + count: 10 + })) + .filter(r => r.name !== undefined) + .map(r => r.name!); + + Logger.debug(`Found ${localBranches.length} local branches to check`, this.id); + + const associationResults: boolean[] = []; + + // Process all branches (max 3) in parallel + const chunkResults = await Promise.all(localBranches.map(async branchName => { + try { + // Check if this branch already has PR metadata + const existingMetadata = await PullRequestGitHelper.getMatchingPullRequestMetadataForBranch( + this.repository, + branchName, + ); + + if (existingMetadata) { + // Branch already has PR metadata, skip + return false; + } + + // Get the branch to check its upstream + const branch = await this.repository.getBranch(branchName); + if (!branch.upstream) { + // No upstream, can't match to a PR + return false; + } + + // Try to find a matching PR on GitHub + const remoteName = branch.upstream.remote; + const upstreamBranchName = branch.upstream.name; + + const githubRepo = githubRepositories.find( + repo => repo.remote.remoteName === remoteName, + ); + + if (!githubRepo) { + return false; + } + + // Get the metadata of the GitHub repository to find owner + const metadata = await githubRepo.getMetadata(); + if (!metadata?.owner) { + return false; + } + + // Search for a PR with this head branch + const matchingPR = await githubRepo.getPullRequestForBranch(upstreamBranchName, metadata.owner.login); + + if (matchingPR) { + Logger.appendLine(`Found PR #${matchingPR.number} for branch ${branchName}, associating...`, this.id); + await PullRequestGitHelper.associateBranchWithPullRequest( + this.repository, + matchingPR, + branchName, + ); + return true; + } + return false; + } catch (e) { + Logger.debug(`Error checking branch ${branchName}: ${e}`, this.id); + // Continue with other branches even if one fails + return false; + } + })); + associationResults.push(...chunkResults); + + const associatedCount = associationResults.filter(r => r).length; + Logger.appendLine(`Branch association complete: ${associatedCount} branches associated with PRs`, this.id); + } catch (e) { + Logger.error(`Error during branch association: ${e}`, this.id); + } finally { + // Mark as complete even if there were errors + await this.context.globalState.update(stateKey, true); + } + } + + async getLabels(issue?: IssueModel, repoInfo?: { owner: string; repo: string }): Promise { + const repo = issue + ? issue.githubRepository + : this._githubRepositories.find( + r => r.remote.owner === repoInfo?.owner && r.remote.repositoryName === repoInfo?.repo, + ); + if (!repo) { + throw new Error(`No matching repository found for getting labels.`); + } + + const { remote, octokit } = await repo.ensure(); + let hasNextPage = false; + let page = 1; + let results: ILabel[] = []; + + do { + const result = await octokit.call(octokit.api.issues.listLabelsForRepo, { + owner: remote.owner, + repo: remote.repositoryName, + per_page: 100, + page, + }); + + results = results.concat( + result.data.map(label => { + return { + name: label.name, + color: label.color, + description: label.description ?? undefined + }; + }), + ); + + results = results.sort((a, b) => a.name.localeCompare(b.name)); + + hasNextPage = !!result.headers.link && result.headers.link.indexOf('rel="next"') > -1; + page += 1; + } while (hasNextPage); + + return results; + } + + async deleteLocalPullRequest(pullRequest: PullRequestModel, force?: boolean): Promise { + if (!pullRequest.localBranchName) { + return; + } + await this.repository.deleteBranch(pullRequest.localBranchName, force); + + let remoteName: string | undefined = undefined; + try { + remoteName = await this.repository.getConfig(`branch.${pullRequest.localBranchName}.remote`); + } catch (e) { } + + if (!remoteName) { + return; + } + + // If the extension created a remote for the branch, remove it if there are no other branches associated with it + const isPRRemote = await PullRequestGitHelper.isRemoteCreatedForPullRequest(this.repository, remoteName); + if (isPRRemote) { + const configs = await this.repository.getConfigs(); + const hasOtherAssociatedBranches = configs.some( + ({ key, value }) => /^branch.*\.remote$/.test(key) && value === remoteName, + ); + + if (!hasOtherAssociatedBranches) { + await this.repository.removeRemote(remoteName); + } + } + + /* __GDPR__ + "branch.delete" : {} + */ + this.telemetry.sendTelemetryEvent('branch.delete'); + } + + // Keep track of how many pages we've fetched for each query, so when we reload we pull the same ones. + private totalFetchedPages = new Map(); + + /** + * This method works in three different ways: + * 1) Initialize: fetch the first page of the first remote that has pages + * 2) Fetch Next: fetch the next page from this remote, or if it has no more pages, the first page from the next remote that does have pages + * 3) Restore: fetch all the pages you previously have fetched + * + * When `options.fetchNextPage === false`, we are in case 2. + * Otherwise: + * If `this.totalFetchQueries[queryId] === 0`, we are in case 1. + * Otherwise, we're in case 3. + */ + private async fetchPagedData( + options: IPullRequestsPagingOptions = { fetchNextPage: false }, + queryId: string, + pagedDataType: PagedDataType = PagedDataType.PullRequest, + type: PRType = PRType.All, + query?: string, + ): Promise> { + const githubRepositoriesWithGitRemotes = pagedDataType === PagedDataType.PullRequest ? this._githubRepositories.filter(repo => this.repository.state.remotes.find(r => r.name === repo.remote.remoteName)) : this._githubRepositories; + if (!githubRepositoriesWithGitRemotes.length) { + return { + items: [], + hasMorePages: false, + hasUnsearchedRepositories: false, + totalCount: 0 + }; + } + + const getTotalFetchedPages = () => this.totalFetchedPages.get(queryId) || 0; + const setTotalFetchedPages = (numPages: number) => this.totalFetchedPages.set(queryId, numPages); + + for (const repository of githubRepositoriesWithGitRemotes) { + const remoteId = repository.remote.url.toString() + queryId; + if (!this._repositoryPageInformation.get(remoteId)) { + this._repositoryPageInformation.set(remoteId, { + pullRequestPage: 0, + hasMorePages: null, + }); + } + } + + let pagesFetched = 0; + const itemData: ItemsData = { hasMorePages: false, items: [], totalCount: 0 }; + const addPage = (page: ItemsData | undefined) => { + pagesFetched++; + if (page) { + itemData.items = itemData.items.concat(page.items); + itemData.hasMorePages = page.hasMorePages; + itemData.totalCount = page.totalCount; + } + }; + + const activeGitHubRemotes = await this.getActiveGitHubRemotes(this._allGitHubRemotes); + + // Check if user has explicitly configured remotes (not using defaults) + const remotesConfig = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).inspect(REMOTES); + const hasUserConfiguredRemotes = !!(remotesConfig?.globalValue || remotesConfig?.workspaceValue || remotesConfig?.workspaceFolderValue); + + 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); + }); + + for (let i = 0; i < githubRepositories.length; i++) { + const githubRepository = githubRepositories[i]; + const remoteId = githubRepository.remote.url.toString() + queryId; + let storedPageInfo = this._repositoryPageInformation.get(remoteId); + if (!storedPageInfo) { + Logger.warn(`No page information for ${remoteId}`); + storedPageInfo = { pullRequestPage: 0, hasMorePages: null }; + this._repositoryPageInformation.set(remoteId, storedPageInfo); + } + const pageInformation = storedPageInfo; + + const fetchPage = async ( + pageNumber: number, + ): Promise<{ items: any[]; hasMorePages: boolean, totalCount?: number } | undefined> => { + // Resolve variables in the query with each repo + const resolvedQuery = query ? variableSubstitution(query, undefined, + { base: await githubRepository.getDefaultBranch(), owner: githubRepository.remote.owner, repo: githubRepository.remote.repositoryName }) : undefined; + switch (pagedDataType) { + case PagedDataType.PullRequest: { + if (type === PRType.All) { + return githubRepository.getAllPullRequests(pageNumber); + } else { + return this.getPullRequestsForCategory(githubRepository, resolvedQuery || '', pageNumber); + } + } + case PagedDataType.IssueSearch: { + return githubRepository.getIssues(pageInformation.pullRequestPage, resolvedQuery); + } + } + }; + + if (options.fetchNextPage) { + // Case 2. Fetch a single new page, and increment the global number of pages fetched for this query. + pageInformation.pullRequestPage++; + addPage(await fetchPage(pageInformation.pullRequestPage)); + setTotalFetchedPages(getTotalFetchedPages() + 1); + } else { + // Case 1&3. Fetch all the pages we have fetched in the past, or in case 1, just a single page. + + if (pageInformation.pullRequestPage === 0) { + // Case 1. Pretend we have previously fetched the first page, then hand off to the case 3 machinery to "fetch all pages we have fetched in the past" + pageInformation.pullRequestPage = 1; + } + + const pages = await Promise.all( + Array.from({ length: pageInformation.pullRequestPage }).map((_, j) => fetchPage(j + 1)), + ); + pages.forEach(page => addPage(page)); + } + + pageInformation.hasMorePages = itemData.hasMorePages; + + // Determine if we should break early from the loop: + // 1) we've received data AND + // 2) either we're fetching just the next page (case 2) + // OR we're fetching all (cases 1&3), and we've fetched as far as we had previously (or further, in case 1). + // 3) AND the user hasn't explicitly configured remotes (if they have, we should search all of them) + const hasReceivedData = itemData.items.length > 0; + const isFetchingNextPage = options.fetchNextPage; + const hasReachedPreviousFetchLimit = (options.fetchNextPage === false) && !options.fetchOnePagePerRepo && (pagesFetched >= getTotalFetchedPages()); + const shouldBreakEarly = hasReceivedData && (isFetchingNextPage || hasReachedPreviousFetchLimit) && !hasUserConfiguredRemotes; + + if (shouldBreakEarly) { + if (getTotalFetchedPages() === 0) { + // We're in case 1, manually set number of pages we looked through until we found first results. + setTotalFetchedPages(pagesFetched); + } + + return { + items: itemData.items, + hasMorePages: pageInformation.hasMorePages, + hasUnsearchedRepositories: i < githubRepositories.length - 1, + totalCount: itemData.totalCount, + }; + } + } + + return { + items: itemData.items, + hasMorePages: itemData.hasMorePages, + hasUnsearchedRepositories: false, + totalCount: itemData.totalCount + }; + } + + async getPullRequestsForCategory(githubRepository: GitHubRepository, categoryQuery: string, page?: number): Promise { + try { + Logger.debug(`Fetch pull request category ${categoryQuery} - enter`, this.id); + const { octokit, query, schema } = await githubRepository.ensure(); + + /* __GDPR__ + "pr.search.category" : { + } + */ + this.telemetry.sendTelemetryEvent('pr.search.category'); + + const user = (await githubRepository.getAuthenticatedUser()).login; + const { data, headers } = await octokit.call(octokit.api.search.issuesAndPullRequests, { + q: getPRFetchQuery(user, categoryQuery), + per_page: PULL_REQUEST_PAGE_SIZE, + advanced_search: 'true', + page: page || 1, + }); + + const promises: Promise<{ data: PullRequestResponse, repo: GitHubRepository } | undefined>[] = data.items.map(async (item) => { + const protocol = new Protocol(item.repository_url); + + const prRepo = await this.createGitHubRepositoryFromOwnerName(protocol.owner, protocol.repositoryName); + const { data } = await query({ + query: schema.PullRequest, + variables: { + owner: prRepo.remote.owner, + name: prRepo.remote.repositoryName, + number: item.number + } + }); + return { data, repo: prRepo }; + }); + + const hasMorePages = !!headers.link && headers.link.indexOf('rel="next"') > -1; + const pullRequestResponses = await Promise.all(promises); + + const pullRequests = (await Promise.all(pullRequestResponses + .map(async response => { + if (!response?.data.repository) { + Logger.appendLine('Pull request doesn\'t appear to exist.', this.id); + return null; + } + + // Pull requests fetched with a query can be from any repo. + // We need to use the correct GitHubRepository for this PR. + return response.repo.createOrUpdatePullRequestModel( + await parseGraphQLPullRequest(response.data.repository.pullRequest, response.repo), true + ); + }))) + .filter(item => item !== null) as PullRequestModel[]; + + Logger.debug(`Fetch pull request category ${categoryQuery} - done`, this.id); + + return { + items: pullRequests, + hasMorePages, + totalCount: data.total_count + }; + } catch (e) { + Logger.error(`Fetching pull request with query failed: ${e}`, this.id); + if (e.status === 404) { + // not found + vscode.window.showWarningMessage( + `Fetching pull requests for remote ${githubRepository.remote.remoteName} with query failed, please check if the repo ${githubRepository.remote.owner}/${githubRepository.remote.repositoryName} is valid.`, + ); + } else { + throw e; + } + } + return undefined; + } + + isPullRequestAssociatedWithOpenRepository(pullRequest: PullRequestModel): boolean { + const remote = pullRequest.githubRepository.remote; + const repository = this.repository.state.remotes.find(repo => repo.name === remote.remoteName); + if (repository) { + return true; + } + + return false; + } + + async getPullRequests( + type: PRType, + options: IPullRequestsPagingOptions = { fetchNextPage: false }, + query?: string, + ): Promise> { + const queryId = type.toString() + (query || ''); + return this.fetchPagedData(options, queryId, PagedDataType.PullRequest, type, query); + } + + async createMilestone(repository: GitHubRepository, milestoneTitle: string): Promise { + try { + const { data } = await repository.octokit.call(repository.octokit.api.issues.createMilestone, { + owner: repository.remote.owner, + repo: repository.remote.repositoryName, + title: milestoneTitle + }); + return { + title: data.title, + dueOn: data.due_on, + createdAt: data.created_at, + id: data.node_id, + number: data.number + }; + } + catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Failed to create a milestone\n{0}', formatError(e))); + return undefined; + } + } + + private async getRepoForIssue(parsedIssue: Issue): Promise { + const remote = new Remote( + parsedIssue.repositoryName!, + parsedIssue.repositoryUrl!, + new Protocol(parsedIssue.repositoryUrl!), + ); + return this.createGitHubRepository(remote, this.credentialStore, true, true); + + } + + /** + * Pull request defaults in the query, like owner and repository variables, will be resolved. + */ + async getIssues( + query?: string, options: IPullRequestsPagingOptions = { fetchNextPage: false, fetchOnePagePerRepo: false } + ): Promise | undefined> { + if (this.gitHubRepositories.length === 0) { + return undefined; + } + try { + const data = await this.fetchPagedData(options, `issuesKey${query}`, PagedDataType.IssueSearch, PRType.All, query); + const mappedData: ItemsResponseResult = { + items: [], + hasMorePages: data.hasMorePages, + hasUnsearchedRepositories: data.hasUnsearchedRepositories, + totalCount: data.totalCount + }; + for (const issue of data.items) { + const githubRepository = await this.getRepoForIssue(issue); + mappedData.items.push(new IssueModel(this.telemetry, githubRepository, githubRepository.remote, issue)); + } + return mappedData; + } catch (e) { + Logger.error(`Error fetching issues with query ${query}: ${e instanceof Error ? e.message : e}`, this.id); + return { hasMorePages: false, hasUnsearchedRepositories: false, items: [], totalCount: 0 }; + } + } + + async getMaxIssue(): Promise { + const maxIssues = await Promise.all( + this._githubRepositories.map(repository => { + return repository.getMaxIssue(); + }), + ); + let max: number = 0; + for (const issueNumber of maxIssues) { + if (issueNumber !== undefined) { + max = Math.max(max, issueNumber); + } + } + return max; + } + + async getIssueTemplates(): Promise { + const mdPattern = '{docs,.github}/ISSUE_TEMPLATE/*.md'; + const ymlPattern = '{docs,.github}/ISSUE_TEMPLATE/*.yml'; + const [mdTemplates, ymlTemplates] = await Promise.all([ + vscode.workspace.findFiles(new vscode.RelativePattern(this._repository.rootUri, mdPattern), null), + vscode.workspace.findFiles(new vscode.RelativePattern(this._repository.rootUri, ymlPattern), null) + ]); + return [...mdTemplates, ...ymlTemplates]; + } + + async getPullRequestTemplateBody(owner: string): Promise { + try { + const template = await this.getPullRequestTemplateWithCache(owner); + if (template) { + return template; + } + + // If there's no local template, look for a owner-wide template + return this.getOwnerPullRequestTemplate(owner); + } catch (e) { + Logger.error(`Error fetching pull request template for ${owner}: ${e instanceof Error ? e.message : e}`, this.id); + } + } + + async getAllPullRequestTemplates(owner: string): Promise { + try { + const repository = this.gitHubRepositories.find(repo => repo.remote.owner === owner); + if (!repository) { + return undefined; + } + const templates = await repository.getPullRequestTemplates(); + if (templates && templates.length > 0) { + return templates; + } + + // If there's no local template, look for owner-wide templates + const githubRepository = await this.createGitHubRepositoryFromOwnerName(owner, '.github'); + if (!githubRepository) { + return undefined; + } + return githubRepository.getPullRequestTemplates(); + } catch (e) { + Logger.error(`Error fetching pull request templates for ${owner}: ${e instanceof Error ? e.message : e}`, this.id); + return undefined; + } + } + + private async getPullRequestTemplateWithCache(owner: string): Promise { + const cacheLocation = `${CACHED_TEMPLATE_BODY}+${this.repository.rootUri.toString()}`; + + const findTemplate = this.getPullRequestTemplate(owner).then((template) => { + //update cache + if (template) { + this.context.workspaceState.update(cacheLocation, template); + } else { + this.context.workspaceState.update(cacheLocation, null); + } + return template; + }); + const hasCachedTemplate = this.context.workspaceState.keys().includes(cacheLocation); + const cachedTemplate = this.context.workspaceState.get(cacheLocation); + if (hasCachedTemplate) { + if (cachedTemplate === null) { + return undefined; + } else if (cachedTemplate) { + return cachedTemplate; + } + } + return findTemplate; + } + + private async getOwnerPullRequestTemplate(owner: string): Promise { + const githubRepository = await this.createGitHubRepositoryFromOwnerName(owner, '.github'); + if (!githubRepository) { + return undefined; + } + const templates = await githubRepository.getPullRequestTemplates(); + if (templates && templates?.length > 0) { + return templates[0]; + } + } + + private async getPullRequestTemplate(owner: string): Promise { + const repository = this.gitHubRepositories.find(repo => repo.remote.owner === owner); + if (!repository) { + return; + } + const templates = await repository.getPullRequestTemplates(); + return templates ? templates[0] : undefined; + } + + async getPullRequestDefaults(branch?: Branch): Promise { + if (!branch && !this.repository.state.HEAD) { + throw new DetachedHeadError(this.repository); + } + + const origin = await this.getOrigin(branch); + const meta = await origin.getMetadata(); + const remotesSettingDefault = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).inspect(REMOTES)?.defaultValue; + const remotesSettingSetValue = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(REMOTES); + const settingsEqual = (!remotesSettingSetValue || remotesSettingDefault?.every((value, index) => remotesSettingSetValue[index] === value)); + const parent = (meta.fork && meta.parent && settingsEqual) + ? meta.parent + : await (this.findRepo(byRemoteName('upstream')) || origin).getMetadata(); + + return { + owner: parent.owner!.login, + repo: parent.name, + base: getOverrideBranch() ?? parent.default_branch, + }; + } + + async getPullRequestDefaultRepo(): Promise { + const defaults = await this.getPullRequestDefaults(); + return this.findRepo(repo => repo.remote.owner === defaults.owner && repo.remote.repositoryName === defaults.repo) || this._githubRepositories[0]; + } + + async getMetadata(remote: string): Promise { + const repo = this.findRepo(byRemoteName(remote)); + return repo && repo.getMetadata(); + } + + async getHeadCommitMessage(): Promise { + const { repository } = this; + if (repository.state.HEAD && repository.state.HEAD.commit) { + const { message } = await repository.getCommit(repository.state.HEAD.commit); + return message; + } + + return ''; + } + + async getTipCommitMessage(branch: string): Promise { + Logger.debug(`Git tip message for branch ${branch} - enter`, this.id); + const { repository } = this; + let { commit } = await repository.getBranch(branch); + let message: string = ''; + let count = 0; + do { + if (commit) { + let fullCommit: Commit = await repository.getCommit(commit); + if (fullCommit.parents.length <= 1) { + message = fullCommit.message; + break; + } else { + commit = fullCommit.parents[0]; + } + } + count++; + } while (message === '' && commit && count < 5); + + + Logger.debug(`Git tip message for branch ${branch} - done`, this.id); + return message; + } + + async getOrigin(branch?: Branch): Promise { + if (!this._githubRepositories.length) { + throw new NoGitHubReposError(this.repository); + } + + const upstreamRef = branch ? branch.upstream : this.upstreamRef; + if (upstreamRef) { + // If our current branch has an upstream ref set, find its GitHubRepository. + const upstream = this.findRepo(byRemoteName(upstreamRef.remote)); + + // If the upstream wasn't listed in the remotes setting, create a GitHubRepository + // object for it if is does point to GitHub. + if (!upstream) { + const remote = (await this.getAllGitHubRemotes()).find(r => r.remoteName === upstreamRef.remote); + if (remote) { + return this.createAndAddGitHubRepository(remote, this._credentialStore); + } + + Logger.error(`The remote '${upstreamRef.remote}' is not a GitHub repository.`, this.id); + + // No GitHubRepository? We currently won't try pushing elsewhere, + // so fail. + throw new BadUpstreamError(this.repository.state.HEAD!.name!, upstreamRef, 'is not a GitHub repo'); + } + + // Otherwise, we'll push upstream. + return upstream; + } + + // If no upstream is set, let's go digging. + const [first, ...rest] = this._githubRepositories; + return !rest.length // Is there only one GitHub remote? + ? first // I GUESS THAT'S WHAT WE'RE GOING WITH, THEN. + : // Otherwise, let's try... + this.findRepo(byRemoteName('origin')) || // by convention + await this.findRepoAsync(ownedByMe) || // bc maybe we can push there + first; // out of raw desperation + } + + findRepo(where: Predicate): GitHubRepository | undefined { + 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; + } + + async createPullRequest(params: OctokitCommon.PullsCreateParams): Promise { + const repo = this._githubRepositories.find( + r => r.remote.owner === params.owner && r.remote.repositoryName === params.repo, + ); + if (!repo) { + throw new Error(`No matching repository ${params.repo} found for ${params.owner}`); + } + + let pullRequestModel: PullRequestModel | undefined; + try { + pullRequestModel = await repo.createPullRequest(params); + + const branchNameSeparatorIndex = params.head.indexOf(':'); + const branchName = params.head.slice(branchNameSeparatorIndex + 1); + await PullRequestGitHelper.associateBranchWithPullRequest(this._repository, pullRequestModel, branchName); + + /* __GDPR__ + "pr.create.success" : { + "isDraft" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this.telemetry.sendTelemetryEvent('pr.create.success', { isDraft: (params.draft || '').toString() }); + return pullRequestModel; + } catch (e) { + if (e.message.indexOf('No commits between ') > -1) { + // There are unpushed commits + if (this._repository.state.HEAD?.ahead) { + // Offer to push changes + const pushCommits = vscode.l10n.t({ message: 'Push Commits', comment: 'Pushes the local commits to the remote.' }); + const shouldPush = await vscode.window.showInformationMessage( + vscode.l10n.t('There are no commits between \'{0}\' and \'{1}\'.\n\nDo you want to push your local commits and create the pull request?', params.base, params.head), + { modal: true }, + pushCommits, + ); + if (shouldPush === pushCommits) { + await this._repository.push(); + return this.createPullRequest(params); + } else { + return; + } + } + + // There are uncommitted changes + if (this._repository.state.workingTreeChanges.length || this._repository.state.indexChanges.length) { + const commitChanges = vscode.l10n.t('Commit Changes'); + const shouldCommit = await vscode.window.showInformationMessage( + vscode.l10n.t('There are no commits between \'{0}\' and \'{1}\'.\n\nDo you want to commit your changes and create the pull request?', params.base, params.head), + { modal: true }, + commitChanges, + ); + if (shouldCommit === commitChanges) { + await this._repository.add(this._repository.state.indexChanges.map(change => change.uri.fsPath)); + await this.repository.commit(`${params.title}${params.body ? `\n${params.body}` : ''}`); + await this._repository.push(); + return this.createPullRequest(params); + } else { + return; + } + } + } + + Logger.error(`Creating pull requests failed: ${e}`, this.id); + + /* __GDPR__ + "pr.create.failure" : { + "isDraft" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this.telemetry.sendTelemetryErrorEvent('pr.create.failure', { + isDraft: (params.draft || '').toString(), + }); + + if (pullRequestModel) { + // We have created the pull request but something else failed (ex., modifying the git config) + // We shouldn't show an error as the pull request was successfully created + return pullRequestModel; + } + throw new Error(formatError(e)); + } + } + + async createIssue(params: OctokitCommon.IssuesCreateParams): Promise { + try { + const repo = this._githubRepositories.find( + r => r.remote.owner === params.owner && r.remote.repositoryName === params.repo, + ); + if (!repo) { + throw new Error(`No matching repository ${params.repo} found for ${params.owner}`); + } + + await repo.ensure(); + + // Create PR + const { data } = await repo.octokit.call(repo.octokit.api.issues.create, params); + const item = convertRESTIssueToRawPullRequest(data, repo); + const issueModel = new IssueModel(this.telemetry, repo, repo.remote, item); + + /* __GDPR__ + "issue.create.success" : { + } + */ + this.telemetry.sendTelemetryEvent('issue.create.success'); + return issueModel; + } catch (e) { + Logger.error(` Creating issue failed: ${e}`, this.id); + + /* __GDPR__ + "issue.create.failure" : {} + */ + this.telemetry.sendTelemetryErrorEvent('issue.create.failure'); + vscode.window.showWarningMessage(vscode.l10n.t('Creating issue failed: {0}', formatError(e))); + } + + return undefined; + } + + async assignIssue(issue: IssueModel, login: string): Promise { + try { + const repo = this._githubRepositories.find( + r => r.remote.owner === issue.remote.owner && r.remote.repositoryName === issue.remote.repositoryName, + ); + if (!repo) { + throw new Error( + `No matching repository ${issue.remote.repositoryName} found for ${issue.remote.owner}`, + ); + } + + await repo.ensure(); + + const param: OctokitCommon.IssuesAssignParams = { + assignees: [login], + owner: issue.remote.owner, + repo: issue.remote.repositoryName, + issue_number: issue.number, + }; + await repo.octokit.call(repo.octokit.api.issues.addAssignees, param); + + /* __GDPR__ + "issue.assign.success" : { + } + */ + this.telemetry.sendTelemetryEvent('issue.assign.success'); + } catch (e) { + Logger.error(`Assigning issue failed: ${e}`, this.id); + + /* __GDPR__ + "issue.assign.failure" : { + } + */ + this.telemetry.sendTelemetryErrorEvent('issue.assign.failure'); + vscode.window.showWarningMessage(vscode.l10n.t('Assigning issue failed: {0}', formatError(e))); + } + } + + getCurrentUser(githubRepository?: GitHubRepository): Promise { + if (!githubRepository) { + githubRepository = this.gitHubRepositories[0]; + } + return this._credentialStore.getCurrentUser(githubRepository.remote.authProviderId); + } + + async deleteBranch(pullRequest: PullRequestModel) { + await pullRequest.githubRepository.deleteBranch(pullRequest); + } + + private async getBranchDeletionItems() { + interface BranchDeletionMetadata extends PullRequestMetadata { + isOpen?: boolean; + } + + const allConfigs = await this.repository.getConfigs(); + const branchInfos: Map = new Map(); + + allConfigs.forEach(config => { + const key = config.key; + const matches = /^branch\.(.*)\.(.*)$/.exec(key); + + if (matches && matches.length === 3) { + const branchName = matches[1]; + + if (!branchInfos.has(branchName)) { + branchInfos.set(branchName, {}); + } + + const value = branchInfos.get(branchName)!; + if (matches[2] === 'remote') { + value['remote'] = config.value; + } + + if (matches[2] === 'github-pr-owner-number') { + const metadata = PullRequestGitHelper.parsePullRequestMetadata(config.value); + if (!value?.metadata) { + value['metadata'] = []; + } + if (metadata) { + // Check if the metadata already exists in the array + const existingMetadata = value.metadata.find(m => m.owner === metadata.owner && m.repositoryName === metadata.repositoryName && m.prNumber === metadata.prNumber); + if (!existingMetadata) { + value['metadata'].push(metadata); + } + } + } + + branchInfos.set(branchName, value!); + } + }); + Logger.debug(`Found ${branchInfos.size} possible branches to delete`, this.id); + Logger.trace(`Branches to delete: ${JSON.stringify(Array.from(branchInfos.keys()))}`, this.id); + + const actions: (vscode.QuickPickItem & { metadata: BranchDeletionMetadata[]; legacy?: boolean })[] = []; + branchInfos.forEach((value, key) => { + if (value.metadata) { + const activePRUrl = this.activePullRequest && this.activePullRequest.base.repositoryCloneUrl; + const activeMetadata = value.metadata.find(metadata => + metadata.owner === activePRUrl?.owner && + metadata.repositoryName === activePRUrl?.repositoryName && + metadata.prNumber === this.activePullRequest?.number + ); + + if (!activeMetadata) { + actions.push({ + label: `${key}`, + picked: false, + metadata: value.metadata, + }); + } else { + Logger.debug(`Skipping ${activeMetadata.prNumber}, active PR is #${this.activePullRequest?.number}`, this.id); + Logger.trace(`Skipping active branch ${key}`, this.id); + } + } + }); + + const results = await Promise.all( + actions.map(async action => { + const allOld = (await Promise.all( + action.metadata.map(async metadata => { + const githubRepo = this._githubRepositories.find( + repo => + repo.remote.owner.toLowerCase() === metadata!.owner.toLowerCase() && + repo.remote.repositoryName.toLowerCase() === metadata!.repositoryName.toLowerCase(), + ); + + if (!githubRepo) { + return action; + } + + const { remote, query, schema } = await githubRepo.ensure(); + try { + const { data } = await query({ + query: schema.PullRequestState, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: metadata!.prNumber, + }, + }); + metadata.isOpen = data.repository?.pullRequest.state === 'OPEN'; + return data.repository?.pullRequest.state !== 'OPEN'; + } catch { } + return false; + }))).every(result => result); + if (allOld) { + action.legacy = true; + } + + return action; + }), + ); + + results.forEach(result => { + if (result.metadata.length === 0) { + return; + } + result.description = `${result.metadata[0].repositoryName}/${result.metadata[0].owner} ${result.metadata.map(metadata => { + const prString = `#${metadata.prNumber}`; + return metadata.isOpen ? vscode.l10n.t('{0} is open', prString) : prString; + }).join(', ')}`; + if (result.legacy) { + result.picked = true; + } + }); + + return results; + } + + public gitRelativeRootPath(path: string) { + // get path relative to git root directory. Handles windows path by converting it to unix path. + return nodePath.relative(this._repository.rootUri.path, path).replace(/\\/g, '/'); + } + + public async cleanupAfterPullRequest(branchName: string, pullRequest: PullRequestModel) { + const defaults = await this.getPullRequestDefaults(); + if (branchName === defaults.base) { + Logger.debug('Not cleaning up default branch.', this.id); + return; + } + if (pullRequest.author.login === (await this.getCurrentUser()).login) { + Logger.debug('Not cleaning up user\'s branch.', this.id); + return; + } + const branch = await this.repository.getBranch(branchName); + const remote = branch.upstream?.remote; + try { + Logger.debug(`Cleaning up branch ${branchName}`, this.id); + await this.repository.deleteBranch(branchName); + } catch (e) { + // The branch probably had unpushed changes and cannot be deleted. + return; + } + if (!remote) { + return; + } + const remotes = await this.getDeleatableRemotes(undefined); + if (remotes.has(remote) && remotes.get(remote)!.createdForPullRequest) { + Logger.debug(`Cleaning up remote ${remote}`, this.id); + this.repository.removeRemote(remote); + } + } + + private async getDeleatableRemotes(nonExistantBranches?: Set) { + const newConfigs = await this.repository.getConfigs(); + const remoteInfos: Map< + string, + { branches: Set; url?: string; createdForPullRequest?: boolean } + > = new Map(); + + newConfigs.forEach(config => { + const key = config.key; + let matches = /^branch\.(.*)\.(.*)$/.exec(key); + + if (matches && matches.length === 3) { + const branchName = matches[1]; + + if (matches[2] === 'remote') { + const remoteName = config.value; + + if (!remoteInfos.has(remoteName)) { + remoteInfos.set(remoteName, { branches: new Set() }); + } + + if (!nonExistantBranches?.has(branchName)) { + const value = remoteInfos.get(remoteName); + value!.branches.add(branchName); + } + } + } + + matches = /^remote\.(.*)\.(.*)$/.exec(key); + + if (matches && matches.length === 3) { + const remoteName = matches[1]; + + if (!remoteInfos.has(remoteName)) { + remoteInfos.set(remoteName, { branches: new Set() }); + } + + const value = remoteInfos.get(remoteName); + + if (matches[2] === 'github-pr-remote') { + value!.createdForPullRequest = config.value === 'true'; + } + + if (matches[2] === 'url') { + value!.url = config.value; + } + } + }); + return remoteInfos; + } + + private async getRemoteDeletionItems(nonExistantBranches: Set) { + // check if there are remotes that should be cleaned + const remoteInfos = await this.getDeleatableRemotes(nonExistantBranches); + const remoteItems: (vscode.QuickPickItem & { remote: string })[] = []; + + remoteInfos.forEach((value, key) => { + if (value.branches.size === 0) { + let description = value.createdForPullRequest ? '' : vscode.l10n.t('Not created by GitHub Pull Request extension'); + if (value.url) { + description = description ? `${description} ${value.url}` : value.url; + } + + remoteItems.push({ + label: key, + description: description, + picked: value.createdForPullRequest, + remote: key, + }); + } + }); + + return remoteItems; + } + + private async deleteBranches(picks: readonly vscode.QuickPickItem[], nonExistantBranches: Set, progress: vscode.Progress<{ message?: string; increment?: number; }>, totalBranches: number, deletedBranches: number, needsRetry?: vscode.QuickPickItem[]) { + const reportProgress = () => { + deletedBranches++; + progress.report({ message: vscode.l10n.t('Deleted {0} of {1} branches', deletedBranches, totalBranches) }); + }; + + const deleteConfig = async (branch: string) => { + await PullRequestGitHelper.associateBaseBranchWithBranch(this.repository, branch, undefined); + await PullRequestGitHelper.associateBranchWithPullRequest(this.repository, undefined, branch); + }; + + // delete configs first since that can't be parallelized + for (const pick of picks) { + await deleteConfig(pick.label); + } + + // batch deleting the branches to avoid consuming all available resources + await batchPromiseAll(picks, 5, async (pick) => { + try { + await this.repository.deleteBranch(pick.label, true); + if ((await PullRequestGitHelper.getMatchingPullRequestMetadataForBranch(this.repository, pick.label))) { + console.log(`Branch ${pick.label} was not deleted`); + } + reportProgress(); + } catch (e) { + if (typeof e.stderr === 'string' && e.stderr.includes('not found')) { + nonExistantBranches.add(pick.label); + reportProgress(); + } else if (typeof e.stderr === 'string' && e.stderr.includes('unable to access') && needsRetry) { + // There is contention for the related git files + needsRetry.push(pick); + } else { + throw e; + } + } + }); + if (needsRetry && needsRetry.length) { + await this.deleteBranches(needsRetry, nonExistantBranches, progress, totalBranches, deletedBranches); + } + } + + async deleteLocalBranchesNRemotes() { + return new Promise(async resolve => { + const quickPick = vscode.window.createQuickPick(); + quickPick.canSelectMany = true; + quickPick.ignoreFocusOut = true; + quickPick.placeholder = vscode.l10n.t('Choose local branches you want to delete permanently'); + quickPick.show(); + quickPick.busy = true; + + // Check local branches + const results = await this.getBranchDeletionItems(); + const defaults = await this.getPullRequestDefaults(); + quickPick.items = results; + quickPick.selectedItems = results.filter(result => { + // Do not pick the default branch for the repo. + return result.picked && !((result.label === defaults.base) && (result.metadata.find(metadata => metadata.owner === defaults.owner && metadata.repositoryName === defaults.repo))); + }); + quickPick.busy = false; + if (results.length === 0) { + quickPick.canSelectMany = false; + quickPick.items = [{ label: vscode.l10n.t('No local branches to delete'), picked: false }]; + } + + let firstStep = true; + quickPick.onDidAccept(async () => { + quickPick.busy = true; + + if (firstStep) { + const picks = quickPick.selectedItems; + const nonExistantBranches = new Set(); + if (picks.length) { + await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Cleaning up') }, async (progress) => { + try { + await this.deleteBranches(picks, nonExistantBranches, progress, picks.length, 0, []); + } catch (e) { + quickPick.hide(); + vscode.window.showErrorMessage(vscode.l10n.t('Deleting branches failed: {0} {1}', e.message, e.stderr)); + } + }); + } + + firstStep = false; + const remoteItems = await this.getRemoteDeletionItems(nonExistantBranches); + + if (remoteItems && remoteItems.length) { + quickPick.canSelectMany = true; + quickPick.placeholder = vscode.l10n.t('Choose remotes you want to delete permanently'); + quickPick.items = remoteItems; + quickPick.selectedItems = remoteItems.filter(item => item.picked); + } else { + quickPick.hide(); + } + } else { + // batch deleting the remotes to avoid consuming all available resources + const picks = quickPick.selectedItems; + if (picks.length) { + await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Deleting {0} remotes...', picks.length) }, async () => { + await batchPromiseAll(picks, 5, async pick => { + await this.repository.removeRemote(pick.label); + }); + }); + } + quickPick.hide(); + } + quickPick.busy = false; + }); + + quickPick.onDidHide(() => { + resolve(); + }); + }); + } + + async revert(pullRequest: PullRequestModel, title: string, body: string, draft: boolean): Promise { + const repo = this._githubRepositories.find( + r => r.remote.owner === pullRequest.remote.owner && r.remote.repositoryName === pullRequest.remote.repositoryName, + ); + if (!repo) { + throw new Error(`No matching repository ${pullRequest.remote.repositoryName} found for ${pullRequest.remote.owner}`); + } + + const pullRequestModel: PullRequestModel | undefined = await repo.revertPullRequest(pullRequest.graphNodeId, title, body, draft); + return pullRequestModel; + } + + async getPullRequestRepositoryDefaultBranch(issue: IssueModel): Promise { + const branch = await issue.githubRepository.getDefaultBranch(); + return branch; + } + + async getPullRequestRepositoryAccessAndMergeMethods( + issue: IssueModel, + ): Promise { + const mergeOptions = await issue.githubRepository.getRepoAccessAndMergeMethods(); + return mergeOptions; + } + + async mergeQueueMethodForBranch(branch: string, owner: string, repoName: string): Promise { + return (await this.gitHubRepositories.find(repository => repository.remote.owner === owner && repository.remote.repositoryName === repoName)?.mergeQueueMethodForBranch(branch)); + } + + async fulfillPullRequestMissingInfo(pullRequest: PullRequestModel): Promise { + try { + if (!pullRequest.isResolved()) { + return; + } + + Logger.debug(`Fulfill pull request missing info - start`, this.id); + const githubRepository = pullRequest.githubRepository; + const { octokit, remote } = await githubRepository.ensure(); + + if (!pullRequest.base) { + const { data } = await octokit.call(octokit.api.pulls.get, { + owner: remote.owner, + repo: remote.repositoryName, + pull_number: pullRequest.number, + }); + pullRequest.update(convertRESTPullRequestToRawPullRequest(data, githubRepository)); + } + + if (!pullRequest.mergeBase) { + const { data } = await octokit.call(octokit.api.repos.compareCommits, { + repo: remote.repositoryName, + owner: remote.owner, + base: `${pullRequest.base.repositoryCloneUrl.owner}:${pullRequest.base.ref}`, + head: `${pullRequest.head.repositoryCloneUrl.owner}:${pullRequest.head.ref}`, + }); + + pullRequest.mergeBase = data.merge_base_commit.sha; + } + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Fetching Pull Request merge base failed: {0}', formatError(e))); + } + Logger.debug(`Fulfill pull request missing info - done`, this.id); + } + + //#region Git related APIs + + private async resolveItem(owner: string, repositoryName: string): Promise { + let githubRepo = this._githubRepositories.find(repo => { + const ret = + repo.remote.owner.toLowerCase() === owner.toLowerCase() && + repo.remote.repositoryName.toLowerCase() === repositoryName.toLowerCase(); + return ret; + }); + + if (!githubRepo) { + Logger.appendLine(`GitHubRepository not found: ${owner}/${repositoryName}`, this.id); + // try to create the repository + githubRepo = await this.createGitHubRepositoryFromOwnerName(owner, repositoryName); + } + return githubRepo; + } + + async resolveIssueOrPullRequest(owner: string, repositoryName: string, issueOrPullRequestNumber: number): Promise { + let issueOrPullRequest: IssueModel | PullRequestModel | undefined = await this.resolveIssue(owner, repositoryName, issueOrPullRequestNumber, true); + if (!issueOrPullRequest) { + issueOrPullRequest = await this.resolvePullRequest(owner, repositoryName, issueOrPullRequestNumber); + } + return issueOrPullRequest; + } + + async resolvePullRequest( + owner: string, + repositoryName: string, + pullRequestNumber: number, + useCache: boolean = false, + ): Promise { + const githubRepo = await this.resolveItem(owner, repositoryName); + Logger.trace(`Found GitHub repo for pr #${pullRequestNumber}: ${githubRepo ? 'yes' : 'no'}`, this.id); + if (githubRepo) { + const pr = await githubRepo.getPullRequest(pullRequestNumber, useCache); + Logger.trace(`Found GitHub pr repo for pr #${pullRequestNumber}: ${pr ? 'yes' : 'no'}`, this.id); + return pr; + } + return undefined; + } + + async resolveIssue( + owner: string, + 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) { + 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; + } + + async resolveUser(owner: string, repositoryName: string, login: string): Promise { + Logger.debug(`Fetch user ${login}`, this.id); + const githubRepository = await this.createGitHubRepositoryFromOwnerName(owner, repositoryName); + return githubRepository.resolveUser(login); + } + + async getMatchingPullRequestMetadataForBranch() { + if (!this.repository || !this.repository.state.HEAD || !this.repository.state.HEAD.name) { + return null; + } + + const matchingPullRequestMetadata = await PullRequestGitHelper.getMatchingPullRequestMetadataForBranch( + this.repository, + this.repository.state.HEAD.name, + ); + return matchingPullRequestMetadata; + } + + async getMatchingPullRequestMetadataFromGitHub(branch: Branch, remoteName?: string, remoteUrl?: string, upstreamBranchName?: string): Promise< + (PullRequestMetadata & { model: PullRequestModel }) | null + > { + try { + if (remoteName) { + return this.getMatchingPullRequestMetadataFromGitHubWithRemoteName(remoteName, upstreamBranchName); + } + return this.getMatchingPullRequestMetadataFromGitHubWithUrl(branch, remoteUrl, upstreamBranchName); + } catch (e) { + Logger.error(`Unable to get matching pull request metadata from GitHub: ${e}`, this.id); + return null; + } + } + + async getMatchingPullRequestMetadataFromGitHubWithUrl(branch: Branch, remoteUrl?: string, upstreamBranchName?: string): Promise< + (PullRequestMetadata & { model: PullRequestModel }) | null + > { + Logger.debug(`Searching GitHub for a PR with branch ${upstreamBranchName} and remote ${remoteUrl}`, this.id); + + if (!remoteUrl) { + return null; + } + const protocol: Protocol = new Protocol(remoteUrl); + let headGitHubRepo = this.findRepo((input) => compareIgnoreCase(input.remote.owner, protocol.owner) === 0 && compareIgnoreCase(input.remote.repositoryName, protocol.repositoryName) === 0); + if (!headGitHubRepo && this.gitHubRepositories.length > 0) { + const remote = parseRemote(protocol.repositoryName, remoteUrl, protocol); + if (remote) { + headGitHubRepo = await this.createGitHubRepository(remote, this.credentialStore, true, true); + } + } + const matchingPR = await this.doGetMatchingPullRequestMetadataFromGitHub(headGitHubRepo, upstreamBranchName); + if (matchingPR && (branch.upstream === undefined) && headGitHubRepo && branch.name) { + const newRemote = await PullRequestGitHelper.createRemote(this.repository, headGitHubRepo?.remote, protocol); + const trackedBranchName = `refs/remotes/${newRemote}/${matchingPR.model.head?.name}`; + await this.repository.fetch({ remote: newRemote, ref: matchingPR.model.head?.name }); + await this.repository.setBranchUpstream(branch.name, trackedBranchName); + } + + return matchingPR; + } + + async getMatchingPullRequestMetadataFromGitHubWithRemoteName(remoteName?: string, upstreamBranchName?: string): Promise< + (PullRequestMetadata & { model: PullRequestModel }) | null + > { + Logger.debug(`Searching GitHub for a PR with branch ${upstreamBranchName} and remote ${remoteName}`, this.id); + if (!remoteName) { + return null; + } + + let headGitHubRepo = this.gitHubRepositories.find( + repo => repo.remote.remoteName === remoteName, + ); + + if (!headGitHubRepo && this.gitHubRepositories.length > 0) { + const gitRemote = this.repository.state.remotes.find(remote => remote.name === remoteName); + const remoteUrl = gitRemote?.fetchUrl ?? gitRemote?.pushUrl; + if (!remoteUrl) { + return null; + } + const protocol = new Protocol(remoteUrl ?? ''); + const remote = parseRemote(remoteName, remoteUrl, protocol); + if (remote) { + headGitHubRepo = await this.createGitHubRepository(remote, this.credentialStore, true, true); + } + } + + return this.doGetMatchingPullRequestMetadataFromGitHub(headGitHubRepo, upstreamBranchName); + } + + private async doGetMatchingPullRequestMetadataFromGitHub(headGitHubRepo?: GitHubRepository, upstreamBranchName?: string): Promise< + (PullRequestMetadata & { model: PullRequestModel }) | null + > { + if (!headGitHubRepo || !upstreamBranchName) { + return null; + } + + const headRepoMetadata = await headGitHubRepo?.getMetadata(); + if (!headRepoMetadata?.owner) { + return null; + } + + const parentRepos = this.gitHubRepositories.filter(repo => { + if (headRepoMetadata.fork) { + return repo.remote.owner === headRepoMetadata.parent?.owner?.login && repo.remote.repositoryName === headRepoMetadata.parent.name; + } else { + return repo.remote.owner === headRepoMetadata.owner?.login && repo.remote.repositoryName === headRepoMetadata.name; + } + }); + + // Search through each github repo to see if it has a PR with this head branch. + for (const repo of parentRepos) { + const matchingPullRequest = await repo.getPullRequestForBranch(upstreamBranchName, headRepoMetadata.owner.login); + if (matchingPullRequest) { + return { + owner: repo.remote.owner, + repositoryName: repo.remote.repositoryName, + prNumber: matchingPullRequest.number, + model: matchingPullRequest, + }; + } + } + return null; + } + + async checkoutExistingPullRequestBranch(pullRequest: PullRequestModel, progress: vscode.Progress<{ message?: string; increment?: number }>): Promise { + return await PullRequestGitHelper.checkoutExistingPullRequestBranch(this.repository, pullRequest, progress); + } + + async getBranchNameForPullRequest(pullRequest: PullRequestModel) { + return await PullRequestGitHelper.getBranchNRemoteForPullRequest(this.repository, pullRequest); + } + + async fetchAndCheckout(pullRequest: PullRequestModel, progress: vscode.Progress<{ message?: string; increment?: number }>): Promise { + await PullRequestGitHelper.fetchAndCheckout(this.repository, this._allGitHubRemotes, pullRequest, progress); + } + + async checkout(branchName: string): Promise { + return this.repository.checkout(branchName); + } + + async tryMergeBaseIntoHead(pullRequest: PullRequestModel, push: boolean): Promise { + if (await this.isHeadUpToDateWithBase(pullRequest)) { + return true; + } + + const isBrowser = (vscode.env.appHost === 'vscode.dev' || vscode.env.appHost === 'github.dev'); + + if (!pullRequest.isActive || isBrowser) { + const conflictModel = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Finding conflicts...') }, () => createConflictResolutionModel(pullRequest)); + if (conflictModel === undefined) { + await vscode.window.showErrorMessage(vscode.l10n.t('Unable to resolved conflicts for this pull request. There are too many file changes.'), { modal: true, detail: isBrowser ? undefined : vscode.l10n.t('Please check out the pull request to resolve conflicts.') }); + return false; + } + let continueWithMerge = true; + if (pullRequest.item.mergeable === PullRequestMergeability.Conflict) { + const githubRepos = await Promise.all([this.createGitHubRepositoryFromOwnerName(pullRequest.head!.owner, pullRequest.head!.repositoryCloneUrl.repositoryName), this.createGitHubRepositoryFromOwnerName(pullRequest.base.owner, pullRequest.base.repositoryCloneUrl.repositoryName)]); + const coordinator = new ConflictResolutionCoordinator(this.telemetry, conflictModel, githubRepos); + continueWithMerge = await coordinator.enterConflictResolutionAndWaitForExit(); + coordinator.dispose(); + } + + if (continueWithMerge) { + return pullRequest.updateBranch(conflictModel); + } else { + return false; + } + } + + if (pullRequest.item.mergeable !== PullRequestMergeability.Conflict) { + const result = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Updating branch...') }, + async () => { + const success = await pullRequest.updateBranchWithGraphQL(); + if (success && pullRequest.isActive) { + await this.repository.pull(); + } + return success; + } + ); + return result; + } + + + if (this.repository.state.workingTreeChanges.length > 0 || this.repository.state.indexChanges.length > 0) { + await vscode.window.showErrorMessage(vscode.l10n.t('The pull request branch cannot be updated when the there changed files in the working tree or index. Stash or commit all change and then try again.'), { modal: true }); + return false; + } + const baseRemote = findLocalRepoRemoteFromGitHubRef(this.repository, pullRequest.base)?.name; + if (!baseRemote) { + return false; + } + const qualifiedUpstream = `${baseRemote}/${pullRequest.base.ref}`; + await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification }, async (progress) => { + progress.report({ message: vscode.l10n.t('Fetching branch {0}', qualifiedUpstream) }); + await this.repository.fetch({ ref: pullRequest.base.ref, remote: baseRemote }); + progress.report({ message: vscode.l10n.t('Merging branch {0} into {1}', qualifiedUpstream, this.repository.state.HEAD!.name!) }); + try { + await this.repository.merge(qualifiedUpstream); + } catch (e) { + if (e.gitErrorCode !== GitErrorCodes.Conflict) { + throw e; + } + } + }); + + if (pullRequest.item.mergeable === PullRequestMergeability.Conflict) { + const wizard = await ConflictModel.begin(this.repository, pullRequest.base.ref, this.repository.state.HEAD!.name!, push); + await wizard?.finished(); + wizard?.dispose(); + } else { + await this.repository.push(); + } + return true; + } + + async isHeadUpToDateWithBase(pullRequestModel: PullRequestModel): Promise { + if (!pullRequestModel.head) { + return false; + } + const repo = this._githubRepositories.find( + r => r.remote.owner === pullRequestModel.remote.owner && r.remote.repositoryName === pullRequestModel.remote.repositoryName, + ); + const headBranch = `${pullRequestModel.head.owner}:${pullRequestModel.head.ref}`; + const baseBranch = `${pullRequestModel.base.owner}:${pullRequestModel.base.ref}`; + const log = await repo?.compareCommits(baseBranch, headBranch); + return log?.behind_by === 0; + } + + async fetchById(githubRepo: GitHubRepository, id: number): Promise { + const pullRequest = await githubRepo.getPullRequest(id); + if (pullRequest) { + return pullRequest; + } else { + vscode.window.showErrorMessage(vscode.l10n.t('Pull request number {0} does not exist in {1}', id, `${githubRepo.remote.owner}/${githubRepo.remote.repositoryName}`), { modal: true }); + } + } + + public async checkoutDefaultBranch(branch: string, pullRequestModel: PullRequestModel | undefined): Promise { + const AND_PULL = 'AndPull'; + + const postDoneAction = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(POST_DONE, CHECKOUT_DEFAULT_BRANCH); + + // Determine which branch to checkout + let targetBranch = branch; + let remoteName: string | undefined = undefined; + if (pullRequestModel && postDoneAction.startsWith(CHECKOUT_PULL_REQUEST_BASE_BRANCH)) { + // Use the PR's base branch if the setting specifies it + targetBranch = pullRequestModel.base.ref; + remoteName = pullRequestModel.remote.remoteName; + } + + if (postDoneAction.endsWith(AND_PULL)) { + await this.checkoutDoneBranchAndPull(targetBranch, remoteName); + } else { + await this.checkoutDoneBranchOnly(targetBranch, remoteName); + } + } + + private async checkoutDoneBranchAndPull(branch: string, remoteName?: string): Promise { + await this.checkoutDoneBranchOnly(branch, remoteName); + // After checking out, pull the latest changes if the branch has an upstream + try { + const branchObj = await this.repository.getBranch(branch); + if (branchObj.upstream) { + Logger.debug(`Pulling latest changes for branch ${branch}`, this.id); + await this.repository.pull(); + } + } catch (e) { + Logger.warn(`Failed to pull latest changes for branch ${branch}: ${e}`, this.id); + // Don't throw error - checkout succeeded, pull failure is non-critical + } + } + + private async fetchBranch(branch: string, remoteName: string) { + try { + await this.repository.fetch({ remote: remoteName, ref: branch }); + await this.repository.createBranch(branch, false); + await this.repository.setBranchUpstream(branch, `refs/remotes/${remoteName}/${branch}`); + } catch (e) { + Logger.error(`Failed to fetch branch ${branch}: ${e}`, this.id); + vscode.window.showErrorMessage(vscode.l10n.t('Failed to create branch {0}: {1}', branch, formatError(e))); + return; + } + } + + private async checkoutDoneBranchOnly(branch: string, remoteName?: string): Promise { + let branchObj: Branch | undefined; + try { + branchObj = await this.repository.getBranch(branch); + } catch (e) { + if (e.message?.includes('No such branch') && remoteName) { + await this.fetchBranch(branch, remoteName); + } + } + + try { + branchObj = await this.repository.getBranch(branch); + + const currentBranch = this.repository.state.HEAD?.name; + if (currentBranch === branchObj.name) { + const chooseABranch = vscode.l10n.t('Choose a Branch'); + vscode.window.showInformationMessage(vscode.l10n.t('The default branch is already checked out.'), chooseABranch).then(choice => { + if (choice === chooseABranch) { + return git.checkout(); + } + }); + return; + } + + // respect the git setting to fetch before checkout + if (vscode.workspace.getConfiguration(GIT).get(PULL_BEFORE_CHECKOUT, false) && branchObj.upstream) { + try { + await this.repository.fetch({ remote: branchObj.upstream.remote, ref: `${branchObj.upstream.name}:${branchObj.name}` }); + } catch (e) { + if (e.stderr?.startsWith && e.stderr.startsWith('fatal: refusing to fetch into branch')) { + // This can happen when there's some state on the "main" branch + // This could be unpushed commits or a bisect for example + vscode.window.showErrorMessage(vscode.l10n.t('Unable to fetch the {0} branch. There is some state (bisect, unpushed commits, etc.) on {0} that is preventing the fetch.', [branchObj.name])); + } else { + throw e; + } + } + } + + if (branchObj.upstream && branch === branchObj.upstream.name) { + await this.repository.checkout(branch); + } else { + await git.checkout(); + } + + const fileClose: Thenable[] = []; + // Close the PR description and any open review scheme files. + for (const tabGroup of vscode.window.tabGroups.all) { + for (const tab of tabGroup.tabs) { + let uri: vscode.Uri | string | undefined; + if (tab.input instanceof vscode.TabInputText) { + uri = tab.input.uri; + } else if (tab.input instanceof vscode.TabInputTextDiff) { + uri = tab.input.original; + } else if (tab.input instanceof vscode.TabInputWebview) { + uri = tab.input.viewType; + } + if ((uri instanceof vscode.Uri && uri.scheme === Schemes.Review) || (typeof uri === 'string' && uri.endsWith(PULL_REQUEST_OVERVIEW_VIEW_TYPE))) { + fileClose.push(vscode.window.tabGroups.close(tab)); + } + } + } + await Promise.all(fileClose); + } catch (e) { + if (e.gitErrorCode) { + // for known git errors, we should provide actions for users to continue. + if (e.gitErrorCode === GitErrorCodes.DirtyWorkTree) { + vscode.window.showErrorMessage( + vscode.l10n.t('Your local changes would be overwritten by checkout, please commit your changes or stash them before you switch branches'), + ); + return; + } + } + Logger.error(`Exiting failed: ${e}. Target branch ${branch} used to find branch ${branchObj?.name ?? 'unknown'} with upstream ${branchObj?.upstream?.name ?? 'unknown'}.`, this.id); + vscode.window.showErrorMessage(`Exiting failed: ${e}`); + } + } + + private async pullBranchConfiguration(): Promise<'never' | 'prompt' | 'always'> { + const neverShowPullNotification = this.context.globalState.get(NEVER_SHOW_PULL_NOTIFICATION, false); + if (neverShowPullNotification) { + this.context.globalState.update(NEVER_SHOW_PULL_NOTIFICATION, false); + await vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(PULL_BRANCH, 'never', vscode.ConfigurationTarget.Global); + } + return vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'never' | 'prompt' | 'always'>(PULL_BRANCH, 'prompt'); + } + + private async pullBranch(branch: Branch) { + if (this._repository.state.HEAD?.name === branch.name) { + await this._repository.pull(); + } + } + + private async promptPullBrach(pr: PullRequestModel, branch: Branch, autoStashSetting?: boolean) { + if (!this._updateMessageShown || autoStashSetting) { + // When the PR is from Copilot, we only want to show the notification when Copilot is done working + const copilotStatus = await pr.copilotWorkingStatus(); + if (copilotStatus === CopilotWorkingStatus.InProgress) { + return; + } + + this._updateMessageShown = true; + const pull = vscode.l10n.t('Pull'); + const always = vscode.l10n.t('Always Pull'); + const never = vscode.l10n.t('Never Show Again'); + const options = [pull]; + if (!autoStashSetting) { + options.push(always, never); + } + const result = await vscode.window.showInformationMessage( + vscode.l10n.t('There are updates available for pull request {0}.', `${pr.number}: ${pr.title}`), + {}, + ...options + ); + + if (result === pull) { + await this.pullBranch(branch); + this._updateMessageShown = false; + } else if (never) { + await vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(PULL_BRANCH, 'never', vscode.ConfigurationTarget.Global); + } else if (always) { + await vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(PULL_BRANCH, 'always', vscode.ConfigurationTarget.Global); + await this.pullBranch(branch); + } + } + } + + private _updateMessageShown: boolean = false; + public async checkBranchUpToDate(pr: PullRequestModel & IResolvedPullRequestModel, shouldFetch: boolean): Promise { + if (this.activePullRequest?.id !== pr.id) { + return; + } + const branch = this._repository.state.HEAD; + if (branch) { + const remote = branch.upstream ? branch.upstream.remote : null; + const remoteBranch = branch.upstream ? branch.upstream.name : branch.name; + if (remote) { + try { + if (shouldFetch && vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(ALLOW_FETCH, true)) { + await this._repository.fetch(remote, remoteBranch); + } + } catch (e) { + if (e.stderr) { + if ((e.stderr as string).startsWith('fatal: couldn\'t find remote ref')) { + // We've managed to check out the PR, but the remote has been deleted. This is fine, but we can't fetch now. + } else if ((e.stderr as string).includes('key_exchange_identification')) { + // Another reason we can't fetch now. https://github.com/microsoft/vscode-pull-request-github/issues/6681 + } else { + vscode.window.showErrorMessage(vscode.l10n.t('An error occurred when fetching the repository: {0}', e.stderr)); + } + } + Logger.error(`Error when fetching: ${e.stderr ?? e}`, this.id); + } + const pullBranchConfiguration = await this.pullBranchConfiguration(); + if (branch.behind !== undefined && branch.behind > 0) { + switch (pullBranchConfiguration) { + case 'always': { + const autoStash = vscode.workspace.getConfiguration(GIT).get(AUTO_STASH, false); + if (autoStash) { + return this.promptPullBrach(pr, branch, autoStash); + } else { + return this.pullBranch(branch); + } + } + case 'prompt': { + return this.promptPullBrach(pr, branch); + } + case 'never': return; + } + } + + } + } + } + + public findExistingGitHubRepository(remote: { owner: string, repositoryName: string, remoteName?: string }): GitHubRepository | undefined { + return this._githubRepositories.find( + r => + (r.remote.owner.toLowerCase() === remote.owner.toLowerCase()) + && (r.remote.repositoryName.toLowerCase() === remote.repositoryName.toLowerCase()) + && (!remote.remoteName || (r.remote.remoteName === remote.remoteName)), + ); + } + + private async createAndAddGitHubRepository(remote: Remote, credentialStore: CredentialStore, silent?: boolean) { + const repoId = this._id + (this._githubRepositories.length * 0.1); + const repo = new GitHubRepository(repoId, GitHubRemote.remoteAsGitHub(remote, await this._githubManager.isGitHub(remote.gitProtocol.normalizeUri()!)), this.repository.rootUri, credentialStore, this.telemetry, silent); + this._githubRepositories.push(repo); + return repo; + } + + private removeGitHubRepository(remote: Remote) { + const index = this._githubRepositories.findIndex( + r => + (r.remote.owner.toLowerCase() === remote.owner.toLowerCase()) + && (r.remote.repositoryName.toLowerCase() === remote.repositoryName.toLowerCase()) + && (!remote.remoteName || (r.remote.remoteName === remote.remoteName)) + ); + if (index > -1) { + this._githubRepositories.splice(index, 1); + } + } + + private _createGitHubRepositoryBulkhead = bulkhead(1, 300); + async createGitHubRepository(remote: Remote, credentialStore: CredentialStore, silent?: boolean, ignoreRemoteName: boolean = false): Promise { + // Use a bulkhead/semaphore to ensure that we don't create multiple GitHubRepositories for the same remote at the same time. + return this._createGitHubRepositoryBulkhead.execute(async () => { + return this.findExistingGitHubRepository({ owner: remote.owner, repositoryName: remote.repositoryName, remoteName: ignoreRemoteName ? undefined : remote.remoteName }) ?? + await this.createAndAddGitHubRepository(remote, credentialStore, silent); + }); + } + + async createGitHubRepositoryFromOwnerName(owner: string, repositoryName: string): Promise { + const existing = this.findExistingGitHubRepository({ owner, repositoryName }); + if (existing) { + return existing; + } + const gitRemotes = parseRepositoryRemotes(this.repository); + const gitRemote = gitRemotes.find(r => r.owner === owner && r.repositoryName === repositoryName); + const uri = gitRemote?.url ?? `https://github.com/${owner}/${repositoryName}`; + return this.createAndAddGitHubRepository(new Remote(gitRemote?.remoteName ?? repositoryName, uri, new Protocol(uri)), this._credentialStore); + } + + async findUpstreamForItem(item: { + remote: Remote; + githubRepository: GitHubRepository; + }): Promise<{ needsFork: boolean; upstream?: GitHubRepository; remote?: Remote }> { + let upstream: GitHubRepository | undefined; + let existingForkRemote: Remote | undefined; + for (const githubRepo of this.gitHubRepositories) { + if ( + !upstream && + githubRepo.remote.owner === item.remote.owner && + githubRepo.remote.repositoryName === item.remote.repositoryName + ) { + upstream = githubRepo; + continue; + } + const forkDetails = await githubRepo.getRepositoryForkDetails(); + if ( + forkDetails && + forkDetails.isFork && + forkDetails.parent.owner.login === item.remote.owner && + forkDetails.parent.name === item.remote.repositoryName + ) { + const foundforkPermission = await githubRepo.getViewerPermission(); + if ( + foundforkPermission === ViewerPermission.Admin || + foundforkPermission === ViewerPermission.Maintain || + foundforkPermission === ViewerPermission.Write + ) { + existingForkRemote = githubRepo.remote; + break; + } + } + } + let needsFork = false; + if (upstream && !existingForkRemote) { + const permission = await item.githubRepository.getViewerPermission(); + if ( + permission === ViewerPermission.Read || + permission === ViewerPermission.Triage || + permission === ViewerPermission.Unknown + ) { + needsFork = true; + } + } + return { needsFork, upstream, remote: existingForkRemote }; + } + + async forkWithProgress( + progress: vscode.Progress<{ message?: string; increment?: number }>, + githubRepository: GitHubRepository, + repoString: string, + matchingRepo: Repository, + ): Promise { + progress.report({ message: vscode.l10n.t('Forking {0}...', repoString) }); + const result = await githubRepository.fork(); + progress.report({ increment: 50 }); + if (!result) { + vscode.window.showErrorMessage( + vscode.l10n.t('Unable to create a fork of {0}. Check that your GitHub credentials are correct.', repoString), + ); + return; + } + + const workingRemoteName: string = + matchingRepo.state.remotes.length > 1 ? 'origin' : matchingRepo.state.remotes[0].name; + progress.report({ message: vscode.l10n.t('Adding remotes. This may take a few moments.') }); + const startingRepoCount = this.gitHubRepositories.length; + await matchingRepo.renameRemote(workingRemoteName, 'upstream'); + await matchingRepo.addRemote(workingRemoteName, result); + // Now the extension is responding to all the git changes. + await new Promise(resolve => { + if ((this.gitHubRepositories.length === startingRepoCount) && vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(REMOTES)?.includes('upstream')) { + const disposable = this.onDidChangeRepositories(() => { + if (this.gitHubRepositories.length > startingRepoCount) { + disposable.dispose(); + resolve(); + } + }); + } else { + resolve(); + } + }); + progress.report({ increment: 50 }); + return workingRemoteName; + } + + async doFork( + githubRepository: GitHubRepository, + repoString: string, + matchingRepo: Repository, + ): Promise { + return vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Creating Fork') }, + async progress => { + try { + return this.forkWithProgress(progress, githubRepository, repoString, matchingRepo); + } catch (e) { + vscode.window.showErrorMessage(`Creating fork failed: ${e}`); + } + return undefined; + }, + ); + } + + async tryOfferToFork(githubRepository: GitHubRepository): Promise { + const repoString = `${githubRepository.remote.owner}/${githubRepository.remote.repositoryName}`; + + const fork = vscode.l10n.t('Fork'); + const dontFork = vscode.l10n.t('Don\'t Fork'); + const response = await vscode.window.showInformationMessage( + vscode.l10n.t('You don\'t have permission to push to {0}. Do you want to fork {0}? This will modify your git remotes to set \`origin\` to the fork, and \`upstream\` to {0}.', repoString), + { modal: true }, + fork, + dontFork, + ); + switch (response) { + case fork: { + return this.doFork(githubRepository, repoString, this.repository); + } + case dontFork: + return false; + default: + return undefined; + } + } + + public async publishBranch(pushRemote: Remote, branchName: string): Promise { + const githubRepo = await this.createGitHubRepository( + pushRemote, + this.credentialStore, + ); + const permission = await githubRepo.getViewerPermission(); + let selectedRemote: GitHubRemote | undefined; + if ( + permission === ViewerPermission.Read || + permission === ViewerPermission.Triage || + permission === ViewerPermission.Unknown + ) { + // No permission to publish the branch to the chosen remote. Offer to fork. + const fork = await this.tryOfferToFork(githubRepo); + if (!fork) { + return; + } + + selectedRemote = (await this.getGitHubRemotes()).find(element => element.remoteName === fork); + } else { + selectedRemote = (await this.getGitHubRemotes()).find(element => element.remoteName === pushRemote.remoteName); + } + + if (!selectedRemote) { + return; + } + + try { + await this._repository.push(selectedRemote.remoteName, branchName, true); + await this._repository.status(); + return selectedRemote; + } catch (err) { + if (err.gitErrorCode === GitErrorCodes.PushRejected) { + vscode.window.showWarningMessage( + vscode.l10n.t(`Can't push refs to remote, try running 'git pull' first to integrate with your change`), + { + modal: true, + }, + ); + + return undefined; + } + + if (err.gitErrorCode === GitErrorCodes.RemoteConnectionError) { + vscode.window.showWarningMessage( + vscode.l10n.t(`Could not read from remote repository '{0}'. Please make sure you have the correct access rights and the repository exists.`, selectedRemote.remoteName), + { + modal: true, + }, + ); + + return undefined; + } + + // we can't handle the error + throw err; + } + } + + public saveLastUsedEmail(email: string | undefined) { + return this.context.globalState.update(LAST_USED_EMAIL, email); + } + + public async getPreferredEmail(pullRequest: PullRequestModel): Promise { + const isEmu = await this.credentialStore.getIsEmu(pullRequest.remote.authProviderId); + if (isEmu) { + return undefined; + } + + const gitHubEmails = await pullRequest.githubRepository.getAuthenticatedUserEmails(); + const getMatch = (match: string | undefined) => match && gitHubEmails.find(email => email.toLowerCase() === match.toLowerCase()); + + const gitEmail = await PullRequestGitHelper.getEmail(this.repository); + let match = getMatch(gitEmail); + if (match) { + return match; + } + + const lastUsedEmail = this.context.globalState.get(LAST_USED_EMAIL); + match = getMatch(lastUsedEmail); + if (match) { + return match; + } + + return gitHubEmails[0]; + } + + public getTitleAndDescriptionProvider(searchTerm?: string) { + if (vscode.workspace.getConfiguration(CHAT_SETTINGS_NAMESPACE).get(DISABLE_AI_FEATURES, false)) { + return undefined; + } + return this._git.getTitleAndDescriptionProvider(searchTerm); + } + + public getAutoReviewer() { + if (vscode.workspace.getConfiguration(CHAT_SETTINGS_NAMESPACE).get(DISABLE_AI_FEATURES, false)) { + return undefined; + } + return this._git.getReviewerCommentsProvider(); + } + + override dispose() { + this._onDidDispose.fire(); + super.dispose(); + } +} + +export function getEventType(text: string) { + switch (text) { + case 'committed': + return EventType.Committed; + case 'mentioned': + return EventType.Mentioned; + case 'subscribed': + return EventType.Subscribed; + case 'commented': + return EventType.Commented; + case 'reviewed': + return EventType.Reviewed; + default: + return EventType.Other; + } +} + +const ownedByMe: AsyncPredicate = async repo => { + return repo.isCurrentUser(repo.remote.authProviderId, repo.remote.owner); +}; + +export const byRemoteName = (name: string): Predicate => ({ remote: { remoteName } }) => + remoteName === name; + +/** + * Unwraps lines that were wrapped for conventional commit message formatting (typically at 72 characters). + * Similar to GitHub's behavior when converting commit messages to PR descriptions. + * + * Rules: + * - Preserves blank lines as paragraph breaks + * - Preserves fenced code blocks (```) + * - Preserves list items (-, *, +, numbered) + * - Preserves blockquotes (>) + * - Preserves indented code blocks (4+ spaces at start, when not in a list context) + * - Joins consecutive plain text lines that appear to be wrapped mid-sentence + */ +function unwrapCommitMessageBody(body: string): string { + if (!body) { + return body; + } + + // Pattern to detect list item markers at the start of a line + const LIST_ITEM_PATTERN = /^[ \t]*([*+\-]|\d+\.)\s/; + // Pattern to detect blockquote markers + const BLOCKQUOTE_PATTERN = /^[ \t]*>/; + // Pattern to detect fenced code block markers + const FENCE_PATTERN = /^[ \t]*```/; + + const getLeadingWhitespaceLength = (text: string): number => text.match(/^[ \t]*/)?.[0].length ?? 0; + const hasHardLineBreak = (text: string): boolean => / {2}$/.test(text); + const appendWithSpace = (base: string, addition: string): string => { + if (!addition) { + return base; + } + return base.length > 0 && !/\s$/.test(base) ? `${base} ${addition}` : `${base}${addition}`; + }; + + const lines = body.split('\n'); + const result: string[] = []; + let i = 0; + let inFencedBlock = false; + const listIndentStack: number[] = []; + + const getNextNonBlankLineInfo = ( + startIndex: number, + ): { line: string; indent: number; isListItem: boolean } | undefined => { + for (let idx = startIndex; idx < lines.length; idx++) { + const candidate = lines[idx]; + if (candidate.trim() === '') { + continue; + } + return { + line: candidate, + indent: getLeadingWhitespaceLength(candidate), + isListItem: LIST_ITEM_PATTERN.test(candidate), + }; + } + return undefined; + }; + + const getActiveListIndent = (lineIndent: number): number | undefined => { + for (let idx = listIndentStack.length - 1; idx >= 0; idx--) { + const indentForLevel = listIndentStack[idx]; + if (lineIndent >= indentForLevel + 2) { + listIndentStack.length = idx + 1; + return indentForLevel; + } + listIndentStack.pop(); + } + return undefined; + }; + + const shouldJoinListContinuation = (lineIndex: number, activeIndent: number, baseLine: string): boolean => { + const currentLine = lines[lineIndex]; + if (!currentLine) { + return false; + } + + const trimmed = currentLine.trim(); + if (!trimmed) { + return false; + } + + if (hasHardLineBreak(baseLine) || hasHardLineBreak(currentLine)) { + return false; + } + + if (LIST_ITEM_PATTERN.test(currentLine)) { + return false; + } + + if (BLOCKQUOTE_PATTERN.test(currentLine) || FENCE_PATTERN.test(currentLine)) { + return false; + } + + const currentIndent = getLeadingWhitespaceLength(currentLine); + if (currentIndent < activeIndent + 2) { + return false; + } + + // Treat indented code blocks (4+ spaces beyond the bullet) as preserve-only. + if (currentIndent >= activeIndent + 4) { + return false; + } + + const nextInfo = getNextNonBlankLineInfo(lineIndex + 1); + if (!nextInfo) { + return true; + } + + if (nextInfo.isListItem && nextInfo.indent <= activeIndent) { + return false; + } + + return true; + }; + + while (i < lines.length) { + const line = lines[i]; + + // Preserve blank lines + if (line.trim() === '') { + result.push(line); + i++; + listIndentStack.length = 0; + continue; + } + + // Check for fenced code block markers + if (FENCE_PATTERN.test(line)) { + inFencedBlock = !inFencedBlock; + result.push(line); + i++; + continue; + } + + // Preserve everything inside fenced code blocks + if (inFencedBlock) { + result.push(line); + i++; + continue; + } + + const lineIndent = getLeadingWhitespaceLength(line); + const isListItem = LIST_ITEM_PATTERN.test(line); + + if (isListItem) { + while (listIndentStack.length && lineIndent < listIndentStack[listIndentStack.length - 1]) { + listIndentStack.pop(); + } + + if (!listIndentStack.length || lineIndent > listIndentStack[listIndentStack.length - 1]) { + listIndentStack.push(lineIndent); + } else { + listIndentStack[listIndentStack.length - 1] = lineIndent; + } + + result.push(line); + i++; + continue; + } + + const activeListIndent = getActiveListIndent(lineIndent); + const codeIndentThreshold = activeListIndent !== undefined ? activeListIndent + 4 : 4; + const isBlockquote = BLOCKQUOTE_PATTERN.test(line); + const isIndentedCode = lineIndent >= codeIndentThreshold; + + if (isBlockquote || isIndentedCode) { + result.push(line); + i++; + continue; + } + + if (activeListIndent !== undefined && lineIndent >= activeListIndent + 2) { + const baseIndex = result.length - 1; + if (baseIndex >= 0) { + let baseLine = result[baseIndex]; + let appended = false; + let currentIndex = i; + + while ( + currentIndex < lines.length && + shouldJoinListContinuation(currentIndex, activeListIndent, baseLine) + ) { + const continuationText = lines[currentIndex].trim(); + if (continuationText) { + baseLine = appendWithSpace(baseLine, continuationText); + appended = true; + } + currentIndex++; + } + + if (appended) { + result[baseIndex] = baseLine; + i = currentIndex; + continue; + } + } + + result.push(line); + i++; + continue; + } + + // Start accumulating lines that should be joined (plain text) + let joinedLine = line; + i++; + + // Keep joining lines until we hit a blank line or a line that shouldn't be joined + while (i < lines.length) { + const nextLine = lines[i]; + + // Stop at blank lines + if (nextLine.trim() === '') { + break; + } + + // Stop at fenced code blocks + if (FENCE_PATTERN.test(nextLine)) { + break; + } + + // Stop at list items + if (LIST_ITEM_PATTERN.test(nextLine)) { + break; + } + + // Stop at blockquotes + if (BLOCKQUOTE_PATTERN.test(nextLine)) { + break; + } + + // Check if next line is indented code (4+ spaces, when not in a list context) + const nextLeadingSpaces = getLeadingWhitespaceLength(nextLine); + const nextIsIndentedCode = nextLeadingSpaces >= 4; + + if (nextIsIndentedCode) { + break; + } + + // Join this line with a space + joinedLine += ' ' + nextLine; + i++; + } + + result.push(joinedLine); + } + + return result.join('\n'); +} + +export const titleAndBodyFrom = async (promise: Promise): Promise<{ title: string; body: string } | undefined> => { + const message = await promise; + if (!message) { + return; + } + const idxLineBreak = message.indexOf('\n'); + const hasBody = idxLineBreak !== -1; + const rawBody = hasBody ? message.slice(idxLineBreak + 1).trim() : ''; + return { + title: hasBody ? message.slice(0, idxLineBreak) : message, + + body: unwrapCommitMessageBody(rawBody), + }; +}; diff --git a/src/github/githubRepository.ts b/src/github/githubRepository.ts index 3572d715ca..39cc0d2d2f 100644 --- a/src/github/githubRepository.ts +++ b/src/github/githubRepository.ts @@ -3,69 +3,114 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ApolloQueryResult, FetchResult, MutationOptions, NetworkStatus, QueryOptions } from 'apollo-boost'; +import * as buffer from 'buffer'; +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 Logger from '../common/logger'; -import { Protocol } from '../common/protocol'; -import { GitHubRemote, parseRemote, Remote } from '../common/remote'; -import { ITelemetry } from '../common/telemetry'; -import { PRCommentControllerRegistry } from '../view/pullRequestCommentControllerRegistry'; -import { OctokitCommon } from './common'; +import { mergeQuerySchemaWithShared, OctokitCommon } from './common'; import { CredentialStore, GitHub } from './credentials'; import { AssignableUsersResponse, CreatePullRequestResponse, FileContentResponse, ForkDetailsResponse, + GetBranchResponse, GetChecksResponse, isCheckRun, - IssuesResponse, + IssueResponse, IssuesSearchResponse, ListBranchesResponse, MaxIssueResponse, MentionableUsersResponse, + MergeQueueForBranchResponse, MilestoneIssuesResponse, + OrganizationTeamsCountResponse, + OrganizationTeamsResponse, + OrgProjectsResponse, + PullRequestNumberData, + PullRequestNumbersResponse, PullRequestParticipantsResponse, PullRequestResponse, PullRequestsResponse, + PullRequestTemplatesResponse, + RepoProjectsResponse, + RevertPullRequestResponse, + SuggestedActorsResponse, + UserResponse, ViewerPermissionResponse, } from './graphql'; -import { CheckState, IAccount, IMilestone, Issue, PullRequest, PullRequestChecks, RepoAccessAndMergeMethods } from './interface'; -import { IssueModel } from './issueModel'; +import { + CheckState, + IAccount, + IMilestone, + IProject, + Issue, + ITeam, + MergeMethod, + PullRequest, + PullRequestChecks, + PullRequestCheckStatus, + PullRequestReviewRequirement, + RepoAccessAndMergeMethods, + User, +} from './interface'; +import { IssueChangeEvent, IssueModel } from './issueModel'; import { LoggingOctokit } from './loggingOctokit'; import { PullRequestModel } from './pullRequestModel'; import defaultSchema from './queries.gql'; +import * as extraSchema from './queriesExtra.gql'; +import * as limitedSchema from './queriesLimited.gql'; +import * as sharedSchema from './queriesShared.gql'; import { convertRESTPullRequestToRawPullRequest, getAvatarWithEnterpriseFallback, getOverrideBranch, - getPRFetchQuery, + isInCodespaces, + parseAccount, parseGraphQLIssue, parseGraphQLPullRequest, + parseGraphQLUser, parseGraphQLViewerPermission, + parseMergeMethod, 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; const GRAPHQL_COMPONENT_ID = 'GraphQL'; -export interface ItemsData { - items: any[]; +export interface ItemsData { + items: T[]; hasMorePages: boolean; + totalCount?: number; } -export interface IssueData extends ItemsData { - items: IssueModel[]; +export interface IssueData extends ItemsData { + items: Issue[]; hasMorePages: boolean; } -export interface PullRequestData extends IssueData { +export interface PullRequestData extends ItemsData { items: PullRequestModel[]; } -export interface MilestoneData extends ItemsData { +export interface MilestoneData extends ItemsData<{ milestone: IMilestone; issues: IssueModel[] }> { items: { milestone: IMilestone; issues: IssueModel[] }[]; hasMorePages: boolean; } @@ -79,6 +124,12 @@ export enum ViewerPermission { Write = 'WRITE', } +export enum TeamReviewerRefreshKind { + None, + Try, + Force +} + export interface ForkDetails { isFork: boolean; parent: { @@ -89,29 +140,69 @@ export interface ForkDetails { }; } -export interface IMetadata extends OctokitCommon.ReposGetResponseData { - currentUser: any; +export type IMetadata = OctokitCommon.ReposGetResponseData; + +export enum GraphQLErrorType { + Unprocessable = 'UNPROCESSABLE', +} + +export interface GraphQLError { + extensions?: { + code: string; + }; + type?: GraphQLErrorType; + message?: string; +} + +export enum CopilotWorkingStatus { + NotCopilotIssue = 'NotCopilotIssue', + InProgress = 'InProgress', + Error = 'Error', + Done = 'Done', } -export class GitHubRepository implements vscode.Disposable { +export interface PullRequestChangeEvent { + model: IssueModel; + event: IssueChangeEvent; +} + +export class GitHubRepository extends Disposable { static ID = 'GitHubRepository'; protected _initialized: boolean = false; protected _hub: GitHub | undefined; - protected _metadata: IMetadata | undefined; - private _toDispose: vscode.Disposable[] = []; + protected _metadata: Promise | undefined; public commentsController?: vscode.CommentController; public commentsHandler?: PRCommentControllerRegistry; - private _pullRequestModels = new Map(); + private _pullRequestModelsByNumber: LRUCache = new LRUCache({ + maxAge: 1000 * 60 * 60 * 4 /* 4 hours */, stale: true, updateAgeOnGet: true, + dispose: (_key, value) => { + disposeAll(value.disposables); + 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 = new vscode.EventEmitter(); + private _onDidAddPullRequest: vscode.EventEmitter = this._register(new vscode.EventEmitter()); public readonly onDidAddPullRequest: vscode.Event = this._onDidAddPullRequest.event; + private _onDidChangePullRequests: vscode.EventEmitter = this._register(new vscode.EventEmitter()); + public readonly onDidChangePullRequests: vscode.Event = this._onDidChangePullRequests.event; public get hub(): GitHub { if (!this._hub) { if (!this._initialized) { throw new Error('Call ensure() before accessing this property.'); } else { - throw new AuthenticationError('Not authenticated.'); + throw new AuthenticationError(); } } return this._hub; @@ -121,32 +212,42 @@ export class GitHubRepository implements vscode.Disposable { return this.remote.equals(repo.remote); } - get pullRequestModels(): Map { - return this._pullRequestModels; + getExistingPullRequestModel(prNumber: number): PullRequestModel | undefined { + 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( - `github-browse-${this.remote.normalizedHost}`, - `GitHub Pull Request for ${this.remote.normalizedHost}`, + `${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._toDispose.push(this.commentsHandler); - this._toDispose.push(this.commentsController); + this.commentsHandler = new PRCommentControllerRegistry(this.commentsController, this.telemetry); + this._register(this.commentsHandler); + this._register(this.commentsController); } catch (e) { console.log(e); } } - dispose() { - this._toDispose.forEach(d => d.dispose()); - this._toDispose = []; + override dispose() { + super.dispose(); this.commentsController = undefined; this.commentsHandler = undefined; } @@ -155,20 +256,30 @@ export class GitHubRepository implements vscode.Disposable { return this.hub && this.hub.octokit; } + private get id(): string { + return `${GitHubRepository.ID}+${this._id}`; + } + constructor( + private readonly _id: number, public remote: GitHubRemote, public readonly rootUri: vscode.Uri, private readonly _credentialStore: CredentialStore, - private readonly _telemetry: ITelemetry, + public readonly telemetry: ITelemetry, + silent: boolean = false ) { + super(); + 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 - this.ensureCommentsController(); + if (!silent) { + this.ensureCommentsController(); + } } get authMatchesServer(): boolean { if ((this.remote.githubServerType === GitHubServerType.GitHubDotCom) && this._credentialStore.isAuthenticated(AuthProvider.github)) { return true; - } else if ((this.remote.githubServerType === GitHubServerType.Enterprise) && this._credentialStore.isAuthenticated(AuthProvider['github-enterprise'])) { + } else if ((this.remote.githubServerType === GitHubServerType.Enterprise) && this._credentialStore.isAuthenticated(AuthProvider.githubEnterprise)) { return true; } else { // Not good. We have a mismatch between auth type and server type. @@ -176,73 +287,133 @@ export class GitHubRepository implements vscode.Disposable { } } - query = async (query: QueryOptions, ignoreSamlErrors: boolean = false): Promise> => { + private async codespacesTokenError(action: QueryOptions | MutationOptions) { + if (isInCodespaces() && (await this._metadata)?.fork) { + // :( https://github.com/microsoft/vscode-pull-request-github/issues/5325#issuecomment-1798243852 + /* __GDPR__ + "pr.codespacesTokenError" : { + "action": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" } + } + */ + this.telemetry.sendTelemetryErrorEvent('pr.codespacesTokenError', { + action: action.context + }); + + throw new Error(vscode.l10n.t('This action cannot be completed in a GitHub Codespace on a fork.')); + } + } + + query = async (query: QueryOptions, ignoreSamlErrors: boolean = false, legacyFallback?: { query: DocumentNode, variables?: OperationVariables }): Promise> => { const gql = this.authMatchesServer && this.hub && this.hub.graphql; if (!gql) { - Logger.debug(`Not available for query: ${query}`, GRAPHQL_COMPONENT_ID); - return { - data: null, + const logValue = (query.query.definitions[0] as { name: { value: string } | undefined }).name?.value; + Logger.debug(`Not available for query: ${logValue ?? 'unknown'}`, GRAPHQL_COMPONENT_ID); + const empty: ApolloQueryResult = { + data: null as T, loading: false, networkStatus: NetworkStatus.error, stale: false, - } as any; + } satisfies ApolloQueryResult; + return empty; } - Logger.trace(`Request: ${JSON.stringify(query, null, 2)}`, GRAPHQL_COMPONENT_ID); let rsp; try { rsp = await gql.query(query); } catch (e) { - // Some queries just result in SAML errors, and some queries we may not want to retry because it will be too disruptive. - if (!ignoreSamlErrors && e.message?.startsWith('GraphQL error: Resource protected by organization SAML enforcement.')) { - await this._credentialStore.recreate(); + const logInfo = (query.query.definitions[0] as { name: { value: string } | undefined }).name?.value; + const gqlErrors = e.graphQLErrors ? e.graphQLErrors as GraphQLError[] : undefined; + 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); + } + + if (gqlErrors && gqlErrors.length && (gqlErrors.some(error => error.extensions?.code === 'undefinedField')) && !this._areQueriesLimited) { + // 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, 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)) { + // Some queries just result in SAML errors. + } else if ((e.message as string | undefined)?.includes('401 Unauthorized')) { + await this._credentialStore.recreate(vscode.l10n.t('Your authentication session has lost authorization. You need to sign in again to regain authorization.')); rsp = await gql.query(query); } else { + if (e.graphQLErrors && e.graphQLErrors.length && e.graphQLErrors[0].message === 'Resource not accessible by integration') { + await this.codespacesTokenError(query); + } throw e; } } - Logger.trace(`Response: ${JSON.stringify(rsp, null, 2)}`, GRAPHQL_COMPONENT_ID); return rsp; }; - mutate = async (mutation: MutationOptions): Promise> => { + mutate = async (mutation: MutationOptions, legacyFallback?: { mutation: DocumentNode, deleteProps: string[] }): Promise> => { const gql = this.authMatchesServer && this.hub && this.hub.graphql; if (!gql) { - Logger.debug(`Not available for query: ${mutation}`, GRAPHQL_COMPONENT_ID); - return { - data: null, - loading: false, - networkStatus: NetworkStatus.error, - stale: false, - } as any; + Logger.debug(`Not available for query: ${mutation.context as string}`, GRAPHQL_COMPONENT_ID); + const empty: FetchResult = { + data: null + }; + return empty; } - Logger.trace(`Request: ${JSON.stringify(mutation, null, 2)}`, GRAPHQL_COMPONENT_ID); - const rsp = await gql.mutate(mutation); - Logger.trace(`Response: ${JSON.stringify(rsp, null, 2)}`, GRAPHQL_COMPONENT_ID); + let rsp: FetchResult; + try { + rsp = await gql.mutate(mutation); + } catch (e) { + if (legacyFallback) { + mutation.mutation = legacyFallback.mutation; + if (mutation.variables?.input) { + for (const prop of legacyFallback.deleteProps) { + delete mutation.variables.input[prop]; + } + } + return this.mutate(mutation); + } else if (e.graphQLErrors && e.graphQLErrors.length && e.graphQLErrors[0].message === 'Resource not accessible by integration') { + await this.codespacesTokenError(mutation); + } + throw e; + } return rsp; }; get schema() { - return defaultSchema as any; + return this._queriesSchema; } - async getMetadata(): Promise { - Logger.debug(`Fetch metadata - enter`, GitHubRepository.ID); - if (this._metadata) { - Logger.debug( - `Fetch metadata ${this._metadata.owner?.login}/${this._metadata.name} - done`, - GitHubRepository.ID, - ); + private async getMetadataForRepo(owner: string, repo: string): Promise { + if (this._metadata && this.remote.owner === owner && this.remote.repositoryName === repo) { + Logger.debug(`Using cached metadata for repo ${owner}/${repo}`, this.id); return this._metadata; } - const { octokit, remote } = await this.ensure(); + + Logger.debug(`Fetch metadata for repo - enter`, this.id); + const { octokit } = await this.ensure(); const result = await octokit.call(octokit.api.repos.get, { - owner: remote.owner, - repo: remote.repositoryName, + owner, + repo }); - Logger.debug(`Fetch metadata ${remote.owner}/${remote.repositoryName} - done`, GitHubRepository.ID); - this._metadata = ({ ...result.data, currentUser: (octokit as any).currentUser } as unknown) as IMetadata; + Logger.debug(`Fetch metadata for repo ${owner}/${repo} - done`, this.id); + const metadata = { ...result.data, currentUser: await this._hub?.currentUser }; + return metadata; + } + + async getMetadata(): Promise { + if (this._metadata) { + const metadata = await this._metadata; + Logger.debug(`Using cached metadata ${metadata.owner?.login}/${metadata.name}`, this.id); + return metadata; + } + + Logger.debug(`Fetch metadata - enter`, this.id); + const { remote } = await this.ensure(); + this._metadata = this.getMetadataForRepo(remote.owner, remote.repositoryName); + Logger.debug(`Fetch metadata ${remote.owner}/${remote.repositoryName} - done`, this.id); return this._metadata; } @@ -263,49 +434,86 @@ export class GitHubRepository implements vscode.Disposable { return true; } - async ensure(): Promise { + async ensure(additionalScopes: boolean = false): Promise { this._initialized = true; - + const oldHub = this._hub; if (!this._credentialStore.isAuthenticated(this.remote.authProviderId)) { // We need auth now. (ex., a PR is already checked out) // We can no longer wait until later for login to be done - await this._credentialStore.create(); + await this._credentialStore.create(undefined, additionalScopes); if (!this._credentialStore.isAuthenticated(this.remote.authProviderId)) { this._hub = await this._credentialStore.showSignInNotification(this.remote.authProviderId); } } else { - this._hub = this._credentialStore.getHub(this.remote.authProviderId); + if (additionalScopes) { + this._hub = await this._credentialStore.getHubEnsureAdditionalScopes(this.remote.authProviderId); + } else { + this._hub = this._credentialStore.getHub(this.remote.authProviderId); + } } + if (oldHub !== this._hub) { + if (this._areQueriesLimited || this._credentialStore.areScopesOld(this.remote.authProviderId) || (this.remote.authProviderId === AuthProvider.githubEnterprise)) { + this._areQueriesLimited = true; + this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default, limitedSchema.default); + } else { + if (this._credentialStore.areScopesExtra(this.remote.authProviderId)) { + this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default, extraSchema.default); + } else { + this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default, defaultSchema); + } + } + } return this; } + async ensureAdditionalScopes(): Promise { + return this.ensure(true); + } + async getDefaultBranch(): Promise { const overrideSetting = getOverrideBranch(); if (overrideSetting) { return overrideSetting; } try { - Logger.debug(`Fetch default branch - enter`, GitHubRepository.ID); const data = await this.getMetadata(); - Logger.debug(`Fetch default branch - done`, GitHubRepository.ID); - return data.default_branch; } catch (e) { - Logger.warn(`Fetching default branch failed: ${e}`, GitHubRepository.ID); + Logger.warn(`Fetching default branch failed: ${e}`, this.id); } return 'master'; } + async getPullRequestTemplates(): Promise { + try { + Logger.debug('Fetch pull request templates - enter', this.id); + const { query, remote, schema } = await this.ensure(); + + const result = await query({ + query: schema.PullRequestTemplates, + variables: { + owner: remote.owner, + name: remote.repositoryName, + } + }); + + Logger.debug('Fetch pull request templates - done', this.id); + return result.data.repository.pullRequestTemplates.map(template => template.body); + } catch (e) { + // The template was not found. + } + } + private _repoAccessAndMergeMethods: RepoAccessAndMergeMethods | undefined; async getRepoAccessAndMergeMethods(refetch: boolean = false): Promise { try { if (!this._repoAccessAndMergeMethods || refetch) { - Logger.debug(`Fetch repo permissions and available merge methods - enter`, GitHubRepository.ID); + Logger.debug(`Fetch repo permissions and available merge methods - enter`, this.id); const data = await this.getMetadata(); - Logger.debug(`Fetch repo permissions and available merge methods - done`, GitHubRepository.ID); + Logger.debug(`Fetch repo permissions and available merge methods - done`, this.id); const hasWritePermission = data.permissions?.push ?? false; this._repoAccessAndMergeMethods = { // Users with push access to repo have rights to merge/close PRs, @@ -316,7 +524,7 @@ export class GitHubRepository implements vscode.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; @@ -335,10 +543,81 @@ export class GitHubRepository implements vscode.Disposable { }; } - async getAllPullRequests(page?: number): Promise { + private _branchHasMergeQueue: Map = new Map(); + async mergeQueueMethodForBranch(branch: string): Promise { + if (this._branchHasMergeQueue.has(branch)) { + return this._branchHasMergeQueue.get(branch)!; + } + try { + Logger.debug('Fetch branch has merge queue - enter', this.id); + const { query, remote, schema } = await this.ensure(); + if (!schema.MergeQueueForBranch) { + return undefined; + } + const result = await query({ + query: schema.MergeQueueForBranch, + variables: { + owner: remote.owner, + name: remote.repositoryName, + branch + } + }); + + Logger.debug('Fetch branch has merge queue - done', this.id); + const mergeMethod = parseMergeMethod(result.data.repository.mergeQueue?.configuration?.mergeMethod); + if (mergeMethod) { + this._branchHasMergeQueue.set(branch, mergeMethod); + } + return mergeMethod; + } catch (e) { + Logger.error(`Fetching branch has merge queue failed: ${e}`, this.id); + } + } + + async commit(branch: string, message: string, files: Map): Promise { + Logger.debug(`Committing files to branch ${branch} - enter`, this.id); + let success = false; + try { + const { octokit, remote } = await this.ensure(); + const lastCommitSha = (await octokit.call(octokit.api.repos.getBranch, { owner: remote.owner, repo: remote.repositoryName, branch })).data.commit.sha; + const lastTreeSha = (await octokit.call(octokit.api.repos.getCommit, { owner: remote.owner, repo: remote.repositoryName, ref: lastCommitSha })).data.commit.tree.sha; + const treeItems: { path: string, mode: '100644', content: string }[] = []; + for (const [path, content] of files) { + treeItems.push({ path: path.substring(1), mode: '100644', content: content.toString() }); + } + const newTreeSha = (await octokit.call(octokit.api.git.createTree, { owner: remote.owner, repo: remote.repositoryName, base_tree: lastTreeSha, tree: treeItems })).data.sha; + const newCommitSha = (await octokit.call(octokit.api.git.createCommit, { owner: remote.owner, repo: remote.repositoryName, message, tree: newTreeSha, parents: [lastCommitSha] })).data.sha; + await octokit.call(octokit.api.git.updateRef, { owner: remote.owner, repo: remote.repositoryName, ref: `heads/${branch}`, sha: newCommitSha }); + success = true; + } catch (e) { + // not sure what kinds of errors to expect here + Logger.error(`Committing files to branch ${branch} failed: ${e}`, this.id); + } + Logger.debug(`Committing files to branch ${branch} - done`, this.id); + + return success; + } + + async getCommitParent(ref: string): Promise { + Logger.debug(`Fetch commit for ref ${ref} - enter`, this.id); try { - Logger.debug(`Fetch all pull requests - enter`, GitHubRepository.ID); const { octokit, remote } = await this.ensure(); + const commit = (await octokit.call(octokit.api.repos.getCommit, { owner: remote.owner, repo: remote.repositoryName, ref })).data; + return commit.parents[0].sha; + } catch (e) { + Logger.error(`Fetching commit for ref ${ref} failed: ${e}`, this.id); + } + Logger.debug(`Fetch commit for ref ${ref} - done`, this.id); + } + + + async getAllPullRequests(page?: number): Promise { + let remote: GitHubRemote | undefined; + try { + Logger.debug(`Fetch all pull requests - enter`, this.id); + const ensured = await this.ensure(); + remote = ensured.remote; + const octokit = ensured.octokit; const result = await octokit.call(octokit.api.pulls.list, { owner: remote.owner, repo: remote.repositoryName, @@ -356,13 +635,14 @@ export class GitHubRepository implements vscode.Disposable { return { items: [], hasMorePages: false, + totalCount: 0 }; } const pullRequests = result.data .map(pullRequest => { if (!pullRequest.head.repo) { - Logger.appendLine('The remote branch for this PR was already deleted.', GitHubRepository.ID); + Logger.appendLine('The remote branch for this PR was already deleted.', this.id); return null; } @@ -372,17 +652,17 @@ export class GitHubRepository implements vscode.Disposable { }) .filter(item => item !== null) as PullRequestModel[]; - Logger.debug(`Fetch all pull requests - done`, GitHubRepository.ID); + Logger.debug(`Fetch all pull requests - done`, this.id); return { items: pullRequests, - hasMorePages, + hasMorePages }; } catch (e) { - Logger.error(`Fetching all pull requests failed: ${e}`, GitHubRepository.ID); - if (e.code === 404) { + Logger.error(`Fetching all pull requests failed: ${e}`, this.id); + if (e.status === 404) { // not found vscode.window.showWarningMessage( - `Fetching pull requests for remote '${this.remote.remoteName}' failed, please check if the url ${this.remote.url} is valid.`, + `Fetching all pull requests for remote '${remote?.remoteName}' failed, please check if the repository ${remote?.owner}/${remote?.repositoryName} is valid.`, ); } else { throw e; @@ -391,10 +671,47 @@ export class GitHubRepository implements vscode.Disposable { return undefined; } - async getPullRequestForBranch(branch: string): Promise { + async getPullRequestNumbers(): Promise { + let remote: GitHubRemote | undefined; try { - Logger.debug(`Fetch pull requests for branch - enter`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); + Logger.debug(`Fetch pull request numbers - enter`, this.id); + const ensured = await this.ensure(); + remote = ensured.remote; + const { query, schema } = ensured; + const { data } = await query({ + query: schema.PullRequestNumbers, + variables: { + owner: remote.owner, + name: remote.repositoryName, + first: 100, + }, + }); + Logger.debug(`Fetch pull request numbers - done`, this.id); + + if (data?.repository?.pullRequests) { + return data.repository.pullRequests.nodes; + } + } catch (e) { + Logger.error(`Fetching pull request numbers failed: ${e}`, this.id); + if (e.status === 404) { + // not found + vscode.window.showWarningMessage( + `Fetching pull request numbers for remote '${remote?.remoteName}' failed, please check if the repository ${remote?.owner}/${remote?.repositoryName} is valid.`, + ); + } else { + throw e; + } + } + return undefined; + } + + async getPullRequestForBranch(branch: string, headOwner: string): Promise { + let remote: GitHubRemote | undefined; + try { + Logger.debug(`Fetch pull requests for branch - enter`, this.id); + const ensured = await this.ensure(); + remote = ensured.remote; + const { query, schema } = ensured; const { data } = await query({ query: schema.PullRequestForHead, variables: { @@ -403,48 +720,100 @@ export class GitHubRepository implements vscode.Disposable { headRefName: branch, }, }); - Logger.debug(`Fetch pull requests for branch - done`, GitHubRepository.ID); + Logger.debug(`Fetch pull requests for branch - done`, this.id); - if (data?.repository.pullRequests.nodes.length > 0) { - const prs = data.repository.pullRequests.nodes.map(node => parseGraphQLPullRequest(node, this)); + if (data?.repository && data.repository.pullRequests.nodes.length > 0) { + const prs = (await Promise.all(data.repository.pullRequests.nodes.map(node => parseGraphQLPullRequest(node, this)))).filter(pr => pr.head?.repo.owner === headOwner); + if (prs.length === 0) { + return undefined; + } const mostRecentOrOpenPr = prs.find(pr => pr.state.toLowerCase() === 'open') ?? prs[0]; return this.createOrUpdatePullRequestModel(mostRecentOrOpenPr); } } catch (e) { - Logger.error(`Fetching pull requests for branch failed: ${e}`, GitHubRepository.ID); - if (e.code === 404) { + Logger.error(`Fetching pull request for branch failed: ${e}`, this.id); + if (e.status === 404) { // not found vscode.window.showWarningMessage( - `Fetching pull requests for remote '${this.remote.remoteName}' failed, please check if the url ${this.remote.url} is valid.`, + `Fetching pull request for branch for remote '${remote?.remoteName}' failed, please check if the repository ${remote?.owner}/${remote?.repositoryName} is valid.`, ); - } else { - throw e; } } - return undefined; } - private getRepoForIssue(githubRepository: GitHubRepository, parsedIssue: Issue): GitHubRepository { - if ( - parsedIssue.repositoryName && - parsedIssue.repositoryUrl && - (githubRepository.remote.owner !== parsedIssue.repositoryOwner || - githubRepository.remote.repositoryName !== parsedIssue.repositoryName) - ) { - const remote = new Remote( - parsedIssue.repositoryName, - parsedIssue.repositoryUrl, - new Protocol(parsedIssue.repositoryUrl), - ); - githubRepository = new GitHubRepository(GitHubRemote.remoteAsGitHub(remote, this.remote.githubServerType), this.rootUri, this._credentialStore, this._telemetry); + async canGetProjectsNow(): Promise { + let { schema } = await this.ensure(); + if (schema.GetRepoProjects && schema.GetOrgProjects) { + return true; + } + return false; + } + + async getOrgProjects(): Promise { + Logger.debug(`Fetch org projects - enter`, this.id); + let { query, remote, schema } = await this.ensure(); + const projects: IProject[] = []; + + try { + const { data } = await query({ + query: schema.GetOrgProjects, + variables: { + owner: remote.owner, + after: null, + } + }); + + if (data && data.organization.projectsV2 && data.organization.projectsV2.nodes) { + data.organization.projectsV2.nodes.forEach(raw => { + projects.push(raw); + }); + } + + } catch (e) { + Logger.error(`Unable to fetch org projects: ${e}`, this.id); + return projects; + } + Logger.debug(`Fetch org projects - done`, this.id); + + return projects; + } + + async getProjects(): Promise { + try { + Logger.debug(`Fetch projects - enter`, this.id); + let { query, remote, schema } = await this.ensure(); + if (!schema.GetRepoProjects) { + const additional = await this.ensureAdditionalScopes(); + query = additional.query; + remote = additional.remote; + schema = additional.schema; + } + const { data } = await query({ + query: schema.GetRepoProjects, + variables: { + owner: remote.owner, + name: remote.repositoryName, + }, + }); + Logger.debug(`Fetch projects - done`, this.id); + + const projects: IProject[] = []; + if (data && data.repository?.projectsV2 && data.repository.projectsV2.nodes) { + data.repository.projectsV2.nodes.forEach(raw => { + projects.push(raw); + }); + } + return projects; + } catch (e) { + Logger.error(`Unable to fetch projects: ${e}`, this.id); + return; } - return githubRepository; } - async getMilestones(includeClosed: boolean = false): Promise { + async getMilestones(includeClosed: boolean = false): Promise { try { - Logger.debug(`Fetch milestones - enter`, GitHubRepository.ID); + Logger.debug(`Fetch milestones - enter`, this.id); const { query, remote, schema } = await this.ensure(); const states = ['OPEN']; if (includeClosed) { @@ -458,10 +827,10 @@ export class GitHubRepository implements vscode.Disposable { states: states, }, }); - Logger.debug(`Fetch milestones - done`, GitHubRepository.ID); + Logger.debug(`Fetch milestones - done`, this.id); const milestones: IMilestone[] = []; - if (data && data.repository.milestones && data.repository.milestones.nodes) { + if (data && data.repository?.milestones && data.repository.milestones.nodes) { data.repository.milestones.nodes.forEach(raw => { const milestone = parseMilestone(raw); if (milestone) { @@ -471,13 +840,13 @@ export class GitHubRepository implements vscode.Disposable { } return milestones; } catch (e) { - Logger.error(`Unable to fetch milestones: ${e}`, GitHubRepository.ID); + Logger.error(`Unable to fetch milestones: ${e}`, this.id); return; } } async getLines(sha: string, file: string, lineStart: number, lineEnd: number): Promise { - Logger.debug(`Fetch milestones - enter`, GitHubRepository.ID); + Logger.debug(`Fetch milestones - enter`, this.id); const { query, remote, schema } = await this.ensure(); const { data } = await query({ query: schema.GetFileContent, @@ -488,91 +857,16 @@ export class GitHubRepository implements vscode.Disposable { } }); - if (!data.repository.object.text) { + if (!data.repository?.object.text) { return undefined; } return data.repository.object.text.split('\n').slice(lineStart - 1, lineEnd).join('\n'); } - async getIssuesForUserByMilestone(_page?: number): Promise { - try { - Logger.debug(`Fetch all issues - enter`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - const { data } = await query({ - query: schema.GetMilestonesWithIssues, - variables: { - owner: remote.owner, - name: remote.repositoryName, - assignee: (await this._credentialStore.getCurrentUser(remote.authProviderId))?.login, - }, - }); - Logger.debug(`Fetch all issues - done`, GitHubRepository.ID); - - const milestones: { milestone: IMilestone; issues: IssueModel[] }[] = []; - let githubRepository: GitHubRepository = this; - if (data && data.repository.milestones && data.repository.milestones.nodes) { - data.repository.milestones.nodes.forEach(raw => { - const milestone = parseMilestone(raw); - if (milestone) { - const issues: IssueModel[] = []; - raw.issues.edges.forEach(issue => { - const parsedIssue = parseGraphQLIssue(issue.node, this); - githubRepository = this.getRepoForIssue(githubRepository, parsedIssue); - issues.push(new IssueModel(githubRepository, githubRepository.remote, parsedIssue)); - }); - milestones.push({ milestone, issues }); - } - }); - } - return { - items: milestones, - hasMorePages: data.repository.milestones.pageInfo.hasNextPage, - }; - } catch (e) { - Logger.error(`Unable to fetch issues: ${e}`, GitHubRepository.ID); - return; - } - } - - async getIssuesWithoutMilestone(_page?: number): Promise { - try { - Logger.debug(`Fetch issues without milestone- enter`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - const { data } = await query({ - query: schema.IssuesWithoutMilestone, - variables: { - owner: remote.owner, - name: remote.repositoryName, - assignee: (await this._credentialStore.getCurrentUser(remote.authProviderId))?.login, - }, - }); - Logger.debug(`Fetch issues without milestone - done`, GitHubRepository.ID); - - const issues: IssueModel[] = []; - let githubRepository: GitHubRepository = this; - if (data && data.repository.issues.edges) { - data.repository.issues.edges.forEach(raw => { - if (raw.node.id) { - const parsedIssue = parseGraphQLIssue(raw.node, this); - githubRepository = this.getRepoForIssue(githubRepository, parsedIssue); - issues.push(new IssueModel(githubRepository, githubRepository.remote, parsedIssue)); - } - }); - } - return { - items: issues, - hasMorePages: data.repository.issues.pageInfo.hasNextPage, - }; - } catch (e) { - Logger.error(`Unable to fetch issues without milestone: ${e}`, GitHubRepository.ID); - return; - } - } - async getIssues(page?: number, queryString?: string): Promise { try { - Logger.debug(`Fetch issues with query - enter`, GitHubRepository.ID); + Logger.debug(`Fetch issues with query - enter`, this.id); const { query, schema } = await this.ensure(); const { data } = await query({ query: schema.Issues, @@ -580,55 +874,61 @@ export class GitHubRepository implements vscode.Disposable { query: `${queryString} type:issue`, }, }); - Logger.debug(`Fetch issues with query - done`, GitHubRepository.ID); + Logger.debug(`Fetch issues with query - done`, this.id); - const issues: IssueModel[] = []; - let githubRepository: GitHubRepository = this; + const issues: Issue[] = []; if (data && data.search.edges) { - data.search.edges.forEach(raw => { + await Promise.all(data.search.edges.map(async raw => { if (raw.node.id) { - const parsedIssue = parseGraphQLIssue(raw.node, this); - githubRepository = this.getRepoForIssue(githubRepository, parsedIssue); - issues.push(new IssueModel(githubRepository, githubRepository.remote, parsedIssue)); + issues.push(await parseGraphQLIssue(raw.node, this)); } - }); + })); } return { items: issues, hasMorePages: data.search.pageInfo.hasNextPage, + totalCount: data.search.issueCount }; } catch (e) { - Logger.error(`Unable to fetch issues with query: ${e}`, GitHubRepository.ID); + Logger.error(`Unable to fetch issues with query: ${e}`, this.id); return; } } - async getMaxIssue(): Promise { + private async _getMaxItem(isIssue: boolean): Promise { try { - Logger.debug(`Fetch max issue - enter`, GitHubRepository.ID); + Logger.debug(`Fetch max ${isIssue ? 'issue' : 'pull request'} - enter`, this.id); const { query, remote, schema } = await this.ensure(); const { data } = await query({ - query: schema.MaxIssue, + query: isIssue ? schema.MaxIssue : schema.MaxPullRequest, variables: { owner: remote.owner, name: remote.repositoryName, }, }); - Logger.debug(`Fetch max issue - done`, GitHubRepository.ID); + Logger.debug(`Fetch max ${isIssue ? 'issue' : 'pull request'} - done`, this.id); - if (data && data.repository.issues.edges.length === 1) { + if (data?.repository && data.repository.issues.edges.length === 1) { return data.repository.issues.edges[0].node.number; } return; } catch (e) { - Logger.error(`Unable to fetch issues with query: ${e}`, GitHubRepository.ID); + Logger.error(`Unable to fetch ${isIssue ? 'issues' : 'pull requests'} with query: ${e}`, this.id); return; } } + async getMaxIssue(): Promise { + return this._getMaxItem(true); + } + + async getMaxPullRequest(): Promise { + return this._getMaxItem(false); + } + async getViewerPermission(): Promise { try { - Logger.debug(`Fetch viewer permission - enter`, GitHubRepository.ID); + Logger.debug(`Fetch viewer permission - enter`, this.id); const { query, remote, schema } = await this.ensure(); const { data } = await query({ query: schema.GetViewerPermission, @@ -637,32 +937,74 @@ export class GitHubRepository implements vscode.Disposable { name: remote.repositoryName, }, }); - Logger.debug(`Fetch viewer permission - done`, GitHubRepository.ID); + Logger.debug(`Fetch viewer permission - done`, this.id); return parseGraphQLViewerPermission(data); } catch (e) { - Logger.error(`Unable to fetch viewer permission: ${e}`, GitHubRepository.ID); + Logger.error(`Unable to fetch viewer permission: ${e}`, this.id); return ViewerPermission.Unknown; } } + public async getWorkflowRunsFromAction(fromDate: string): Promise { + const { octokit, remote } = await this.ensure(); + const createdDate = new Date(fromDate); + const created = `>=${createdDate.getFullYear()}-${String(createdDate.getMonth() + 1).padStart(2, '0')}-${String(createdDate.getDate()).padStart(2, '0')}`; + const allRuns = await restPaginate(octokit.api.actions.listWorkflowRunsForRepo, { + owner: remote.owner, + repo: remote.repositoryName, + event: 'dynamic', + created + }); + + return allRuns; + } + + public async getWorkflowJobs(workflowRunId: number): Promise { + const { octokit, remote } = await this.ensure(); + const jobs = await octokit.call(octokit.api.actions.listJobsForWorkflowRun, { + owner: remote.owner, + repo: remote.repositoryName, + run_id: workflowRunId + }); + return jobs.data.jobs; + } + async fork(): Promise { try { - Logger.debug(`Fork repository`, GitHubRepository.ID); + Logger.debug(`Fork repository`, this.id); const { octokit, remote } = await this.ensure(); const result = await octokit.call(octokit.api.repos.createFork, { owner: remote.owner, repo: remote.repositoryName, }); + Logger.debug(`Fork repository - done`, this.id); + // GitHub can say the fork succeeded but it isn't actually ready yet. + // So we wait up to 5 seconds for the fork to be ready + const start = Date.now(); + let exists = async () => { + try { + await octokit.call(octokit.api.repos.get, { owner: result.data.owner.login, repo: result.data.name }); + Logger.appendLine('Fork ready', this.id); + return true; + } catch (e) { + Logger.appendLine('Fork not ready yet', this.id); + return false; + } + }; + while (!(await exists()) && ((Date.now() - start) < 5000)) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + return result.data.clone_url; } catch (e) { - Logger.error(`GitHubRepository> Forking repository failed: ${e}`, GitHubRepository.ID); + Logger.error(`GitHubRepository> Forking repository failed: ${e}`, this.id); return undefined; } } async getRepositoryForkDetails(): Promise { try { - Logger.debug(`Fetch repository fork details - enter`, GitHubRepository.ID); + Logger.debug(`Fetch repository fork details - enter`, this.id); const { query, remote, schema } = await this.ensure(); const { data } = await query({ query: schema.GetRepositoryForkDetails, @@ -671,98 +1013,73 @@ export class GitHubRepository implements vscode.Disposable { name: remote.repositoryName, }, }); - Logger.debug(`Fetch repository fork details - done`, GitHubRepository.ID); + Logger.debug(`Fetch repository fork details - done`, this.id); return data.repository; } catch (e) { - Logger.error(`Unable to fetch repository fork details: ${e}`, GitHubRepository.ID); + Logger.error(`Unable to fetch repository fork details: ${e}`, this.id); return; } } - async getAuthenticatedUser(): Promise { - return (await this._credentialStore.getCurrentUser(this.remote.authProviderId)).login; + async getAuthenticatedUser(): Promise { + return await this._credentialStore.getCurrentUser(this.remote.authProviderId); } - async getPullRequestsForCategory(categoryQuery: string, page?: number): Promise { + async getAuthenticatedUserEmails(): Promise { try { - Logger.debug(`Fetch pull request category ${categoryQuery} - enter`, GitHubRepository.ID); - const { octokit, query, schema } = await this.ensure(); - - const user = await this.getAuthenticatedUser(); - // Search api will not try to resolve repo that redirects, so get full name first - const repo = await this.getMetadata(); - const { data, headers } = await octokit.call(octokit.api.search.issuesAndPullRequests, { - q: getPRFetchQuery(repo.full_name, user, categoryQuery), - per_page: PULL_REQUEST_PAGE_SIZE, - page: page || 1, - }); - - const promises: Promise[] = data.items.map(async (item) => { - const prRepo = new Protocol(item.repository_url); - const { data } = await query({ - query: schema.PullRequest, - variables: { - owner: prRepo.owner, - name: prRepo.repositoryName, - number: item.number - } - }); - return data; - }); - - const hasMorePages = !!headers.link && headers.link.indexOf('rel="next"') > -1; - const pullRequestResponses = await Promise.all(promises); - - const pullRequests = pullRequestResponses - .map(response => { - if (!response.repository.pullRequest.headRef) { - Logger.appendLine('The remote branch for this PR was already deleted.', GitHubRepository.ID); - return null; - } - - return this.createOrUpdatePullRequestModel( - parseGraphQLPullRequest(response.repository.pullRequest, this), - ); - }) - .filter(item => item !== null) as PullRequestModel[]; - - Logger.debug(`Fetch pull request category ${categoryQuery} - done`, GitHubRepository.ID); - - return { - items: pullRequests, - hasMorePages, - }; + Logger.debug(`Fetch authenticated user emails - enter`, this.id); + const { octokit } = await this.ensure(); + const { data } = await octokit.call(octokit.api.users.listEmailsForAuthenticatedUser, {}); + Logger.debug(`Fetch authenticated user emails - done`, this.id); + // sort the primary email to the first index + const hasPrivate = data.some(email => email.visibility === 'private'); + return data.filter(email => hasPrivate ? email.email.endsWith('@users.noreply.github.com') : email.verified) + .sort((a, b) => +b.primary - +a.primary) + .map(email => email.email); } catch (e) { - Logger.error(`Fetching all pull requests failed: ${e}`, GitHubRepository.ID); - if (e.code === 404) { - // not found - vscode.window.showWarningMessage( - `Fetching pull requests for remote ${this.remote.remoteName}, please check if the url ${this.remote.url} is valid.`, - ); - } else { - throw e; - } + Logger.error(`Unable to fetch authenticated user emails: ${e}`, this.id); + return []; } - return undefined; } - createOrUpdatePullRequestModel(pullRequest: PullRequest): PullRequestModel { - let model = this._pullRequestModels.get(pullRequest.number); + createOrUpdatePullRequestModel(pullRequest: PullRequest, silent: boolean = false): PullRequestModel { + let model = this._pullRequestModelsByNumber.get(pullRequest.number)?.model; if (model) { model.update(pullRequest); } else { - model = new PullRequestModel(this._telemetry, this, this.remote, pullRequest); - model.onDidInvalidate(() => this.getPullRequest(pullRequest.number)); - this._pullRequestModels.set(pullRequest.number, model); - this._onDidAddPullRequest.fire(model); + model = new PullRequestModel(this._credentialStore, this.telemetry, this, this.remote, pullRequest); + const prModel = model; + const disposables: vscode.Disposable[] = []; + disposables.push(model.onDidChange(e => this._onPullRequestModelChanged(prModel, e))); + this._pullRequestModelsByNumber.set(pullRequest.number, { model, disposables }); + if (!silent) { + this._onDidAddPullRequest.fire(model); + } } 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 }]); + } + async createPullRequest(params: OctokitCommon.PullsCreateParams): Promise { try { - Logger.debug(`Create pull request - enter`, GitHubRepository.ID); + Logger.debug(`Create pull request - enter`, this.id); const metadata = await this.getMetadata(); const { mutate, schema } = await this.ensure(); @@ -779,21 +1096,53 @@ export class GitHubRepository implements vscode.Disposable { } } }); - Logger.debug(`Create pull request - done`, GitHubRepository.ID); + Logger.debug(`Create pull request - done`, this.id); if (!data) { throw new Error('Failed to create pull request.'); } - return this.createOrUpdatePullRequestModel(parseGraphQLPullRequest(data.createPullRequest.pullRequest, this)); + return this.createOrUpdatePullRequestModel(await parseGraphQLPullRequest(data.createPullRequest.pullRequest, this)); } catch (e) { - Logger.error(`Unable to create PR: ${e}`, GitHubRepository.ID); + Logger.error(`Unable to create PR: ${e}`, this.id); throw e; } } - async getPullRequest(id: number): Promise { + async revertPullRequest(pullRequestId: string, title: string, body: string, draft: boolean): Promise { + try { + Logger.debug(`Revert pull request - enter`, this.id); + const { mutate, schema } = await this.ensure(); + + const { data } = await mutate({ + mutation: schema.RevertPullRequest, + variables: { + input: { + pullRequestId, + title, + body, + draft + } + } + }); + Logger.debug(`Revert pull request - done`, this.id); + if (!data) { + throw new Error('Failed to create revert pull request.'); + } + return this.createOrUpdatePullRequestModel(await parseGraphQLPullRequest(data.revertPullRequest.revertPullRequest, this)); + } catch (e) { + Logger.error(`Unable to create revert PR: ${e}`, this.id); + throw e; + } + } + + async getPullRequest(id: number, useCache: boolean = false): Promise { + if (useCache && this._pullRequestModelsByNumber.has(id)) { + Logger.debug(`Using cached pull request model for ${id}`, this.id); + return this._pullRequestModelsByNumber.get(id)!.model; + } + try { - Logger.debug(`Fetch pull request ${id} - enter`, GitHubRepository.ID); const { query, remote, schema } = await this.ensure(); + Logger.debug(`Fetch pull request ${remote.owner}/${remote.repositoryName} ${id} - enter`, this.id); const { data } = await query({ query: schema.PullRequest, @@ -802,45 +1151,135 @@ export class GitHubRepository implements vscode.Disposable { name: remote.repositoryName, number: id, }, - }); - Logger.debug(`Fetch pull request ${id} - done`, GitHubRepository.ID); - return this.createOrUpdatePullRequestModel(parseGraphQLPullRequest(data.repository.pullRequest, this)); + }, true); + if (data.repository === null) { + Logger.error('Unexpected null repository when getting PR', this.id); + return; + } + + Logger.debug(`Fetch pull request ${id} - done`, this.id); + 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}`, GitHubRepository.ID); + 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`, GitHubRepository.ID); + Logger.debug(`Fetch issue ${id} - enter`, this.id); const { query, remote, schema } = await this.ensure(); - const { data } = await query({ + const { data } = await query({ query: withComments ? schema.IssueWithComments : schema.Issue, variables: { owner: remote.owner, name: remote.repositoryName, number: id, }, - }, true); // Don't retry on SAML errors as it's too distruptive for this query. - Logger.debug(`Fetch issue ${id} - done`, GitHubRepository.ID); + }, true); // Don't retry on SAML errors as it's too disruptive for this query. + + if (data.repository === null) { + Logger.error('Unexpected null repository when getting issue', this.id); + return undefined; + } + Logger.debug(`Fetch issue ${id} - done`, this.id); - return new IssueModel(this, remote, parseGraphQLIssue(data.repository.pullRequest, 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 PR: ${e}`, GitHubRepository.ID); + Logger.error(`Unable to fetch issue: ${e}`, this.id); return; } } - async listBranches(owner: string, repositoryName: string): Promise { + /** + * Gets file content for a file at the specified commit + * @param filePath The file path + * @param ref The commit + */ + async getFile(filePath: string, ref: string): Promise { + const { octokit, remote } = await this.ensure(); + let contents: string = ''; + let fileContent: { data: { content: string; encoding: string; sha: string } }; + Logger.debug(`Fetch file ${filePath} - enter`, this.id); + try { + fileContent = (await octokit.call(octokit.api.repos.getContent, + { + owner: remote.owner, + repo: remote.repositoryName, + path: filePath, + ref, + }, + )) as { data: { content: string; encoding: string; sha: string } }; + + if (Array.isArray(fileContent.data)) { + throw new Error(`Unexpected array response when getting file ${filePath}`); + } + + contents = fileContent.data.content ?? ''; + } catch (e) { + Logger.error(`Unable to fetch file ${filePath}: ${e}`, this.id); + if (e.status === 404) { + return new Uint8Array(0); + } + throw e; + } + + // Empty contents and 'none' encoding indcates that the file has been truncated and we should get the blob. + if (contents === '' && fileContent.data.encoding === 'none') { + Logger.debug(`Fetch blob file ${filePath} - enter`, this.id); + const fileSha = fileContent.data.sha; + fileContent = await octokit.call(octokit.api.git.getBlob, { + owner: remote.owner, + repo: remote.repositoryName, + file_sha: fileSha, + }); + contents = fileContent.data.content; + Logger.debug(`Fetch blob file ${filePath} - done`, this.id); + } + + 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; + } + + async hasBranch(branchName: string): Promise { + Logger.appendLine(`Fetch branch ${branchName} - enter`, this.id); + const { query, remote, schema } = await this.ensure(); + + const { data } = await query({ + query: schema.GetBranch, + variables: { + owner: remote.owner, + name: remote.repositoryName, + qualifiedName: `refs/heads/${branchName}`, + } + }); + Logger.appendLine(`Fetch branch ${branchName} - done: ${data.repository?.ref !== null}`, this.id); + return data.repository?.ref?.target.oid; + } + + async listBranches(owner: string, repositoryName: string, prefix: string | undefined): Promise { const { query, remote, schema } = await this.ensure(); - Logger.debug(`List branches for ${owner}/${repositoryName} - enter`, GitHubRepository.ID); + Logger.debug(`List branches for ${owner}/${repositoryName} - enter`, this.id); let after: string | null = null; let hasNextPage = false; 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 { @@ -851,23 +1290,27 @@ export class GitHubRepository implements vscode.Disposable { name: remote.repositoryName, first: 100, after: after, + query: prefix ? prefix : null, }, }); branches.push(...data.repository.refs.nodes.map(node => node.name)); - if (new Date().getTime() - startingTime > 5000) { - Logger.warn('List branches timeout hit.', GitHubRepository.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; after = data.repository.refs.pageInfo.endCursor; } catch (e) { - Logger.debug(`List branches for ${owner}/${repositoryName} failed`, GitHubRepository.ID); + Logger.debug(`List branches for ${owner}/${repositoryName} failed`, this.id); throw e; } } while (hasNextPage); - Logger.debug(`List branches for ${owner}/${repositoryName} - done`, GitHubRepository.ID); + Logger.debug(`List branches for ${owner}/${repositoryName} - done`, this.id); + if (!branches.includes(defaultBranch)) { + branches.unshift(defaultBranch); + } return branches; } @@ -885,13 +1328,13 @@ export class GitHubRepository implements vscode.Disposable { ref: `heads/${pullRequestModel.head.ref}`, }); } catch (e) { - Logger.error(`Unable to delete branch: ${e}`, GitHubRepository.ID); + Logger.error(`Unable to delete branch: ${e}`, this.id); return; } } async getMentionableUsers(): Promise { - Logger.debug(`Fetch mentionable users - enter`, GitHubRepository.ID); + Logger.debug(`Fetch mentionable users - enter`, this.id); const { query, remote, schema } = await this.ensure(); let after: string | null = null; @@ -910,22 +1353,21 @@ export class GitHubRepository implements vscode.Disposable { }, }); + if (result.data.repository === null) { + Logger.error('Unexpected null repository when getting mentionable users', this.id); + return []; + } + ret.push( ...result.data.repository.mentionableUsers.nodes.map(node => { - return { - login: node.login, - avatarUrl: getAvatarWithEnterpriseFallback(node.avatarUrl, undefined, this.remote.authProviderId), - name: node.name, - url: node.url, - email: node.email, - }; + return parseAccount(node, this); }), ); hasNextPage = result.data.repository.mentionableUsers.pageInfo.hasNextPage; after = result.data.repository.mentionableUsers.pageInfo.endCursor; } catch (e) { - Logger.debug(`Unable to fetch mentionable users: ${e}`, GitHubRepository.ID); + Logger.debug(`Unable to fetch mentionable users: ${e}`, this.id); return ret; } } while (hasNextPage); @@ -933,8 +1375,29 @@ export class GitHubRepository implements vscode.Disposable { return ret; } + async resolveUser(login: string): Promise { + Logger.debug(`Fetch user ${login}`, this.id); + const { query, schema } = await this.ensure(); + + try { + const { data } = await query({ + query: schema.GetUser, + variables: { + login, + }, + }); + return parseGraphQLUser(data, this); + } catch (e) { + // Ignore cases where the user doesn't exist + if (!(e.message as (string | undefined))?.startsWith('GraphQL error: Could not resolve to a User with the login of')) { + Logger.warn(e.message); + } + } + return undefined; + } + async getAssignableUsers(): Promise { - Logger.debug(`Fetch assignable users - enter`, GitHubRepository.ID); + Logger.debug(`Fetch assignable users - enter`, this.id); const { query, remote, schema } = await this.ensure(); let after: string | null = null; @@ -943,32 +1406,56 @@ export class GitHubRepository implements vscode.Disposable { do { try { - const result: { data: AssignableUsersResponse } = await query({ - query: schema.GetAssignableUsers, - variables: { - owner: remote.owner, - name: remote.repositoryName, - first: 100, - after: after, - }, - }); + let result: { data: AssignableUsersResponse | SuggestedActorsResponse } | undefined; + if (schema.GetSuggestedActors) { + result = await query({ + query: schema.GetSuggestedActors, + variables: { + owner: remote.owner, + name: remote.repositoryName, + capabilities: ['CAN_BE_ASSIGNED'], + first: 100, + after: after, + }, + }, false, { + query: schema.GetAssignableUsers, + variables: { + owner: remote.owner, + name: remote.repositoryName, + first: 100, + after: after, + } + }); + + } else { + result = await query({ + query: schema.GetAssignableUsers, + variables: { + owner: remote.owner, + name: remote.repositoryName, + first: 100, + after: after, + }, + }, true); // we ignore SAML errors here because this query can happen at startup + } + + if (result.data.repository === null) { + Logger.error('Unexpected null repository when getting assignable users', this.id); + return []; + } + + const users = (result.data as AssignableUsersResponse).repository?.assignableUsers ?? (result.data as SuggestedActorsResponse).repository?.suggestedActors; ret.push( - ...result.data.repository.assignableUsers.nodes.map(node => { - return { - login: node.login, - avatarUrl: getAvatarWithEnterpriseFallback(node.avatarUrl, undefined, this.remote.authProviderId), - name: node.name, - url: node.url, - email: node.email, - }; - }), + ...(users?.nodes.map(node => { + return parseAccount(node, this); + }) || []), ); - hasNextPage = result.data.repository.assignableUsers.pageInfo.hasNextPage; - after = result.data.repository.assignableUsers.pageInfo.endCursor; + hasNextPage = users?.pageInfo.hasNextPage; + after = users?.pageInfo.endCursor; } catch (e) { - Logger.debug(`Unable to fetch assignable users: ${e}`, GitHubRepository.ID); + Logger.debug(`Unable to fetch assignable users: ${e}`, this.id); if ( e.graphQLErrors && e.graphQLErrors.length > 0 && @@ -985,8 +1472,114 @@ export class GitHubRepository implements vscode.Disposable { return ret; } + async cancelWorkflow(workflowRunId: number): Promise { + Logger.debug(`Cancel workflow run - enter`, this.id); + const { octokit, remote } = await this.ensure(); + try { + const result = await octokit.call(octokit.api.actions.cancelWorkflowRun, { + owner: remote.owner, + repo: remote.repositoryName, + run_id: workflowRunId, + }); + return result.status === 202; + } catch (e) { + Logger.error(`Unable to cancel workflow run: ${e}`, this.id); + return false; + } + } + + async getOrgTeamsCount(): Promise { + Logger.debug(`Fetch Teams Count - enter`, this.id); + if (!this._credentialStore.isAuthenticatedWithAdditionalScopes(this.remote.authProviderId)) { + return 0; + } + + const { query, remote, schema } = await this.ensureAdditionalScopes(); + + try { + const result: { data: OrganizationTeamsCountResponse } = await query({ + query: schema.GetOrganizationTeamsCount, + variables: { + login: remote.owner + }, + }); + const totalCount = result.data.organization.teams.totalCount; + Logger.debug(`Fetch Teams Count - done`, this.id); + return totalCount; + } catch (e) { + Logger.debug(`Unable to fetch teams Count: ${e}`, this.id); + if ( + e.graphQLErrors && + e.graphQLErrors.length > 0 && + e.graphQLErrors[0].type === 'INSUFFICIENT_SCOPES' + ) { + vscode.window.showWarningMessage( + `GitHub teams features will not work. ${e.graphQLErrors[0].message}`, + ); + } + return 0; + } + } + + async getOrgTeams(refreshKind: TeamReviewerRefreshKind): Promise<(ITeam & { repositoryNames: string[] })[]> { + Logger.debug(`Fetch Teams - enter`, this.id); + if ((refreshKind === TeamReviewerRefreshKind.None) || (refreshKind === TeamReviewerRefreshKind.Try && !this._credentialStore.isAuthenticatedWithAdditionalScopes(this.remote.authProviderId))) { + Logger.debug(`Fetch Teams - exit without fetching teams`, this.id); + return []; + } + + const { query, remote, schema } = await this.ensureAdditionalScopes(); + + let after: string | null = null; + let hasNextPage = false; + const orgTeams: (ITeam & { repositoryNames: string[] })[] = []; + + do { + try { + const result: { data: OrganizationTeamsResponse } = await query({ + query: schema.GetOrganizationTeams, + variables: { + login: remote.owner, + after: after, + repoName: remote.repositoryName, + }, + }); + + result.data.organization.teams.nodes.forEach(node => { + const team: ITeam = { + avatarUrl: getAvatarWithEnterpriseFallback(node.avatarUrl, undefined, this.remote.isEnterprise), + name: node.name, + url: node.url, + slug: node.slug, + id: node.id, + org: remote.owner + }; + orgTeams.push({ ...team, repositoryNames: node.repositories.nodes.map(repo => repo.name) }); + }); + + hasNextPage = result.data.organization.teams.pageInfo.hasNextPage; + after = result.data.organization.teams.pageInfo.endCursor; + } catch (e) { + Logger.debug(`Unable to fetch teams: ${e}`, this.id); + if ( + e.graphQLErrors && + e.graphQLErrors.length > 0 && + e.graphQLErrors[0].type === 'INSUFFICIENT_SCOPES' + ) { + vscode.window.showWarningMessage( + `GitHub teams features will not work. ${e.graphQLErrors[0].message}`, + ); + } + return orgTeams; + } + } while (hasNextPage); + + Logger.debug(`Fetch Teams - exit`, this.id); + return orgTeams; + } + async getPullRequestParticipants(pullRequestNumber: number): Promise { - Logger.debug(`Fetch participants from a Pull Request`, GitHubRepository.ID); + Logger.debug(`Fetch participants from a Pull Request`, this.id); const { query, remote, schema } = await this.ensure(); const ret: IAccount[] = []; @@ -1001,20 +1594,18 @@ export class GitHubRepository implements vscode.Disposable { first: 18 }, }); + if (result.data.repository === null) { + Logger.error('Unexpected null repository when fetching participants', this.id); + return []; + } ret.push( ...result.data.repository.pullRequest.participants.nodes.map(node => { - return { - login: node.login, - avatarUrl: getAvatarWithEnterpriseFallback(node.avatarUrl, undefined, this.remote.authProviderId), - name: node.name, - url: node.url, - email: node.email, - }; + return parseAccount(node, this); }), ); } catch (e) { - Logger.debug(`Unable to fetch participants from a PullRequest: ${e}`, GitHubRepository.ID); + Logger.debug(`Unable to fetch participants from a PullRequest: ${e}`, this.id); if ( e.graphQLErrors && e.graphQLErrors.length > 0 && @@ -1035,6 +1626,7 @@ export class GitHubRepository implements vscode.Disposable { * @param head The head branch. Must be a branch name. If comparing across repositories, use the format :branch. */ public async compareCommits(base: string, head: string): Promise { + Logger.debug('Compare commits - enter', this.id); try { const { remote, octokit } = await this.ensure(); const { data } = await octokit.call(octokit.api.repos.compareCommits, { @@ -1043,15 +1635,15 @@ export class GitHubRepository implements vscode.Disposable { base, head, }); - + Logger.debug('Compare commits - done', this.id); return data; } catch (e) { - Logger.error(`Unable to compare commits between ${base} and ${head}: ${e}`, GitHubRepository.ID); + Logger.error(`Unable to compare commits between ${base} and ${head}: ${e}`, this.id); } } - isCurrentUser(login: string): Promise { - return this._credentialStore.isCurrentUser(login); + isCurrentUser(authProviderId: AuthProvider, login: string): Promise { + return this._credentialStore.isCurrentUser(authProviderId, login); } /** @@ -1060,10 +1652,12 @@ export class GitHubRepository implements vscode.Disposable { * This method should go in PullRequestModel, but because of the status checks bug we want to track `_useFallbackChecks` at a repo level. */ private _useFallbackChecks: boolean = false; - async getStatusChecks(number: number): Promise { + async getStatusChecks(number: number): Promise<[PullRequestChecks | null, PullRequestReviewRequirement | null]> { + Logger.debug('Get Status Checks - enter', this.id); + const { query, remote, schema } = await this.ensure(); const captureUseFallbackChecks = this._useFallbackChecks; - let result; + let result: ApolloQueryResult; try { result = await query({ query: captureUseFallbackChecks ? schema.GetChecksWithoutSuite : schema.GetChecks, @@ -1072,9 +1666,10 @@ export class GitHubRepository implements vscode.Disposable { name: remote.repositoryName, number: number, }, - }, true); // There's an issue with the GetChecks that can result in SAML errors. + }); } catch (e) { - if (e.message?.startsWith('GraphQL error: Resource protected by organization SAML enforcement.')) { + // There's an issue with the GetChecks that can result in SAML errors. + if (isSamlError(e) || this.remote.isEnterprise) { // There seems to be an issue with fetching status checks if you haven't SAML'd with every org you have // The issue is specifically with the CheckSuite property. Make the query again, but without that property. if (!captureUseFallbackChecks) { @@ -1082,44 +1677,210 @@ export class GitHubRepository implements vscode.Disposable { return this.getStatusChecks(number); } } + Logger.error(`Unable to fetch PR checks: ${e}`, this.id); throw e; } + if ((result.data.repository === null) || (result.data.repository.pullRequest.commits.nodes === undefined) || (result.data.repository.pullRequest.commits.nodes.length === 0)) { + Logger.error(`Unable to fetch PR checks: ${result.errors?.map(error => error.message).join(', ')}`, this.id); + return [null, null]; + } + // We always fetch the status checks for only the last commit, so there should only be one node present const statusCheckRollup = result.data.repository.pullRequest.commits.nodes[0].commit.statusCheckRollup; + let checks: PullRequestChecks; if (!statusCheckRollup) { - return undefined; - } - - const checks: PullRequestChecks = { - state: statusCheckRollup.state.toLowerCase(), - statuses: statusCheckRollup.contexts.nodes.map(context => { + checks = { + state: CheckState.Success, + statuses: [] + }; + } else { + const dedupedStatuses = this.deduplicateStatusChecks(statusCheckRollup.contexts.nodes.map(context => { if (isCheckRun(context)) { return { id: context.id, url: context.checkSuite?.app?.url, - avatar_url: context.checkSuite?.app?.logoUrl, - state: context.conclusion?.toLowerCase() || CheckState.Pending, + avatarUrl: + context.checkSuite?.app?.logoUrl && + getAvatarWithEnterpriseFallback( + context.checkSuite.app.logoUrl, + undefined, + this.remote.isEnterprise, + ), + state: this.mapStateAsCheckState(context.conclusion), description: context.title, context: context.name, - target_url: context.detailsUrl, + workflowName: context.checkSuite?.workflowRun?.workflow.name, + event: context.checkSuite?.workflowRun?.event, + targetUrl: context.detailsUrl, + isRequired: context.isRequired, }; } else { return { id: context.id, - url: context.targetUrl, - avatar_url: context.avatarUrl, - state: context.state?.toLowerCase(), + url: context.targetUrl ?? undefined, + avatarUrl: context.avatarUrl + ? getAvatarWithEnterpriseFallback(context.avatarUrl, undefined, this.remote.isEnterprise) + : undefined, + state: this.mapStateAsCheckState(context.state), description: context.description, context: context.context, - target_url: context.targetUrl, + workflowName: undefined, + event: undefined, + targetUrl: context.targetUrl, + isRequired: context.isRequired, }; } - }), - }; + })); + + checks = { + state: this.computeOverallCheckState(dedupedStatuses), + statuses: dedupedStatuses + }; + } + let reviewRequirement: PullRequestReviewRequirement | null = null; + const rule = result.data.repository.pullRequest.baseRef?.refUpdateRule; + if (rule) { + const prUrl = result.data.repository.pullRequest.url; + + for (const context of rule.requiredStatusCheckContexts || []) { + if (!checks.statuses.some(status => status.context === context)) { + checks.state = CheckState.Pending; + checks.statuses.push({ + id: '', + url: undefined, + avatarUrl: undefined, + state: CheckState.Pending, + description: vscode.l10n.t('Waiting for status to be reported'), + context: context, + workflowName: undefined, + event: undefined, + targetUrl: prUrl, + isRequired: true + }); + } + } - return checks; + const requiredApprovingReviews = rule.requiredApprovingReviewCount ?? 0; + const approvingReviews = result.data.repository.pullRequest.latestReviews.nodes.filter( + review => review.authorCanPushToRepository && review.state === 'APPROVED', + ); + const requestedChanges = result.data.repository.pullRequest.reviewsRequestingChanges.nodes.filter( + review => review.authorCanPushToRepository + ); + let state: CheckState = CheckState.Success; + if (approvingReviews.length < requiredApprovingReviews) { + state = CheckState.Failure; + + if (requestedChanges.length) { + state = CheckState.Pending; + } + } + if (requiredApprovingReviews > 0) { + reviewRequirement = { + count: requiredApprovingReviews, + approvals: approvingReviews.map(review => review.author.login), + requestedChanges: requestedChanges.map(review => review.author.login), + state: state + }; + } + } + + Logger.debug('Get Status Checks - done', this.id); + return [checks.statuses.length ? checks : null, reviewRequirement]; + } + + mapStateAsCheckState(state: string | null | undefined): CheckState { + switch (state) { + case 'EXPECTED': + case 'PENDING': + case 'ACTION_REQUIRED': + case 'STALE': + return CheckState.Pending; + case 'ERROR': + case 'FAILURE': + case 'TIMED_OUT': + case 'STARTUP_FAILURE': + return CheckState.Failure; + case 'SUCCESS': + return CheckState.Success; + case 'NEUTRAL': + case 'SKIPPED': + return CheckState.Neutral; + } + + return CheckState.Unknown; + } + + /** + * Deduplicate status checks by context (check name). + * When a check is re-run on the same commit (e.g., when a PR is closed and reopened), + * GitHub's API returns all check run instances. This method keeps only one entry per + * check context, preferring pending/running checks over completed ones, and the most + * recent completed check when all are completed. + */ + private deduplicateStatusChecks(statuses: PullRequestCheckStatus[]): PullRequestCheckStatus[] { + const statusByContext = new Map(); + + for (const status of statuses) { + const existing = statusByContext.get(status.context); + if (!existing) { + statusByContext.set(status.context, status); + continue; + } + + // Prefer pending/unknown checks over completed ones (they represent the latest run) + const existingIsPending = existing.state === CheckState.Pending || existing.state === CheckState.Unknown; + const currentIsPending = status.state === CheckState.Pending || status.state === CheckState.Unknown; + + if (currentIsPending && !existingIsPending) { + // Current is pending, existing is completed - prefer current + statusByContext.set(status.context, status); + } else if (!currentIsPending && existingIsPending) { + // Current is completed, existing is pending - keep existing + continue; + } else { + // Both are same type (both pending or both completed) + // Prefer the one with a higher ID (more recent), as GitHub IDs are monotonically increasing + if (status.id > existing.id) { + statusByContext.set(status.context, status); + } + } + } + + return Array.from(statusByContext.values()); + } + + /** + * Compute the overall check state from individual status checks. + * - If any check has failed, the overall state is failure + * - If any check is pending/unknown (and none have failed), the overall state is pending + * - If all checks are successful or neutral/skipped, the overall state is success + */ + private computeOverallCheckState(statuses: PullRequestCheckStatus[]): CheckState { + if (statuses.length === 0) { + return CheckState.Success; + } + + let hasFailure = false; + let hasPending = false; + + for (const status of statuses) { + if (status.state === CheckState.Failure) { + hasFailure = true; + } else if (status.state === CheckState.Pending || status.state === CheckState.Unknown) { + hasPending = true; + } + } + + if (hasFailure) { + return CheckState.Failure; + } + if (hasPending) { + return CheckState.Pending; + } + return CheckState.Success; } } diff --git a/src/github/graphql.ts b/src/github/graphql.ts index 9bb0eb9f7b..8d16740ef6 100644 --- a/src/github/graphql.ts +++ b/src/github/graphql.ts @@ -3,17 +3,18 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { DiffSide, ViewedState } from '../common/comment'; import { ForkDetails } from './githubRepository'; +import { DiffSide, SubjectType, ViewedState } from '../common/comment'; + +interface PageInfo { + hasNextPage: boolean; + endCursor: string; +} export interface MergedEvent { __typename: string; id: string; - actor: { - login: string; - avatarUrl: string; - url: string; - }; + actor: Actor; createdAt: string; mergeRef: { name: string; @@ -28,24 +29,63 @@ export interface MergedEvent { export interface HeadRefDeletedEvent { __typename: string; id: string; - actor: { - login: string; - avatarUrl: string; - url: string; - }; + actor: Actor; createdAt: string; headRefName: string; } -export interface AbbreviatedIssueComment { - author: { - login: string; - avatarUrl: string; +export interface CrossReferencedEvent { + __typename: string; + id: string; + actor: Actor; + createdAt: string; + source?: { + __typename: string; + number: number; url: string; - email?: string + title: string; + repository: { + name: string; + owner: { + login: string; + }; + } }; + willCloseTarget: boolean; +} + +export interface ClosedEvent { + __typename: string; + id: string; + actor: Actor; + createdAt: string; +} + +export interface ReopenedEvent { + __typename: string; + id: string; + actor: Actor; + createdAt: string; +} + +export interface BaseRefChangedEvent { + __typename: string; + id: string; + actor: Actor; + createdAt: string; + currentRefName: string; + previousRefName: string; +} + +export interface AbbreviatedIssueComment { + author: Account; body: string; databaseId: number; + reactions: { + totalCount: number; + }; + reactionGroups: ReactionGroup[] + createdAt: string; } export interface IssueComment extends AbbreviatedIssueComment { @@ -64,29 +104,65 @@ export interface IssueComment extends AbbreviatedIssueComment { export interface ReactionGroup { content: string; viewerHasReacted: boolean; - users: { + reactors: { + nodes: { + login: string; + }[] totalCount: number; }; } -export interface Account { +export interface Node { + id: string; +} + +export interface Actor { + __typename: string; + id: string; login: string; avatarUrl: string; - name: string; url: string; +} + +export interface Account extends Actor { + name: string; email: string; } +export function isAccount(x: Actor | Team | Node | undefined | null): x is Account { + const asAccount = x as Partial; + return !!asAccount && (asAccount?.name !== undefined) && (asAccount?.email !== undefined); +} + +export function isTeam(x: Actor | Team | Node | undefined | null): x is Team { + const asTeam = x as Partial; + return !!asTeam && (asTeam?.slug !== undefined); +} + +export function isBot(x: Actor | Team | Node | undefined | null): x is Actor { + const asBot = x as Partial; + return !!asBot && !!asBot.id?.startsWith('BOT_'); +} + +export interface Team { + avatarUrl: string; + name: string; + url: string; + repositories: { + nodes: { + name: string + }[]; + }; + slug: string; + id: string; +} + export interface ReviewComment { __typename: string; id: string; databaseId: number; url: string; - author?: { - login: string; - avatarUrl: string; - url: string; - }; + author?: Actor | Account; path: string; originalPosition: number; body: string; @@ -117,11 +193,7 @@ export interface Commit { id: string; commit: { author: { - user: { - login: string; - avatarUrl: string; - url: string; - }; + user: Account; }; committer: { avatarUrl: string; @@ -129,7 +201,10 @@ export interface Commit { }; oid: string; message: string; - authoredDate: Date; + committedDate: Date; + statusCheckRollup?: { + state: 'EXPECTED' | 'ERROR' | 'FAILURE' | 'PENDING' | 'SUCCESS'; + }; }; url: string; @@ -138,16 +213,25 @@ export interface Commit { export interface AssignedEvent { __typename: string; id: number; - actor: { - login: string; - avatarUrl: string; - url: string; - }; - user: { - login: string; - avatarUrl: string; + actor: Actor; + user: Account; + createdAt: string; +} + +export interface UnassignedEvent { + __typename: string; + id: number; + actor: Actor; + user: Account; + createdAt: string; +} + +export interface MergeQueueEntry { + position: number; + state: MergeQueueState; + mergeQueue: { url: string; - }; + } } export interface Review { @@ -156,17 +240,14 @@ export interface Review { databaseId: number; authorAssociation: string; url: string; - author: { - login: string; - avatarUrl: string; - url: string; - }; + author: Actor | Account; state: 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED' | 'PENDING'; body: string; bodyHTML?: string; submittedAt: string; updatedAt: string; createdAt: string; + reactionGroups: ReactionGroup[]; } export interface ReviewThread { @@ -181,11 +262,12 @@ export interface ReviewThread { originalStartLine: number | null; originalLine: number; isOutdated: boolean; + subjectType?: SubjectType; comments: { nodes: ReviewComment[]; edges: [{ node: { - pullRequestReview: { + pullRequestReview?: { databaseId: number } } @@ -197,23 +279,67 @@ export interface TimelineEventsResponse { repository: { pullRequest: { timelineItems: { - nodes: (MergedEvent | Review | IssueComment | Commit | AssignedEvent | HeadRefDeletedEvent)[]; + nodes: (MergedEvent | Review | IssueComment | Commit | AssignedEvent | HeadRefDeletedEvent | BaseRefChangedEvent | null)[]; }; }; - }; + } | null; rateLimit: RateLimit; } +export interface LatestCommit { + commit: { + committedDate: string; + } +} + +export interface LatestReviewThread { + comments: { + nodes: { + createdAt: string; + }[]; + } +} + +export interface LatestUpdatesResponse { + repository: { + pullRequest: { + reactions: { + nodes: { + createdAt: string; + }[]; + } + updatedAt: string; + comments: { + nodes: { + updatedAt: string; + reactions: { + nodes: { + createdAt: string; + }[]; + } + }[]; + } + timelineItems: { + nodes: ({ + createdAt: string; + } | LatestCommit | LatestReviewThread | null)[]; + } + } + } +} + export interface LatestReviewCommitResponse { repository: { pullRequest: { - viewerLatestReview: { - commit: { - oid: string; - } + reviews: { + nodes: { + commit: { + oid: string; + } + }[]; }; }; - }; + } | null; } export interface PendingReviewIdResponse { @@ -225,6 +351,26 @@ export interface PendingReviewIdResponse { rateLimit: RateLimit; } +export interface GetReviewRequestsResponse { + repository: { + pullRequest: { + reviewRequests: { + nodes: { + requestedReviewer: Actor | Account | Team | Node | null; + }[]; + }; + }; + } | null; +} + +export interface AddReviewRequestResponse { + requestReviews: { + pullRequest: { + id: string; + }; + } | null; +} + export interface PullRequestState { repository: { pullRequest: { @@ -232,7 +378,15 @@ export interface PullRequestState { number: number; state: 'OPEN' | 'CLOSED' | 'MERGED'; }; - }; + } | null; +} + +export interface PullRequestTemplatesResponse { + repository: { + pullRequestTemplates: { + body: string; + }[] + } } export interface PullRequestCommentsResponse { @@ -240,21 +394,19 @@ export interface PullRequestCommentsResponse { pullRequest: { reviewThreads: { nodes: ReviewThread[]; + pageInfo: PageInfo; }; }; - }; + } | null; } export interface MentionableUsersResponse { repository: { mentionableUsers: { nodes: Account[]; - pageInfo: { - hasNextPage: boolean; - endCursor: string; - }; + pageInfo: PageInfo; }; - }; + } | null; rateLimit: RateLimit; } @@ -262,10 +414,36 @@ export interface AssignableUsersResponse { repository: { assignableUsers: { nodes: Account[]; - pageInfo: { - hasNextPage: boolean; - endCursor: string; - }; + pageInfo: PageInfo; + }; + } | null; + rateLimit: RateLimit; +} + +export interface SuggestedActorsResponse { + repository: { + suggestedActors: { + nodes: Actor[]; + pageInfo: PageInfo; + }; + } | null; + rateLimit: RateLimit; +} + +export interface OrganizationTeamsCountResponse { + organization: { + teams: { + totalCount: number; + }; + }; +} + +export interface OrganizationTeamsResponse { + organization: { + teams: { + nodes: Team[]; + totalCount: number; + pageInfo: PageInfo; }; }; rateLimit: RateLimit; @@ -278,7 +456,7 @@ export interface PullRequestParticipantsResponse { nodes: Account[]; }; }; - }; + } | null; } export interface CreatePullRequestResponse { @@ -287,6 +465,12 @@ export interface CreatePullRequestResponse { } } +export interface RevertPullRequestResponse { + revertPullRequest: { + revertPullRequest: PullRequest + } +} + export interface AddReviewThreadResponse { addPullRequestReviewThread: { thread: ReviewThread; @@ -323,10 +507,53 @@ export interface MarkPullRequestReadyForReviewResponse { markPullRequestReadyForReview: { pullRequest: { isDraft: boolean; + mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'; + mergeStateStatus: 'BEHIND' | 'BLOCKED' | 'CLEAN' | 'DIRTY' | 'HAS_HOOKS' | 'UNKNOWN' | 'UNSTABLE'; + viewerCanEnableAutoMerge: boolean; + viewerCanDisableAutoMerge: boolean; + }; + }; +} + +export interface ConvertPullRequestToDraftResponse { + convertPullRequestToDraft: { + pullRequest: { + isDraft: boolean; + mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'; + mergeStateStatus: 'BEHIND' | 'BLOCKED' | 'CLEAN' | 'DIRTY' | 'HAS_HOOKS' | 'UNKNOWN' | 'UNSTABLE'; }; }; } +export interface MergeQueueForBranchResponse { + repository: { + mergeQueue?: { + configuration?: { + mergeMethod: MergeMethod; + } + } + } +} + +export interface DequeuePullRequestResponse { + mergeQueueEntry: MergeQueueEntry; +} + +export interface EnqueuePullRequestResponse { + enqueuePullRequest: { + mergeQueueEntry: MergeQueueEntry; + } +} + +export interface UpdatePullRequestBranchResponse { + updatePullRequestBranch: { + pullRequest: { + id: string; + headRefOid: string; + } + } +} + export interface SubmittedReview extends Review { comments: { nodes: ReviewComment[]; @@ -372,37 +599,68 @@ export interface DeleteReactionResponse { }; } -export interface UpdatePullRequestResponse { - updatePullRequest: { - pullRequest: { +export interface UpdateIssueResponse { + updateIssue: { + issue: { body: string; bodyHTML: string; title: string; titleHTML: string; + milestone?: { + title: string; + dueOn?: string; + id: string; + createdAt: string; + number: number; + }; }; }; } +export interface AddPullRequestToProjectResponse { + addProjectV2ItemById: { + item: { + id: string; + }; + }; +} + +export interface GetBranchResponse { + repository: { + ref: { + target: { + oid: string; + } + } | null; + } | null; +} + export interface ListBranchesResponse { repository: { refs: { nodes: { name: string; }[]; - pageInfo: { - hasNextPage: boolean; - endCursor: string; - }; + pageInfo: PageInfo; }; - }; + } | null; } export interface RefRepository { + isInOrganization: boolean; owner: { login: string; }; url: string; } + +export interface BaseRefRepository extends RefRepository { + squashMergeCommitTitle?: DefaultCommitTitle; + squashMergeCommitMessage?: DefaultCommitMessage; + mergeCommitMessage?: DefaultCommitMessage; + mergeCommitTitle?: DefaultCommitTitle; +} + export interface Ref { name: string; repository: RefRepository; @@ -414,71 +672,55 @@ export interface Ref { export interface SuggestedReviewerResponse { isAuthor: boolean; isCommenter: boolean; - reviewer: { - login: string; - avatarUrl: string; - name: string; - url: string; - }; + reviewer: Actor | Account; } -export interface PullRequest { +export type MergeMethod = 'MERGE' | 'REBASE' | 'SQUASH'; +export type MergeQueueState = 'AWAITING_CHECKS' | 'LOCKED' | 'MERGEABLE' | 'QUEUED' | 'UNMERGEABLE'; + +export interface Issue { id: string; databaseId: number; number: number; url: string; - state: 'OPEN' | 'CLOSED' | 'MERGED'; + state: 'OPEN' | 'CLOSED' | 'MERGED'; // TODO: don't allow merged in an issue + stateReason?: 'REOPENED' | 'NOT_PLANNED' | 'COMPLETED' | 'DUPLICATE'; body: string; bodyHTML: string; title: string; titleHTML: string; assignees?: { - nodes: { - login: string; - url: string; - email: string; - avatarUrl: string; - }[]; - }; - author: { - login: string; - url: string; - avatarUrl: string; + nodes: Account[]; }; - comments?: { - nodes: AbbreviatedIssueComment[]; + author: Account; + comments: { + nodes?: AbbreviatedIssueComment[]; + totalCount: number; }; createdAt: string; updatedAt: string; - headRef?: Ref; - headRefName: string; - headRefOid: string; - headRepository?: RefRepository; - baseRef?: Ref; - baseRefName: string; - baseRefOid: string; - baseRepository: RefRepository; labels: { nodes: { name: string; color: string; }[]; }; - merged: boolean; - mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'; - mergeStateStatus: 'BEHIND' | 'BLOCKED' | 'CLEAN' | 'DIRTY' | 'HAS_HOOKS' | 'UNKNOWN' | 'UNSTABLE'; - autoMergeRequest?: { - mergeMethod: 'MERGE' | 'REBASE' | 'SQUASH' + viewerCanUpdate: boolean; + projectItems?: { + nodes: { + project: { + id: string; + title: string; + }, + id: string + }[]; }; - viewerCanEnableAutoMerge: boolean; - viewerCanDisableAutoMerge: boolean; - isDraft?: boolean; - suggestedReviewers: SuggestedReviewerResponse[]; milestone?: { title: string; dueOn?: string; id: string; createdAt: string; + number: number; }; repository?: { name: string; @@ -487,12 +729,71 @@ export interface PullRequest { }; url: string; }; + reactions: { + totalCount: number; + } + reactionGroups: ReactionGroup[]; +} + + +export interface PullRequest extends Issue { + commits: { + nodes: { + commit: { + message: string; + }; + }[]; + }; + headRef?: Ref; + headRefName: string; + headRefOid: string; + headRepository?: RefRepository; + baseRef?: Ref; + baseRefName: string; + baseRefOid: string; + baseRepository: BaseRefRepository; + merged: boolean; + mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'; + mergeQueueEntry?: MergeQueueEntry | null; + mergeStateStatus: 'BEHIND' | 'BLOCKED' | 'CLEAN' | 'DIRTY' | 'HAS_HOOKS' | 'UNKNOWN' | 'UNSTABLE'; + reviewThreads: { + totalCount: number; + } + autoMergeRequest?: { + mergeMethod: MergeMethod; + }; + viewerCanEnableAutoMerge: boolean; + viewerCanDisableAutoMerge: boolean; + isDraft?: boolean; + suggestedReviewers: SuggestedReviewerResponse[]; + additions?: number; + deletions?: number; +} + +export enum DefaultCommitTitle { + prTitle = 'PR_TITLE', + commitOrPrTitle = 'COMMIT_OR_PR_TITLE', + mergeMessage = 'MERGE_MESSAGE' +} + +export enum DefaultCommitMessage { + prBody = 'PR_BODY', + commitMessages = 'COMMIT_MESSAGES', + blank = 'BLANK', + prTitle = 'PR_TITLE' } export interface PullRequestResponse { repository: { pullRequest: PullRequest; - }; + } | null; + rateLimit: RateLimit; +} + +export interface IssueResponse { + repository: { + issue: PullRequest; + } | null; rateLimit: RateLimit; } @@ -501,18 +802,22 @@ export interface PullRequestMergabilityResponse { pullRequest: { mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'; mergeStateStatus: 'BEHIND' | 'BLOCKED' | 'CLEAN' | 'DIRTY' | 'HAS_HOOKS' | 'UNKNOWN' | 'UNSTABLE'; + mergeRequirements?: { + conditions: { + __typename: string | 'PullRequestMergeConflictStateCondition'; + result: 'PASSED' | 'FAILED'; + conflicts: string[]; + }[]; + } }; - }; + } | null; rateLimit: RateLimit; } export interface IssuesSearchResponse { search: { issueCount: number; - pageInfo: { - hasNextPage: boolean; - endCursor: string; - }; + pageInfo: PageInfo; edges: { node: PullRequest; }[]; @@ -520,6 +825,29 @@ export interface IssuesSearchResponse { rateLimit: RateLimit; } +export interface RepoProjectsResponse { + repository: { + projectsV2: { + nodes: { + title: string; + id: string; + }[]; + } + } | null; +} + +export interface OrgProjectsResponse { + organization: { + projectsV2: { + nodes: { + title: string; + id: string; + }[]; + pageInfo: PageInfo; + } + } +} + export interface MilestoneIssuesResponse { repository: { milestones: { @@ -528,18 +856,16 @@ export interface MilestoneIssuesResponse { createdAt: string; title: string; id: string; + number: number issues: { edges: { node: PullRequest; }[]; }; }[]; - pageInfo: { - hasNextPage: boolean; - endCursor: string; - }; + pageInfo: PageInfo; }; - }; + } | null; } export interface IssuesResponse { @@ -548,12 +874,9 @@ export interface IssuesResponse { edges: { node: PullRequest; }[]; - pageInfo: { - hasNextPage: boolean; - endCursor: string; - }; + pageInfo: PageInfo; }; - }; + } | null; } export interface PullRequestsResponse { @@ -561,7 +884,24 @@ export interface PullRequestsResponse { pullRequests: { nodes: PullRequest[] } - } + } | null; +} + +export interface PullRequestNumbersResponse { + repository: { + pullRequests: { + nodes: PullRequestNumberData[] + } + } | null; + rateLimit: RateLimit; +} + +export interface PullRequestNumberData { + number: number; + title: string; + author: { + login: string; + }; } export interface MaxIssueResponse { @@ -573,13 +913,13 @@ export interface MaxIssueResponse { }; }[]; }; - }; + } | null; } export interface ViewerPermissionResponse { repository: { viewerPermission: string; - }; + } | null; } export interface ForkDetailsResponse { @@ -619,6 +959,8 @@ export interface UserResponse { name: string; contributionsCollection: ContributionsCollection; url: string; + id: string; + __typename: string; }; } @@ -627,7 +969,7 @@ export interface FileContentResponse { object: { text: string | undefined; } - } + } | null; } export interface StartReviewResponse { @@ -639,17 +981,20 @@ export interface StartReviewResponse { } export interface StatusContext { + __typename: string; id: string; - state?: 'ERROR' | 'EXPECTED' | 'FAILURE' | 'PENDING' | 'SUCCESS'; - description?: string; + state: 'ERROR' | 'EXPECTED' | 'FAILURE' | 'PENDING' | 'SUCCESS'; + description: string | null; context: string; - targetUrl?: string; - avatarUrl?: string; + targetUrl: string | null; + avatarUrl: string | null; + isRequired: boolean; } export interface CheckRun { + __typename: string; id: string; - conclusion?: + conclusion: | 'ACTION_REQUIRED' | 'CANCELLED' | 'FAILURE' @@ -657,51 +1002,71 @@ export interface CheckRun { | 'SKIPPED' | 'STALE' | 'SUCCESS' - | 'TIMED_OUT'; + | 'TIMED_OUT' + | null; name: string; - title?: string; - detailsUrl?: string; + title: string | null; + detailsUrl: string | null; checkSuite?: { - app?: { + app: { logoUrl: string; url: string; + } | null; + workflowRun?: { + event: string; + workflow: { + name: string; + }; }; }; + isRequired: boolean; } export function isCheckRun(x: CheckRun | StatusContext): x is CheckRun { - return (x as any).__typename === 'CheckRun'; + return x.__typename === 'CheckRun'; +} + +export interface ChecksReviewNode { + authorAssociation: 'MEMBER' | 'OWNER' | 'MANNEQUIN' | 'COLLABORATOR' | 'CONTRIBUTOR' | 'FIRST_TIME_CONTRIBUTOR' | 'FIRST_TIMER' | 'NONE'; + authorCanPushToRepository: boolean + state: 'PENDING' | 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED' | 'DISMISSED'; + author: { + login: string; + } } export interface GetChecksResponse { repository: { pullRequest: { + url: string; + latestReviews: { + nodes: ChecksReviewNode[]; + }; + reviewsRequestingChanges: { + nodes: ChecksReviewNode[]; + }; + baseRef: { + refUpdateRule: { + requiredApprovingReviewCount: number | null; + requiredStatusCheckContexts: string[] | null; + requiresCodeOwnerReviews: boolean; + viewerCanPush: boolean; + } | null; + } | null; commits: { nodes: { commit: { statusCheckRollup?: { - state: string; + state: 'EXPECTED' | 'ERROR' | 'FAILURE' | 'PENDING' | 'SUCCESS'; contexts: { nodes: (StatusContext | CheckRun)[]; }; }; }; - }[]; + }[] | undefined; }; }; - }; -} - -export interface LatestReviewsResponse { - repository: { - pullRequest: { - latestReviews: { - nodes: { - state: 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED' | 'PENDING'; - }[] - } - } - } + } | null; } export interface ResolveReviewThreadResponse { @@ -730,5 +1095,24 @@ export interface PullRequestFilesResponse { }; } } + } | null; +} + +export interface MergePullRequestInput { + pullRequestId: string; + mergeMethod: MergeMethod; + authorEmail?: string; + commitBody?: string; + commitHeadline?: string; + expectedHeadOid?: string; +} + +export interface MergePullRequestResponse { + mergePullRequest: { + pullRequest: PullRequest & { + timelineItems: { + nodes: (MergedEvent | Review | IssueComment | Commit | AssignedEvent | HeadRefDeletedEvent | BaseRefChangedEvent)[] + } + }; } -} \ No newline at end of file +} diff --git a/src/github/interface.ts b/src/github/interface.ts index 07ae798906..d09b47acdf 100644 --- a/src/github/interface.ts +++ b/src/github/interface.ts @@ -3,22 +3,24 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { ReviewStateValue } from '../common/timelineEvent'; + export enum PRType { Query, All, LocalPullRequest, } -export enum ReviewEvent { +export enum ReviewEventEnum { Approve = 'APPROVE', RequestChanges = 'REQUEST_CHANGES', Comment = 'COMMENT', } export enum GithubItemStateEnum { - Open, - Merged, - Closed, + Open = 'OPEN', + Merged = 'MERGED', + Closed = 'CLOSED', } export enum PullRequestMergeability { @@ -26,19 +28,97 @@ export enum PullRequestMergeability { NotMergeable, Conflict, Unknown, + Behind, +} + +export enum MergeQueueState { + AwaitingChecks, + Locked, + Mergeable, + Queued, + Unmergeable } export interface ReviewState { - reviewer: IAccount; - state: string; + reviewer: IAccount | ITeam; + state: ReviewStateValue; } -export interface IAccount { +export interface ReadyForReview { + isDraft: boolean; + mergeable: PullRequestMergeability; + allowAutoMerge: boolean; +} + +export interface ConvertToDraft { + isDraft: boolean; + mergeable: PullRequestMergeability; +} + +export interface IActor { login: string; + avatarUrl?: string; + url: string; +} + +export enum AccountType { + User = 'User', + Organization = 'Organization', + Mannequin = 'Mannequin', + Bot = 'Bot' +} + +export function toAccountType(type: string): AccountType { + switch (type) { + case 'Organization': + return AccountType.Organization; + case 'Mannequin': + return AccountType.Mannequin; + case 'Bot': + return AccountType.Bot; + default: + return AccountType.User; + } +} + +export interface IAccount extends IActor { + login: string; + id: string; name?: string; avatarUrl?: string; url: string; email?: string; + specialDisplayName?: string; + accountType: AccountType; +} + +export interface ITeam { + name?: string; + avatarUrl?: string; + url: string; + slug?: string; + org: string; + id: string; +} + +export interface MergeQueueEntry { + position: number; + state: MergeQueueState; + url: string; +} + +export function reviewerId(reviewer: ITeam | IAccount): string { + // We can literally get different login values for copilot depending on where it's coming from (already assignee vs suggested assingee) + return isITeam(reviewer) ? reviewer.id : (reviewer.specialDisplayName ?? reviewer.login); +} + +export function reviewerLabel(reviewer: ITeam | IAccount | IActor | any): string { + return isITeam(reviewer) ? (reviewer.name ?? reviewer.slug ?? reviewer.id) : (reviewer.specialDisplayName ?? reviewer.login); +} + +export function isITeam(reviewer: ITeam | IAccount | IActor | any): reviewer is ITeam { + const asITeam = reviewer as Partial; + return !!asITeam.org; } export interface ISuggestedReviewer extends IAccount { @@ -46,11 +126,29 @@ export interface ISuggestedReviewer extends IAccount { isCommenter: boolean; } +export function isISuggestedReviewer( + reviewer: IAccount | ISuggestedReviewer | ITeam +): reviewer is ISuggestedReviewer { + const asISuggestedReviewer = reviewer as Partial; + return !!asISuggestedReviewer.isAuthor && !!asISuggestedReviewer.isCommenter; +} + +export interface IProject { + title: string; + id: string; +} + +export interface IProjectItem { + id: string; + project: IProject; +} + export interface IMilestone { title: string; dueOn?: string | null; createdAt: string; id: string; + number: number; } export interface MergePullRequest { @@ -62,6 +160,7 @@ export interface MergePullRequest { export interface IRepository { cloneUrl: string; + isInOrganization: boolean; owner: string; name: string; } @@ -76,14 +175,34 @@ export interface IGitHubRef { export interface ILabel { name: string; color: string; + description?: string; +} + +export interface IIssueComment { + author: IAccount; + body: string; + databaseId: number; + reactionCount: number; + createdAt: string; } +export interface Reaction { + label: string; + count: number; + icon?: string; + viewerHasReacted: boolean; + reactors: readonly string[]; +} + +export type StateReason = 'REOPENED' | 'NOT_PLANNED' | 'COMPLETED' | 'DUPLICATE'; + export interface Issue { id: number; graphNodeId: string; url: string; number: number; state: string; + stateReason?: StateReason; body: string; bodyHTML?: string; title: string; @@ -93,15 +212,15 @@ export interface Issue { updatedAt: string; user: IAccount; labels: ILabel[]; + projectItems?: IProjectItem[]; milestone?: IMilestone; repositoryOwner?: string; repositoryName?: string; repositoryUrl?: string; - comments?: { - author: IAccount; - body: string; - databaseId: number; - }[]; + comments?: IIssueComment[]; + commentCount: number; + reactionCount: number; + reactions: Reaction[]; } export interface PullRequest extends Issue { @@ -110,24 +229,79 @@ export interface PullRequest extends Issue { head?: IGitHubRef; isRemoteBaseDeleted?: boolean; base?: IGitHubRef; + commits: { + message: string; + }[]; merged?: boolean; mergeable?: PullRequestMergeability; + mergeQueueEntry?: MergeQueueEntry | null; + viewerCanUpdate: boolean; autoMerge?: boolean; autoMergeMethod?: MergeMethod; allowAutoMerge?: boolean; + mergeCommitMeta?: { title: string, description: string }; + squashCommitMeta?: { title: string, description: string }; suggestedReviewers?: ISuggestedReviewer[]; + hasComments?: boolean; + additions?: number; + deletions?: number; +} + +export enum NotificationSubjectType { + Issue = 'Issue', + PullRequest = 'PullRequest' +} + +export interface Notification { + owner: string; + name: string; + key: string; + id: string, + itemID: string; + subject: { + title: string; + type: NotificationSubjectType; + url: string; + }; + reason: string; + unread: boolean; + updatedAt: Date; + lastReadAt: Date | undefined; } export interface IRawFileChange { + sha: string; filename: string; - previous_filename?: string; + previous_filename?: string | undefined; additions: number; deletions: number; changes: number; - status: string; + status: 'added' | 'removed' | 'modified' | 'renamed' | 'copied' | 'changed' | 'unchanged'; raw_url: string; blob_url: string; - patch: string; + contents_url: string; + patch?: string | undefined; +} + +export interface IRawFileContent { + type: string; + size: number; + name: string; + path: string; + content?: string | undefined; + sha: string; + url: string; + git_url: string | null; + html_url: string | null; + download_url: string | null; +} + +export interface IGitTreeItem { + path: string; + mode: '100644' | '100755' | '120000'; + // Must contain a content or a sha. + content?: string; + sha?: string | null; } export interface IPullRequestsPagingOptions { @@ -135,7 +309,7 @@ export interface IPullRequestsPagingOptions { fetchOnePagePerRepo?: boolean; } -export interface IPullRequestEditData { +export interface IIssueEditData { body?: string; title?: string; } @@ -170,15 +344,27 @@ export enum CheckState { Unknown = 'unknown' } +export interface PullRequestCheckStatus { + id: string; + url: string | undefined; + avatarUrl: string | undefined; + state: CheckState; + description: string | null; + targetUrl: string | null; + context: string; // Job name + workflowName: string | undefined; + event: string | undefined; + isRequired: boolean; +} + export interface PullRequestChecks { state: CheckState; - statuses: { - id: string; - url?: string; - avatar_url?: string; - state?: CheckState; - description?: string; - target_url?: string; - context: string; - }[]; + statuses: PullRequestCheckStatus[]; +} + +export interface PullRequestReviewRequirement { + count: number; + state: CheckState; + approvals: string[]; + requestedChanges: string[]; } diff --git a/src/github/issueModel.ts b/src/github/issueModel.ts index 14eea6eec3..b6595f1ad0 100644 --- a/src/github/issueModel.ts +++ b/src/github/issueModel.ts @@ -4,23 +4,47 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { IComment } from '../common/comment'; -import Logger from '../common/logger'; -import { Remote } from '../common/remote'; -import { TimelineEvent } from '../common/timelineEvent'; -import { formatError } from '../common/utils'; import { OctokitCommon } from './common'; -import { GitHubRepository } from './githubRepository'; +import { CopilotWorkingStatus, GitHubRepository } from './githubRepository'; import { AddIssueCommentResponse, + AddPullRequestToProjectResponse, EditIssueCommentResponse, + LatestCommit, + LatestReviewThread, + LatestUpdatesResponse, TimelineEventsResponse, - UpdatePullRequestResponse, + UpdateIssueResponse, } from './graphql'; -import { GithubItemStateEnum, IAccount, IMilestone, IPullRequestEditData, Issue } from './interface'; -import { parseGraphQlIssueComment, parseGraphQLTimelineEvents } from './utils'; +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; + body?: true; + milestone?: true; + // updatedAt?: true; + state?: true; + labels?: true; + assignees?: true; + projects?: true; + comments?: true; + + timeline?: true; + + draft?: true; + reviewers?: true; + base?: true; +} -export class IssueModel { +export class IssueModel extends Disposable { static ID = 'IssueModel'; public id: number; public graphNodeId: string; @@ -29,20 +53,30 @@ export class IssueModel { public titleHTML: string; public html_url: string; public state: GithubItemStateEnum = GithubItemStateEnum.Open; + public stateReason?: StateReason; public author: IAccount; public assignees?: IAccount[]; public createdAt: string; public updatedAt: string; public milestone?: IMilestone; public readonly githubRepository: GitHubRepository; + protected readonly _telemetry: ITelemetry; public readonly remote: Remote; public item: TItem; + public body: string; public bodyHTML?: string; - private _onDidInvalidate = new vscode.EventEmitter(); - public onDidInvalidate = this._onDidInvalidate.event; + private _lastCheckedForUpdatesAt?: Date; - constructor(githubRepository: GitHubRepository, remote: Remote, item: TItem, skipUpdate: boolean = false) { + private _timelineEvents: readonly TimelineEvent[] | undefined; + private _copilotTimelineEvents: TimelineEvent[] | undefined; + + protected _onDidChange = this._register(new vscode.EventEmitter()); + public onDidChange = this._onDidChange.event; + + constructor(telemetry: ITelemetry, githubRepository: GitHubRepository, remote: Remote, item: TItem, skipUpdate: boolean = false) { + super(); + this._telemetry = telemetry; this.githubRepository = githubRepository; this.remote = remote; this.item = item; @@ -52,9 +86,19 @@ export class IssueModel { } } - public invalidate() { - // Something about the PR data is stale - this._onDidInvalidate.fire(); + get timelineEvents(): readonly TimelineEvent[] | undefined { + return this._timelineEvents; + } + + protected set timelineEvents(timelineEvents: readonly TimelineEvent[]) { + if (!this._timelineEvents || this._timelineEvents.length !== timelineEvents.length) { + this._timelineEvents = timelineEvents; + this._onDidChange.fire({ timeline: true }); + } + } + + public get lastCheckedForUpdatesAt(): Date | undefined { + return this._lastCheckedForUpdatesAt; } public get isOpen(): boolean { @@ -96,45 +140,67 @@ export class IssueModel { return undefined; } - public get body(): string { - if (this.item) { - return this.item.body; - } - return ''; - } - - protected updateState(state: string) { + protected stateToStateEnum(state: string): GithubItemStateEnum { + let newState = GithubItemStateEnum.Closed; if (state.toLowerCase() === 'open') { - this.state = GithubItemStateEnum.Open; - } else { - this.state = GithubItemStateEnum.Closed; + newState = GithubItemStateEnum.Open; } + return newState; } - update(issue: TItem): void { + protected doUpdate(issue: TItem): IssueChangeEvent { + const changes: IssueChangeEvent = {}; + this.id = issue.id; this.graphNodeId = issue.graphNodeId; this.number = issue.number; - this.title = issue.title; - if (issue.titleHTML) { - this.titleHTML = issue.titleHTML; - } - if (!this.bodyHTML || (issue.body !== this.body)) { - this.bodyHTML = issue.bodyHTML; - } this.html_url = issue.url; this.author = issue.user; - this.milestone = issue.milestone; this.createdAt = issue.createdAt; - this.updatedAt = issue.updatedAt; - - this.updateState(issue.state); - if (issue.assignees) { + if (this.title !== issue.title) { + changes.title = true; + this.title = issue.title; + } + if (issue.titleHTML && this.titleHTML !== issue.titleHTML) { + this.titleHTML = issue.titleHTML; + } + if ((!this.bodyHTML || (issue.body !== this.body)) && this.bodyHTML !== issue.bodyHTML) { + this.bodyHTML = issue.bodyHTML; + } + if (this.body !== issue.body) { + changes.body = true; + this.body = issue.body; + } + if (this.milestone?.id !== issue.milestone?.id) { + changes.milestone = true; + this.milestone = issue.milestone; + } + if (this.updatedAt !== issue.updatedAt) { + this.updatedAt = issue.updatedAt; + } + const newState = this.stateToStateEnum(issue.state); + if (this.state !== newState) { + changes.state = true; + this.state = newState; + } + if ((this.stateReason !== issue.stateReason) && issue.stateReason) { + changes.state = true; + this.stateReason = issue.stateReason; + } + if (issue.assignees && (issue.assignees.length !== (this.assignees?.length ?? 0) || issue.assignees.some(assignee => this.assignees?.every(a => a.id !== assignee.id)))) { + changes.assignees = true; this.assignees = issue.assignees; } + return changes; + } + update(issue: TItem): void { + const changes = this.doUpdate(issue); this.item = issue; + if (Object.keys(changes).length > 0) { + this._onDidChange.fire(changes); + } } equals(other: IssueModel | undefined): boolean { @@ -153,28 +219,45 @@ export class IssueModel { return true; } - async edit(toEdit: IPullRequestEditData): Promise<{ body: string; bodyHTML: string; title: string; titleHTML: string }> { + protected updateIssueInput(id: string): Object { + return { + id + }; + } + + protected updateIssueSchema(schema: any): any { + return schema.UpdateIssue; + } + + async edit(toEdit: IIssueEditData): Promise<{ body: string; bodyHTML: string; title: string; titleHTML: string }> { try { const { mutate, schema } = await this.githubRepository.ensure(); - const { data } = await mutate({ - mutation: schema.UpdatePullRequest, + const { data } = await mutate({ + mutation: this.updateIssueSchema(schema), variables: { input: { - pullRequestId: this.graphNodeId, + ...this.updateIssueInput(this.graphNodeId), body: toEdit.body, title: toEdit.title, }, }, }); - if (data?.updatePullRequest.pullRequest) { - this.item.body = data.updatePullRequest.pullRequest.body; - this.bodyHTML = data.updatePullRequest.pullRequest.bodyHTML; - this.title = data.updatePullRequest.pullRequest.title; - this.titleHTML = data.updatePullRequest.pullRequest.titleHTML; - this.invalidate(); + if (data?.updateIssue.issue) { + const changes: IssueChangeEvent = {}; + if (this.body !== data.updateIssue.issue.body) { + changes.body = true; + this.item.body = data.updateIssue.issue.body; + this.bodyHTML = data.updateIssue.issue.bodyHTML; + } + if (this.title !== data.updateIssue.issue.title) { + changes.title = true; + this.title = data.updateIssue.issue.title; + this.titleHTML = data.updateIssue.issue.titleHTML; + } + this._onDidChange.fire(changes); } - return data!.updatePullRequest.pullRequest; + return data!.updateIssue.issue; } catch (e) { throw new Error(formatError(e)); } @@ -182,22 +265,7 @@ export class IssueModel { canEdit(): Promise { const username = this.author && this.author.login; - return this.githubRepository.isCurrentUser(username); - } - - async getIssueComments(): Promise { - Logger.debug(`Fetch issue comments of PR #${this.number} - enter`, IssueModel.ID); - const { octokit, remote } = await this.githubRepository.ensure(); - - const promise = await octokit.call(octokit.api.issues.listComments, { - owner: remote.owner, - repo: remote.repositoryName, - issue_number: this.number, - per_page: 100, - }); - Logger.debug(`Fetch issue comments of PR #${this.number} - done`, IssueModel.ID); - - return promise.data; + return this.githubRepository.isCurrentUser(this.remote.authProviderId, username); } async createIssueComment(text: string): Promise { @@ -212,6 +280,7 @@ export class IssueModel { }, }); + this._onDidChange.fire({ timeline: true }); return parseGraphQlIssueComment(data!.addComment.commentEdge.node, this.githubRepository); } @@ -229,6 +298,7 @@ export class IssueModel { }, }); + this._onDidChange.fire({ timeline: true }); return parseGraphQlIssueComment(data!.updateIssueComment.issueComment, this.githubRepository); } catch (e) { throw new Error(formatError(e)); @@ -244,6 +314,7 @@ export class IssueModel { repo: remote.repositoryName, comment_id: Number(commentId), }); + this._onDidChange.fire({ timeline: true }); } catch (e) { throw new Error(formatError(e)); } @@ -252,12 +323,18 @@ export class IssueModel { async setLabels(labels: string[]): Promise { const { octokit, remote } = await this.githubRepository.ensure(); try { - await octokit.call(octokit.api.issues.setLabels, { + const result = await octokit.call(octokit.api.issues.setLabels, { owner: remote.owner, repo: remote.repositoryName, issue_number: this.number, labels, }); + this.item.labels = result.data.map(label => ({ + name: label.name, + color: label.color, + description: label.description ?? undefined + })); + this._onDidChange.fire({ labels: true }); } catch (e) { // We don't get a nice error message from the API when setting labels fails. // Since adding labels isn't a critical part of the PR creation path it's safe to catch all errors that come from setting labels. @@ -268,18 +345,134 @@ export class IssueModel { async removeLabel(label: string): Promise { const { octokit, remote } = await this.githubRepository.ensure(); - await octokit.call(octokit.api.issues.removeLabel, { + const result = await octokit.call(octokit.api.issues.removeLabel, { owner: remote.owner, repo: remote.repositoryName, issue_number: this.number, name: label, }); + this.item.labels = result.data.map(label => ({ + name: label.name, + color: label.color, + description: label.description ?? undefined + })); + this._onDidChange.fire({ labels: true }); } - async getIssueTimelineEvents(): Promise { + public async removeProjects(projectItems: IProjectItem[]): Promise { + const result = await this.doRemoveProjects(projectItems); + if (!result) { + // If we failed to remove the projects, we don't want to update the model. + return; + } + this._onDidChange.fire({ projects: true }); + } + + private async doRemoveProjects(projectItems: IProjectItem[]): Promise { + const { mutate, schema } = await this.githubRepository.ensure(); + + try { + await Promise.all(projectItems.map(project => + mutate({ + mutation: schema.RemovePullRequestFromProject, + variables: { + input: { + itemId: project.id, + projectId: project.project.id + }, + }, + }))); + this.item.projectItems = this.item.projectItems?.filter(project => !projectItems.find(p => p.project.id === project.project.id)); + return true; + } catch (err) { + Logger.error(err, IssueModel.ID); + return false; + } + } + + private async addProjects(projects: IProject[]): Promise { + const { mutate, schema } = await this.githubRepository.ensure(); + + try { + const itemIds = await Promise.all(projects.map(project => + mutate({ + mutation: schema.AddPullRequestToProject, + variables: { + input: { + contentId: this.item.graphNodeId, + projectId: project.id + }, + }, + }))); + if (!this.item.projectItems) { + this.item.projectItems = []; + } + this.item.projectItems.push(...projects.map((project, index) => { return { project, id: itemIds[index].data!.addProjectV2ItemById.item.id }; })); + } catch (err) { + Logger.error(err, IssueModel.ID); + } + } + + async updateProjects(projects: IProject[]): Promise { + const projectsToAdd: IProject[] = projects.filter(project => !this.item.projectItems?.find(p => p.project.id === project.id)); + const projectsToRemove: IProjectItem[] = this.item.projectItems?.filter(project => !projects.find(p => p.id === project.project.id)) ?? []; + await this.removeProjects(projectsToRemove); + await this.addProjects(projectsToAdd); + this._onDidChange.fire({ projects: true }); + return this.item.projectItems; + } + + protected getUpdatesQuery(schema: any): any { + return schema.LatestIssueUpdates; + } + + 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 { + const { data } = await query({ + query: this.getUpdatesQuery(schema), + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: this.number, + since: new Date(time), + } + }); + + const times = [ + time, + new Date(data.repository.pullRequest.updatedAt), + ...(data.repository.pullRequest.reactions.nodes.map(node => new Date(node.createdAt))), + ...(data.repository.pullRequest.comments.nodes.map(node => new Date(node.updatedAt))), + ...(data.repository.pullRequest.comments.nodes.flatMap(node => node.reactions.nodes.map(reaction => new Date(reaction.createdAt)))), + ...(data.repository.pullRequest.timelineItems.nodes.map(node => { + const latestCommit = node as (Partial | null); + if (latestCommit?.commit?.committedDate) { + return new Date(latestCommit.commit.committedDate); + } + const latestReviewThread = node as (Partial | null); + if ((latestReviewThread?.comments?.nodes.length ?? 0) > 0) { + return new Date(latestReviewThread!.comments!.nodes[0].createdAt); + } + return new Date((node as { createdAt: string }).createdAt); + })) + ]; + + // Sort times and return the most recent one + return new Date(Math.max(...times.map(t => t.getTime()))); + } catch (e) { + Logger.error(`Error fetching timeline events of issue #${this.number} - ${formatError(e)}`, IssueModel.ID); + return time; // Return the original time in case of an error + } + } + + async getIssueTimelineEvents(): Promise { + Logger.debug(`Fetch timeline events of issue #${this.number} - enter`, GitHubRepository.ID); + const { query, remote, schema } = await this.githubRepository.ensure(); try { const { data } = await query({ @@ -290,9 +483,29 @@ export class IssueModel { number: this.number, }, }); + + if (data.repository === null) { + Logger.error('Unexpected null repository when getting issue timeline events', GitHubRepository.ID); + return []; + } + const ret = data.repository.pullRequest.timelineItems.nodes; - const events = parseGraphQLTimelineEvents(ret, githubRepository); + const events = await parseCombinedTimelineEvents(ret, await this.getCopilotTimelineEvents(true), this.githubRepository); + + const crossRefs = events.filter((event): event is CrossReferencedEvent => { + if ((event.event === EventType.CrossReferenced) && !event.source.isIssue) { + return !this.githubRepository.getExistingPullRequestModel(event.source.number) && (compareIgnoreCase(event.source.owner, this.remote.owner) === 0 && compareIgnoreCase(event.source.repo, this.remote.repositoryName) === 0); + } + return false; + }); + + for (const unseenPrs of crossRefs) { + // Kick off getting the new PRs so that the system knows about them (and refreshes the tree when they're found) + this.githubRepository.getPullRequest(unseenPrs.source.number); + } + + this.timelineEvents = events; return events; } catch (e) { console.log(e); @@ -300,5 +513,174 @@ export class IssueModel { } } + /** + * TODO: @alexr00 we should delete this https://github.com/microsoft/vscode-pull-request-github/issues/6965 + */ + async getCopilotTimelineEvents(skipMerge: boolean = false, useCache: boolean = false): Promise { + if (!COPILOT_ACCOUNTS[this.author.login]) { + return []; + } + + Logger.debug(`Fetch Copilot timeline events of issue #${this.number} - enter`, GitHubRepository.ID); + + if (useCache && this._copilotTimelineEvents) { + Logger.debug(`Fetch Copilot timeline events of issue #${this.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, { + issue_number: this.number, + owner: remote.owner, + repo: remote.repositoryName, + per_page: 100 + }); + + const timelineEvents = parseSelectRestTimelineEvents(this, timeline); + this._copilotTimelineEvents = timelineEvents; + if (timelineEvents.length === 0) { + return []; + } + if (!skipMerge) { + const oldLastEvent = this.timelineEvents ? (this.timelineEvents.length > 0 ? this.timelineEvents[this.timelineEvents.length - 1] : undefined) : undefined; + let allEvents: TimelineEvent[]; + if (!oldLastEvent) { + allEvents = timelineEvents; + } else { + const oldEventTime = (eventTime(oldLastEvent) ?? 0); + const newEvents = timelineEvents.filter(event => (eventTime(event) ?? 0) > oldEventTime); + allEvents = [...(this.timelineEvents ?? []), ...newEvents]; + } + this.timelineEvents = allEvents; + } + Logger.debug(`Fetch Copilot timeline events of issue #${this.number} - exit`, GitHubRepository.ID); + return timelineEvents; + } catch (e) { + Logger.error(`Error fetching Copilot timeline events of issue #${this.number} - ${formatError(e)}`, GitHubRepository.ID); + return []; + } + } + + async copilotWorkingStatus(): Promise { + const copilotEvents = await this.getCopilotTimelineEvents(); + if (copilotEvents.length > 0) { + const lastEvent = copilotEvents[copilotEvents.length - 1]; + if (lastEvent.event === EventType.CopilotFinished) { + return CopilotWorkingStatus.Done; + } else if (lastEvent.event === EventType.CopilotStarted) { + return CopilotWorkingStatus.InProgress; + } else if (lastEvent.event === EventType.CopilotFinishedError) { + return CopilotWorkingStatus.Error; + } + } + return CopilotWorkingStatus.NotCopilotIssue; + } + + async updateMilestone(id: string): Promise { + const { mutate, schema } = await this.githubRepository.ensure(); + const finalId = id === 'null' ? null : id; + try { + const result = await mutate({ + mutation: this.updateIssueSchema(schema), + variables: { + input: { + ...this.updateIssueInput(this.graphNodeId), + milestoneId: finalId, + }, + }, + }); + this.milestone = parseMilestone(result.data!.updateIssue.issue.milestone); + this._onDidChange.fire({ milestone: true }); + } catch (err) { + Logger.error(err, IssueModel.ID); + } + } + + async replaceAssignees(allAssignees: IAccount[]): Promise { + Logger.debug(`Replace assignees of issue #${this.number} - enter`, IssueModel.ID); + const { mutate, schema } = await this.githubRepository.ensure(); + + try { + if (schema.ReplaceActorsForAssignable) { + const assignToCopilot = allAssignees.find(assignee => COPILOT_ACCOUNTS[assignee.login]); + const alreadyHasCopilot = this.assignees?.find(assignee => COPILOT_ACCOUNTS[assignee.login]) !== undefined; + if (assignToCopilot && !alreadyHasCopilot) { + /* __GDPR__ + "pr.assignCopilot" : {} + */ + this._telemetry.sendTelemetryEvent('pr.assignCopilot'); + } + + const assigneeIds = allAssignees.map(assignee => assignee.id); + await mutate({ + mutation: schema.ReplaceActorsForAssignable, + variables: { + input: { + actorIds: assigneeIds, + assignableId: this.graphNodeId + } + } + }); + } else { + const addAssignees = allAssignees.map(assignee => assignee.login); + const removeAssignees = (this.assignees?.filter(currentAssignee => !allAssignees.find(newAssignee => newAssignee.login === currentAssignee.login)) ?? []).map(assignee => assignee.login); + await this.addAssignees(addAssignees); + await this.deleteAssignees(removeAssignees); + } + this.assignees = allAssignees; + this._onDidChange.fire({ assignees: true }); + } catch (e) { + Logger.error(e, IssueModel.ID); + } + Logger.debug(`Replace assignees of issue #${this.number} - done`, IssueModel.ID); + } + + private async addAssignees(assigneesToAdd: string[]): Promise { + const { octokit, remote } = await this.githubRepository.ensure(); + await octokit.call(octokit.api.issues.addAssignees, { + owner: remote.owner, + repo: remote.repositoryName, + issue_number: this.number, + assignees: assigneesToAdd, + }); + } + + private async deleteAssignees(assignees: string[]): Promise { + const { octokit, remote } = await this.githubRepository.ensure(); + await octokit.call(octokit.api.issues.removeAssignees, { + owner: remote.owner, + repo: remote.repositoryName, + issue_number: this.number, + assignees, + }); + } + + async close(): Promise<{ item: Issue, closedEvent: ClosedEvent }> { + const { octokit, remote } = await this.githubRepository.ensure(); + const ret = await octokit.call(octokit.api.issues.update, { + owner: remote.owner, + repo: remote.repositoryName, + issue_number: this.number, + state: 'closed' + }); + + this.state = GithubItemStateEnum.Closed; + this._onDidChange.fire({ state: true }); + return { + item: convertRESTIssueToRawPullRequest(ret.data, this.githubRepository), + closedEvent: { + createdAt: ret.data.closed_at ?? '', + event: EventType.Closed, + id: `${ret.data.id}`, + actor: { + login: ret.data.closed_by!.login, + avatarUrl: ret.data.closed_by!.avatar_url, + url: ret.data.closed_by!.url + } + } + }; + } } diff --git a/src/github/issueOverview.ts b/src/github/issueOverview.ts index e9ddc1a0e7..a734d9c3e4 100644 --- a/src/github/issueOverview.ts +++ b/src/github/issueOverview.ts @@ -5,38 +5,50 @@ 'use strict'; import * as vscode from 'vscode'; -import { IComment } from '../common/comment'; -import Logger from '../common/logger'; -import { asPromise, formatError } from '../common/utils'; -import { getNonce, IRequestMessage, WebviewBase } from '../common/webview'; -import { DescriptionNode } from '../view/treeNodes/descriptionNode'; -import { OctokitCommon } from './common'; +import { CloseResult } from '../../common/views'; +import { openPullRequestOnGitHub } from '../commands'; import { FolderRepositoryManager } from './folderRepositoryManager'; -import { ILabel } from './interface'; +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, UnresolvedIdentity } from './views'; +import { COPILOT_ACCOUNTS, IComment } from '../common/comment'; +import { emojify, ensureEmojis } from '../common/emoji'; +import Logger from '../common/logger'; +import { PR_SETTINGS_NAMESPACE, WEBVIEW_REFRESH_INTERVAL } from '../common/settingKeys'; +import { ITelemetry } from '../common/telemetry'; +import { CommentEvent, EventType, ReviewStateValue, TimelineEvent } from '../common/timelineEvent'; +import { asPromise, formatError } from '../common/utils'; +import { generateUuid } from '../common/uuid'; +import { IRequestMessage, WebviewBase } from '../common/webview'; export class IssueOverviewPanel extends WebviewBase { - public static ID: string = 'PullRequestOverviewPanel'; + public static ID: string = 'IssueOverviewPanel'; /** * Track the currently panel. Only allow a single panel to exist at a time. */ public static currentPanel?: IssueOverviewPanel; - private static readonly _viewType: string = 'IssueOverview'; + public static readonly viewType: string = 'IssueOverview'; protected readonly _panel: vscode.WebviewPanel; - protected _disposables: vscode.Disposable[] = []; - protected _descriptionNode: DescriptionNode; protected _item: TItem; + protected _identity: UnresolvedIdentity; protected _folderRepositoryManager: FolderRepositoryManager; protected _scrollPosition = { x: 0, y: 0 }; public static async createOrShow( + telemetry: ITelemetry, extensionUri: vscode.Uri, folderRepositoryManager: FolderRepositoryManager, - issue: IssueModel, + identity: UnresolvedIdentity, + issue?: IssueModel, toTheSide: Boolean = false, + _preserveFocus: boolean = true, + existingPanel?: vscode.WebviewPanel ) { + await ensureEmojis(folderRepositoryManager.context); const activeColumn = toTheSide ? vscode.ViewColumn.Beside : vscode.window.activeTextEditor @@ -48,16 +60,20 @@ export class IssueOverviewPanel extends W if (IssueOverviewPanel.currentPanel) { IssueOverviewPanel.currentPanel._panel.reveal(activeColumn, true); } else { - const title = `Issue #${issue.number.toString()}`; + const title = `Issue #${identity.number.toString()}`; IssueOverviewPanel.currentPanel = new IssueOverviewPanel( + telemetry, extensionUri, activeColumn || vscode.ViewColumn.Active, title, folderRepositoryManager, + undefined, + existingPanel, + undefined ); } - await IssueOverviewPanel.currentPanel!.update(folderRepositoryManager, issue); + await IssueOverviewPanel.currentPanel!.updateWithIdentity(folderRepositoryManager, identity, issue); } public static refresh(): void { @@ -66,34 +82,57 @@ export class IssueOverviewPanel extends W } } + protected setPanelTitle(title: string): void { + try { + this._panel.title = title; + } catch (e) { + // The webview can be disposed at the time that we try to set the title if the user has closed + // it while it's still loading. + } + } + protected constructor( - private readonly _extensionUri: vscode.Uri, + protected readonly _telemetry: ITelemetry, + protected readonly _extensionUri: vscode.Uri, column: vscode.ViewColumn, title: string, folderRepositoryManager: FolderRepositoryManager, - type: string = IssueOverviewPanel._viewType, + private readonly type: string = IssueOverviewPanel.viewType, + existingPanel?: vscode.WebviewPanel, + iconSubpath?: { + light: string, + dark: string, + } ) { super(); this._folderRepositoryManager = folderRepositoryManager; // Create and show a new webview panel - this._panel = vscode.window.createWebviewPanel(type, title, column, { + this._panel = existingPanel ?? this._register(vscode.window.createWebviewPanel(type, title, column, { // Enable javascript in the webview enableScripts: true, retainContextWhenHidden: true, // And restrict the webview to only loading content from our extension's `dist` directory. localResourceRoots: [vscode.Uri.joinPath(_extensionUri, 'dist')], - }); + enableFindWidget: true + })); + + if (iconSubpath) { + this._panel.iconPath = { + dark: vscode.Uri.joinPath(_extensionUri, iconSubpath.dark), + light: vscode.Uri.joinPath(_extensionUri, iconSubpath.light) + }; + } this._webview = this._panel.webview; super.initialize(); // Listen for when the panel is disposed // This happens when the user closes the panel or when the panel is closed programmatically - this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + this._register(this._panel.onDidDispose(() => this.dispose())); - this._folderRepositoryManager.onDidChangeActiveIssue( + this._register(this._folderRepositoryManager.onDidChangeActiveIssue( _ => { if (this._folderRepositoryManager && this._item) { const isCurrentlyCheckedOut = this._item.equals(this._folderRepositoryManager.activeIssue); @@ -102,84 +141,224 @@ export class IssueOverviewPanel extends W isCurrentlyCheckedOut: isCurrentlyCheckedOut, }); } - }, - null, - this._disposables, - ); + })); + + this._register(folderRepositoryManager.credentialStore.onDidUpgradeSession(() => { + this.updateItem(this._item); + })); + + this._register(this._panel.onDidChangeViewState(e => this.onDidChangeViewState(e))); + this.lastRefreshTime = new Date(); + this.pollForUpdates(true); + this._register(vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${WEBVIEW_REFRESH_INTERVAL}`)) { + this.pollForUpdates(this._panel.visible, true); + } + })); + this._register({ dispose: () => clearTimeout(this.timeout) }); + + } + + private getRefreshInterval(): number { + return vscode.workspace.getConfiguration().get(`${PR_SETTINGS_NAMESPACE}.${WEBVIEW_REFRESH_INTERVAL}`) || 60; + } + + protected onDidChangeViewState(e: vscode.WebviewPanelOnDidChangeViewStateEvent): void { + if (e.webviewPanel.visible) { + this.pollForUpdates(!!this._item, true); + } + } + + private timeout: NodeJS.Timeout | undefined = undefined; + private lastRefreshTime: Date; + private pollForUpdates(isVisible: boolean, refreshImmediately: boolean = false): void { + clearTimeout(this.timeout); + const refresh = async () => { + const previousRefreshTime = this.lastRefreshTime; + this.lastRefreshTime = await this._item.getLastUpdateTime(previousRefreshTime); + if (this.lastRefreshTime.getTime() > previousRefreshTime.getTime()) { + return this.refreshPanel(); + } + }; + + if (refreshImmediately) { + refresh(); + } + const webview = isVisible || vscode.window.tabGroups.all.find(group => group.activeTab?.input instanceof vscode.TabInputWebview && group.activeTab.input.viewType.endsWith(this.type)); + const timeoutDuration = 1000 * (webview ? this.getRefreshInterval() : (5 * 60)); + this.timeout = setTimeout(async () => { + await refresh(); + this.pollForUpdates(this._panel.visible); + }, timeoutDuration); } public async refreshPanel(): Promise { if (this._panel && this._panel.visible) { - this.update(this._folderRepositoryManager, this._item); + await this.update(this._folderRepositoryManager, this._item); } } - public async updateIssue(issueModel: IssueModel): Promise { - return Promise.all([ - this._folderRepositoryManager.resolveIssue( - issueModel.remote.owner, - issueModel.remote.repositoryName, - issueModel.number, - ), - issueModel.getIssueTimelineEvents(), - this._folderRepositoryManager.getPullRequestRepositoryDefaultBranch(issueModel), - ]) - .then(result => { - const [issue, timelineEvents, defaultBranch] = result; - if (!issue) { - throw new Error( - `Fail to resolve issue #${issueModel.number} in ${issueModel.remote.owner}/${issueModel.remote.repositoryName}`, - ); - } + protected continueOnGitHub() { + return isInCodespaces(); + } - this._item = issue as TItem; - this._panel.title = `Pull Request #${issueModel.number.toString()}`; - - Logger.debug('pr.initialize', IssueOverviewPanel.ID); - this._postMessage({ - command: 'pr.initialize', - pullrequest: { - number: this._item.number, - title: this._item.title, - url: this._item.html_url, - createdAt: this._item.createdAt, - body: this._item.body, - bodyHTML: this._item.bodyHTML, - labels: this._item.item.labels, - author: { - login: this._item.author.login, - name: this._item.author.name, - avatarUrl: this._item.userAvatar, - url: this._item.author.url, - }, - state: this._item.state, - events: timelineEvents, - repositoryDefaultBranch: defaultBranch, - canEdit: true, - // TODO@eamodio What is status? - status: /*status ? status :*/ { statuses: [] }, - isIssue: true, - isDarkTheme: vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark - }, - }); - }) - .catch(e => { - vscode.window.showErrorMessage(formatError(e)); + protected getInitializeContext(currentUser: IAccount, issue: IssueModel, timelineEvents: TimelineEvent[], repositoryAccess: RepoAccessAndMergeMethods, viewerCanEdit: boolean, assignableUsers: IAccount[]): Issue { + const hasWritePermission = repositoryAccess.hasWritePermission; + const canEdit = hasWritePermission || viewerCanEdit; + const labels = issue.item.labels.map(label => ({ + ...label, + displayName: emojify(label.name) + })); + + const context: Issue = { + owner: issue.remote.owner, + repo: issue.remote.repositoryName, + number: issue.number, + title: issue.title, + titleHTML: issue.titleHTML, + url: issue.html_url, + createdAt: issue.createdAt, + body: issue.body, + bodyHTML: issue.bodyHTML, + labels: labels, + author: issue.author, + state: issue.state, + stateReason: issue.stateReason, + events: timelineEvents, + continueOnGitHub: this.continueOnGitHub(), + canEdit, + hasWritePermission, + isIssue: true, + projectItems: issue.item.projectItems, + milestone: issue.milestone, + assignees: issue.assignees ?? [], + isEnterprise: issue.githubRepository.remote.isEnterprise, + isDarkTheme: vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark, + canAssignCopilot: assignableUsers.find(user => COPILOT_ACCOUNTS[user.login]) !== undefined, + canRequestCopilotReview: false, + reactions: issue.item.reactions, + isAuthor: issue.author.login === currentUser.login, + }; + + return context; + } + + protected async updateItem(issueModel: TItem): Promise { + try { + const [ + issue, + timelineEvents, + repositoryAccess, + viewerCanEdit, + assignableUsers, + currentUser + ] = await Promise.all([ + this._folderRepositoryManager.resolveIssue( + issueModel.remote.owner, + issueModel.remote.repositoryName, + issueModel.number, + ), + issueModel.getIssueTimelineEvents(), + this._folderRepositoryManager.getPullRequestRepositoryAccessAndMergeMethods(issueModel), + issueModel.canEdit(), + this._folderRepositoryManager.getAssignableUsers(), + this._folderRepositoryManager.getCurrentUser(), + ]); + + if (!issue) { + throw new Error( + `Fail to resolve issue #${issueModel.number} in ${issueModel.remote.owner}/${issueModel.remote.repositoryName}`, + ); + } + + this._item = issue as TItem; + this.setPanelTitle(`Issue #${issueModel.number.toString()}`); + + Logger.debug('pr.initialize', IssueOverviewPanel.ID); + this._postMessage({ + command: 'pr.initialize', + pullrequest: this.getInitializeContext(currentUser, issue, timelineEvents, repositoryAccess, viewerCanEdit, assignableUsers[this._item.remote.remoteName] ?? []), }); + + } catch (e) { + vscode.window.showErrorMessage(`Error updating issue description: ${formatError(e)}`); + } } - public async update(foldersManager: FolderRepositoryManager, issueModel: IssueModel): Promise { - this._folderRepositoryManager = foldersManager; + protected registerPrListeners() { + // none for issues + } + + /** + * Resolve a model from an unresolved identity. + * Subclasses can override to resolve different types (e.g., pull requests vs issues). + */ + protected async resolveModel(identity: UnresolvedIdentity): Promise { + return this._folderRepositoryManager.resolveIssue( + identity.owner, + identity.repo, + identity.number + ) as Promise; + } + + /** + * Get the display name for the item type (for error messages). + */ + protected getItemTypeName(): string { + return 'issue'; + } + + /** + * Update the panel with an unresolved identity and optional model. + * If no model is provided, it will be resolved from the identity. + */ + public async updateWithIdentity(foldersManager: FolderRepositoryManager, identity: UnresolvedIdentity, issueModel?: TItem, progressLocation?: string): Promise { + this._identity = identity; + + if (this._folderRepositoryManager !== foldersManager) { + this._folderRepositoryManager = foldersManager; + this.registerPrListeners(); + } + this._postMessage({ command: 'set-scroll', scrollPosition: this._scrollPosition, }); - this._panel.webview.html = this.getHtmlForWebview(issueModel.number.toString()); - return this.updateIssue(issueModel); + const isNewItem = !this._item || (this._item.number !== identity.number); + if (isNewItem || !this._panel.webview.html) { + this._panel.webview.html = this.getHtmlForWebview(); + this._postMessage({ command: 'pr.clear' }); + } + + // If no model provided, resolve it from the identity + if (!issueModel) { + const resolvedModel = await this.resolveModel(identity); + if (!resolvedModel) { + throw new Error( + `Failed to resolve ${this.getItemTypeName()} #${identity.number} in ${identity.owner}/${identity.repo}`, + ); + } + issueModel = resolvedModel; + } + + if (progressLocation) { + return vscode.window.withProgress({ location: { viewId: progressLocation } }, () => this.updateItem(issueModel!)); + } else { + return this.updateItem(issueModel); + } + } + + public async update(foldersManager: FolderRepositoryManager, issueModel: TItem, progressLocation?: string): Promise { + const identity: UnresolvedIdentity = { + owner: issueModel.remote.owner, + repo: issueModel.remote.repositoryName, + number: issueModel.number + }; + return this.updateWithIdentity(foldersManager, identity, issueModel, progressLocation); } - protected async _onDidReceiveMessage(message: IRequestMessage) { + protected override async _onDidReceiveMessage(message: IRequestMessage) { const result = await super._onDidReceiveMessage(message); if (result !== this.MESSAGE_UNHANDLED) { return; @@ -191,10 +370,10 @@ export class IssueOverviewPanel extends W return; case 'pr.close': return this.close(message); - case 'pr.comment': - return this.createComment(message); + case 'pr.submit': + return this.submitReviewMessage(message); case 'scroll': - this._scrollPosition = message.args; + this._scrollPosition = message.args.scrollPosition; return; case 'pr.edit-comment': return this.editComment(message); @@ -211,6 +390,26 @@ export class IssueOverviewPanel extends W return this.addLabels(message); case 'pr.remove-label': return this.removeLabel(message); + case 'pr.change-assignees': + return this.changeAssignees(message); + case 'pr.remove-milestone': + return this.removeMilestone(message); + case 'pr.add-milestone': + return this.addMilestone(message); + case 'pr.change-projects': + return this.changeProjects(message); + case 'pr.remove-project': + return this.removeProject(message); + case 'pr.add-assignee-yourself': + return this.addAssigneeYourself(message); + case 'pr.add-assignee-copilot': + return this.addAssigneeCopilot(message); + case 'pr.copy-prlink': + return this.copyItemLink(); + case 'pr.copy-vscodedevlink': + return this.copyVscodeDevLink(); + case 'pr.openOnGitHub': + return openPullRequestOnGitHub(this._item, this._telemetry); case 'pr.debug': return this.webviewDebug(message); default: @@ -218,28 +417,82 @@ export class IssueOverviewPanel extends W } } - private async addLabels(message: IRequestMessage): Promise { - const quickPick = vscode.window.createQuickPick(); - try { - let newLabels: ILabel[] = []; - async function getLabelOptions( - folderRepoManager: FolderRepositoryManager, - issue: IssueModel, - ): Promise { - newLabels = await folderRepoManager.getLabels(issue); - - return newLabels.map(label => { - return { - label: label.name, - picked: issue.item.labels.some(existingLabel => existingLabel.name === label.name) - }; + protected async submitReviewMessage(message: IRequestMessage) { + const comment = await this._item.createIssueComment(message.args); + const commentedEvent: CommentEvent = { + ...comment, + event: EventType.Commented + }; + const allEvents = await this._getTimeline(); + const reply: SubmitReviewReply = { + events: allEvents, + reviewedEvent: commentedEvent, + }; + this.tryScheduleCopilotRefresh(comment.body); + return this._replyMessage(message, reply); + } + + private _scheduledRefresh: Promise | undefined; + protected async tryScheduleCopilotRefresh(commentBody: string, reviewType?: ReviewStateValue) { + if (!this._scheduledRefresh) { + this._scheduledRefresh = this.doScheduleCopilotRefresh(commentBody, reviewType) + .finally(() => { + this._scheduledRefresh = undefined; }); + } + } + + private async doScheduleCopilotRefresh(commentBody: string, reviewType?: ReviewStateValue) { + if (!COPILOT_ACCOUNTS[this._item.author.login]) { + return; + } + + if (!commentBody.includes('@copilot') && !commentBody.includes('@Copilot') && reviewType !== 'CHANGES_REQUESTED') { + return; + } + + const initialTimeline = await this._getTimeline(); + const delays = [250, 500, 1000, 2000]; + + for (const delay of delays) { + await new Promise(resolve => setTimeout(resolve, delay)); + if (this._isDisposed) { + return; + } + + try { + const currentTimeline = await this._getTimeline(); + + // Check if we have any new CopilotStarted events + if (currentTimeline.length > initialTimeline.length) { + // Found a new CopilotStarted event, refresh and stop + this.refreshPanel(); + return; + } + } catch (error) { + // If timeline fetch fails, continue with the next retry + Logger.warn(`Failed to fetch timeline during Copilot refresh retry: ${error}`, IssueOverviewPanel.ID); } + } + + // If no new CopilotStarted events were found after all retries, still refresh once + if (!this._isDisposed) { + this.refreshPanel(); + } + } + + private async addLabels(message: IRequestMessage): Promise { + const quickPick = vscode.window.createQuickPick<(vscode.QuickPickItem & { name: string })>(); + try { + let newLabels: DisplayLabel[] = []; quickPick.busy = true; quickPick.canSelectMany = true; quickPick.show(); - quickPick.items = await getLabelOptions(this._folderRepositoryManager, this._item); + quickPick.items = await (getLabelOptions(this._folderRepositoryManager, this._item.item.labels, this._item.remote.owner, this._item.remote.repositoryName).then(options => { + newLabels = options.newLabels; + return options.labelPicks; + })); quickPick.selectedItems = quickPick.items.filter(item => item.picked); quickPick.busy = false; @@ -247,14 +500,13 @@ export class IssueOverviewPanel extends W return quickPick.selectedItems; }); const hidePromise = asPromise(quickPick.onDidHide); - const labelsToAdd = await Promise.race([acceptPromise, hidePromise]); + const labelsToAdd = await Promise.race([acceptPromise, hidePromise]); quickPick.busy = true; + quickPick.enabled = false; - if (labelsToAdd && labelsToAdd.length) { - await this._item.setLabels(labelsToAdd.map(r => r.label)); - const addedLabels: ILabel[] = labelsToAdd.map(label => newLabels.find(l => l.name === label.label)!); - - this._item.item.labels = addedLabels; + if (labelsToAdd) { + await this._item.setLabels(labelsToAdd.map(r => r.name)); + const addedLabels: DisplayLabel[] = labelsToAdd.map(label => newLabels.find(l => l.name === label.name)!); await this._replyMessage(message, { added: addedLabels, @@ -271,10 +523,6 @@ export class IssueOverviewPanel extends W private async removeLabel(message: IRequestMessage): Promise { try { await this._item.removeLabel(message.args); - - const index = this._item.item.labels.findIndex(label => label.name === message.args); - this._item.item.labels.splice(index, 1); - this._replyMessage(message, {}); } catch (e) { vscode.window.showErrorMessage(formatError(e)); @@ -308,6 +556,137 @@ export class IssueOverviewPanel extends W }); } + protected _getTimeline(): Promise { + return this._item.getIssueTimelineEvents(); + } + + private async changeAssignees(message: IRequestMessage): Promise { + const quickPick = vscode.window.createQuickPick(); + + try { + quickPick.busy = true; + quickPick.canSelectMany = true; + quickPick.matchOnDescription = true; + quickPick.show(); + quickPick.items = await getAssigneesQuickPickItems(this._folderRepositoryManager, undefined, this._item.remote.remoteName, this._item.assignees ?? [], this._item); + quickPick.selectedItems = quickPick.items.filter(item => item.picked); + + quickPick.busy = false; + const acceptPromise = asPromise(quickPick.onDidAccept).then(() => { + return quickPick.selectedItems.filter(item => item.user) as (vscode.QuickPickItem & { user: IAccount })[] | undefined; + }); + const hidePromise = asPromise(quickPick.onDidHide); + const allAssignees = await Promise.race<(vscode.QuickPickItem & { user: IAccount })[] | void>([acceptPromise, hidePromise]); + quickPick.busy = true; + quickPick.enabled = false; + + if (allAssignees) { + const newAssignees: IAccount[] = allAssignees.map(item => item.user); + await this._item.replaceAssignees(newAssignees); + const events = await this._getTimeline(); + const reply: ChangeAssigneesReply = { + assignees: newAssignees, + events + }; + await this._replyMessage(message, reply); + } + } catch (e) { + vscode.window.showErrorMessage(formatError(e)); + } finally { + quickPick.hide(); + quickPick.dispose(); + } + } + + + private async addMilestone(message: IRequestMessage): Promise { + return getMilestoneFromQuickPick(this._folderRepositoryManager, this._item.githubRepository, this._item.milestone, (milestone) => this.updateMilestone(milestone, message)); + } + + private async updateMilestone(milestone: IMilestone | undefined, message: IRequestMessage) { + if (!milestone) { + return this.removeMilestone(message); + } + await this._item.updateMilestone(milestone.id); + this._replyMessage(message, { + added: milestone, + }); + } + + private async removeMilestone(message: IRequestMessage): Promise { + try { + await this._item.updateMilestone('null'); + this._replyMessage(message, {}); + } catch (e) { + vscode.window.showErrorMessage(formatError(e)); + } + } + + private async changeProjects(message: IRequestMessage): Promise { + return getProjectFromQuickPick(this._folderRepositoryManager, this._item.githubRepository, this._item.item.projectItems?.map(item => item.project), (project) => this.updateProjects(project, message)); + } + + private async updateProjects(projects: IProject[] | undefined, message: IRequestMessage) { + let newProjects: IProjectItem[] = []; + if (projects) { + newProjects = (await this._item.updateProjects(projects)) ?? []; + } + const projectItemsReply: ProjectItemsReply = { + projectItems: newProjects, + }; + return this._replyMessage(message, projectItemsReply); + } + + private async removeProject(message: IRequestMessage): Promise { + await this._item.removeProjects([message.args]); + return this._replyMessage(message, {}); + } + + private async addAssigneeYourself(message: IRequestMessage): Promise { + try { + const currentUser = await this._folderRepositoryManager.getCurrentUser(); + const alreadyAssigned = this._item.assignees?.find(user => user.login === currentUser.login); + if (!alreadyAssigned) { + const newAssignees = (this._item.assignees ?? []).concat(currentUser); + await this._item.replaceAssignees(newAssignees); + } + const events = await this._getTimeline(); + const reply: ChangeAssigneesReply = { + assignees: this._item.assignees ?? [], + events + }; + this._replyMessage(message, reply); + } catch (e) { + vscode.window.showErrorMessage(formatError(e)); + } + } + + private async addAssigneeCopilot(message: IRequestMessage): Promise { + try { + const copilotUser = (await this._folderRepositoryManager.getAssignableUsers())[this._item.remote.remoteName].find(user => COPILOT_ACCOUNTS[user.login]); + if (copilotUser) { + const newAssignees = (this._item.assignees ?? []).concat(copilotUser); + await this._item.replaceAssignees(newAssignees); + } + const events = await this._getTimeline(); + const reply: ChangeAssigneesReply = { + assignees: this._item.assignees ?? [], + events + }; + this._replyMessage(message, reply); + } catch (e) { + vscode.window.showErrorMessage(formatError(e)); + } + } + + private async copyItemLink(): Promise { + return vscode.env.clipboard.writeText(this._item.html_url); + } + + private async copyVscodeDevLink(): Promise { + return vscode.env.clipboard.writeText(vscodeDevPrLink(this._item)); + } + protected editCommentPromise(comment: IComment, text: string): Promise { return this._item.editIssueComment(comment, text); } @@ -347,49 +726,35 @@ export class IssueOverviewPanel extends W }); } - private close(message: IRequestMessage): void { - vscode.commands - .executeCommand('pr.close', this._item, message.args) - .then(comment => { - if (comment) { - this._replyMessage(message, { - value: comment, - }); - } else { - this._throwError(message, 'Close cancelled'); - } - }); - } - - private createComment(message: IRequestMessage) { - this._item.createIssueComment(message.args).then(comment => { - this._replyMessage(message, { - value: comment, - }); - }); + protected async close(message: IRequestMessage) { + let comment: IComment | undefined; + if (message.args) { + comment = await this._item.createIssueComment(message.args); + } + const closeUpdate = await this._item.close(); + const result: CloseResult = { + state: closeUpdate.item.state.toUpperCase() as GithubItemStateEnum, + commentEvent: comment ? { + ...comment, + event: EventType.Commented + } : undefined, + closeEvent: closeUpdate.closedEvent + }; + this._replyMessage(message, result); } protected set _currentPanel(panel: IssueOverviewPanel | undefined) { IssueOverviewPanel.currentPanel = panel; } - public dispose() { + public override dispose() { + super.dispose(); this._currentPanel = undefined; - - // Clean up our resources - this._panel.dispose(); this._webview = undefined; - - while (this._disposables.length) { - const x = this._disposables.pop(); - if (x) { - x.dispose(); - } - } } - protected getHtmlForWebview(number: string) { - const nonce = getNonce(); + protected getHtmlForWebview() { + const nonce = generateUuid(); const uri = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview-pr-description.js'); @@ -397,10 +762,9 @@ export class IssueOverviewPanel extends W - + - Pull Request #${number}
@@ -412,4 +776,8 @@ export class IssueOverviewPanel extends W public getCurrentTitle(): string { return this._panel.title; } + + public getCurrentItem(): TItem | undefined { + return this._item; + } } diff --git a/src/github/loggingOctokit.ts b/src/github/loggingOctokit.ts index f3e8fa796e..09813b75ae 100644 --- a/src/github/loggingOctokit.ts +++ b/src/github/loggingOctokit.ts @@ -5,12 +5,15 @@ 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 Logger from '../common/logger'; import { RateLimit } from './graphql'; - -const RATE_COUNTER_LAST_WINDOW = 'rateCounterLastWindow'; -const RATE_COUNTER_COUNT = 'rateCounterCount'; +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'; interface RestResponse { headers: { @@ -19,63 +22,79 @@ interface RestResponse { } } +interface RateLimitResult { + data: { + rateLimit: RateLimit | undefined + } | undefined; +} + export class RateLogger { - private lastWindow: number; - private count: number = 0; + private bulkhead: BulkheadPolicy = bulkhead(140); private static ID = 'RateLimit'; + private hasLoggedLowRateLimit: boolean = false; - constructor(private readonly context: vscode.ExtensionContext) { - // We assume the common case for this logging: only one user. - // We also make up our own window. This will not line up exactly with GitHub's rate limit reset time, - // but it will give us a nice idea of how many API calls we're making. We use an hour, just like GitHub. - this.lastWindow = this.context.globalState.get(RATE_COUNTER_LAST_WINDOW, 0); - // It looks like there might be separate rate limits for the REST and GraphQL api. - // We'll just count total API calls as a lower bound. - this.count = this.context.globalState.get(RATE_COUNTER_COUNT, 0); - this.tryUpdateWindow(); - } + constructor(private readonly telemetry: ITelemetry, private readonly errorOnFlood: boolean) { } - private tryUpdateWindow() { - const now = new Date().getTime(); - if ((now - this.lastWindow) > (60 * 60 * 1000) /* 1 hour */) { - this.lastWindow = now; - this.context.globalState.update(RATE_COUNTER_LAST_WINDOW, this.lastWindow); - this.count = 0; - } - } + public logAndLimit>(info: string | undefined, apiRequest: () => T): T | undefined { + if (this.bulkhead.executionSlots === 0) { + Logger.error('API call count has exceeded 140 concurrent calls.', RateLogger.ID); + // We have hit more than 140 concurrent API requests. + /* __GDPR__ + "pr.highApiCallRate" : {} + */ + this.telemetry.sendTelemetryErrorEvent('pr.highApiCallRate'); - public log(info: string | undefined) { - this.tryUpdateWindow(); - this.count++; - this.context.globalState.update(RATE_COUNTER_COUNT, this.count); - const countMessage = `API call count: ${this.count}${info ? ` (${info})` : ''}`; - if (this.count > 4000) { - Logger.appendLine(countMessage, RateLogger.ID); + if (!this.errorOnFlood) { + // We don't want to error on flood, so try to execute the API request anyway. + return apiRequest(); + } else { + vscode.window.showErrorMessage(vscode.l10n.t('The GitHub Pull Requests extension is making too many requests to GitHub. This indicates a bug in the extension. Please file an issue on GitHub and include the output from "GitHub Pull Request".')); + return undefined; + } + } + const log = `Extension rate limit remaining: ${this.bulkhead.executionSlots}, ${info}`; + if (this.bulkhead.executionSlots < 5) { + Logger.appendLine(log, RateLogger.ID); } else { - Logger.debug(countMessage, RateLogger.ID); + Logger.debug(log, RateLogger.ID); } + + return this.bulkhead.execute(() => apiRequest()) as T; } - public async logGraphqlRateLimit(result: Promise<{ data: { rateLimit: RateLimit | undefined } | undefined } | undefined>) { - let rateLimitInfo; + public async logRateLimit(info: string | undefined, result: Promise, isRest: boolean = false) { + let rateLimitInfo: { limit: number, remaining: number, cost: number } | undefined; try { - rateLimitInfo = (await result)?.data?.rateLimit; + const resolvedResult = await result; + rateLimitInfo = resolvedResult?.data?.rateLimit; } catch (e) { // Ignore errors here since we're just trying to log the rate limit. return; } + const isSearch = info?.startsWith('/search/'); if ((rateLimitInfo?.limit ?? 5000) < 5000) { - Logger.appendLine(`Unexpectedly low rate limit: ${rateLimitInfo?.limit}`, RateLogger.ID); + if (!isSearch) { + Logger.appendLine(`Unexpectedly low rate limit: ${rateLimitInfo?.limit}`, RateLogger.ID); + } else if ((rateLimitInfo?.limit ?? 30) < 30) { + Logger.appendLine(`Unexpectedly low SEARCH rate limit: ${rateLimitInfo?.limit}`, RateLogger.ID); + } } - const remaining = `Rate limit remaining: ${rateLimitInfo?.remaining}`; - if ((rateLimitInfo?.remaining ?? 1000) < 1000) { - Logger.appendLine(remaining, RateLogger.ID); + const remaining = `${isRest ? 'REST' : 'GraphQL'} Rate limit remaining: ${rateLimitInfo?.remaining}, cost: ${rateLimitInfo?.cost}, ${info}`; + if (((rateLimitInfo?.remaining ?? 1000) < 1000) && !isSearch) { + if (!this.hasLoggedLowRateLimit) { + /* __GDPR__ + "pr.lowRateLimitRemaining" : {} + */ + this.telemetry.sendTelemetryErrorEvent('pr.lowRateLimitRemaining'); + this.hasLoggedLowRateLimit = true; + } + Logger.warn(remaining, RateLogger.ID); } else { Logger.debug(remaining, RateLogger.ID); } } - public async logRestRateLimit(restResponse: Promise) { + public async logRestRateLimit(info: string | undefined, restResponse: Promise) { let result; try { result = await restResponse; @@ -89,35 +108,88 @@ export class RateLogger { remaining: Number(result.headers['x-ratelimit-remaining']), resetAt: '' }; - this.logGraphqlRateLimit(Promise.resolve({ data: { rateLimit } })); + this.logRateLimit(info, Promise.resolve({ data: { rateLimit } }), true); } } export class LoggingApolloClient { - constructor(private readonly _graphql: ApolloClient, private _rateLogger: RateLogger) { }; + constructor(private readonly _graphql: ApolloClient, private _rateLogger: RateLogger) { } query(options: QueryOptions): Promise> { - this._rateLogger.log((options.query.definitions[0] as { name: { value: string } | undefined }).name?.value); - const result = this._graphql.query(options); - this._rateLogger.logGraphqlRateLimit(result as any); + const logInfo = (options.query.definitions[0] as { name: { value: string } | undefined }).name?.value; + const result = this._rateLogger.logAndLimit(logInfo, () => this._graphql.query(options)); + if (result === undefined) { + throw new Error('API call count has exceeded a rate limit.'); + } + this._rateLogger.logRateLimit(logInfo, result as Promise); return result; } mutate(options: MutationOptions): Promise> { - this._rateLogger.log(options.context); - const result = this._graphql.mutate(options); - this._rateLogger.logGraphqlRateLimit(result as any); + const logInfo = options.context; + const result = this._rateLogger.logAndLimit(logInfo, () => this._graphql.mutate(options)); + if (result === undefined) { + throw new Error('API call count has exceeded a rate limit.'); + } + this._rateLogger.logRateLimit(logInfo, result as Promise); return result; } } export class LoggingOctokit { - constructor(public readonly api: Octokit, private _rateLogger: RateLogger) { }; + constructor(public readonly api: Octokit, private _rateLogger: RateLogger) { } - async call(api: (T) => Promise, args: T): Promise { - this._rateLogger.log((api as unknown as { endpoint: { DEFAULTS: { url: string } | undefined } | undefined }).endpoint?.DEFAULTS?.url); - const result = api(args); - this._rateLogger.logRestRateLimit(result as Promise as Promise); + call Promise>(api: T, ...args: Parameters): ReturnType { + const logInfo = (api as unknown as { endpoint: { DEFAULTS: { url: string } | undefined } | undefined }).endpoint?.DEFAULTS?.url; + const result = this._rateLogger.logAndLimit>(logInfo, ((() => api(...args)) as () => ReturnType)); + if (result === undefined) { + throw new Error('API call count has exceeded a rate limit.'); + } + this._rateLogger.logRestRateLimit(logInfo, result as Promise as Promise); return result; } } + +export async function compareCommits(remote: GitHubRemote, octokit: LoggingOctokit, base: GitHubRef, head: GitHubRef, compareWithBaseRef: string, prNumber: number, logId: string): Promise<{ mergeBaseSha: string; files: IRawFileChange[] }> { + Logger.debug(`Comparing commits for ${remote.owner}/${remote.repositoryName} with base ${base.repositoryCloneUrl.owner}:${compareWithBaseRef} and head ${head.repositoryCloneUrl.owner}:${head.sha}`, logId); + let files: IRawFileChange[] | undefined; + let mergeBaseSha: string | undefined; + + const listFiles = async (perPage?: number) => { + return restPaginate(octokit.api.pulls.listFiles, { + owner: base.repositoryCloneUrl.owner, + pull_number: prNumber, + repo: remote.repositoryName, + }, perPage); + }; + + try { + const { data } = await octokit.call(octokit.api.repos.compareCommits, { + repo: remote.repositoryName, + owner: remote.owner, + base: `${base.repositoryCloneUrl.owner}:${compareWithBaseRef}`, + head: `${head.repositoryCloneUrl.owner}:${head.sha}`, + }); + const MAX_FILE_CHANGES_IN_COMPARE_COMMITS = 100; + + if (data.files && data.files.length >= MAX_FILE_CHANGES_IN_COMPARE_COMMITS) { + // compareCommits will return a maximum of 100 changed files + // If we have (maybe) more than that, we'll need to fetch them with listFiles API call + Logger.appendLine(`More than ${MAX_FILE_CHANGES_IN_COMPARE_COMMITS} files changed in #${prNumber}`, logId); + files = await listFiles(); + } else { + // if we're under the limit, just use the result from compareCommits, don't make additional API calls. + files = data.files ? data.files as IRawFileChange[] : []; + } + mergeBaseSha = data.merge_base_commit.sha; + } catch (e) { + if (e.message === 'Server Error') { + // Happens when github times out. Let's try to get a few at a time. + files = await listFiles(3); + mergeBaseSha = base.sha; + } else { + throw e; + } + } + return { mergeBaseSha, files }; +} diff --git a/src/github/markdownUtils.ts b/src/github/markdownUtils.ts new file mode 100644 index 0000000000..cf4c766af7 --- /dev/null +++ b/src/github/markdownUtils.ts @@ -0,0 +1,357 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as marked from 'marked'; +import 'url-search-params-polyfill'; +import * as vscode from 'vscode'; +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) { + case GithubItemStateEnum.Open: { + return issue instanceof PullRequestModel ? '$(git-pull-request)' : '$(issues)'; + } + case GithubItemStateEnum.Closed: { + return issue instanceof PullRequestModel ? '$(git-pull-request)' : '$(issue-closed)'; + } + case GithubItemStateEnum.Merged: + return '$(git-merge)'; + } +} + +function getIconMarkdown(issue: IssueModel) { + if (issue instanceof PullRequestModel) { + return getIconString(issue); + } + switch (issue.state) { + case GithubItemStateEnum.Open: { + return `$(issues)`; + } + case GithubItemStateEnum.Closed: { + // Use grey for issues closed as "not planned", purple for "completed" + const color = issue.stateReason !== 'COMPLETED' ? '#6a737d' : '#8957e5'; + return `$(issue-closed)`; + } + } +} + +function repoCommitDate(user: User, repoNameWithOwner: string): string | undefined { + let date: string | undefined = undefined; + user.commitContributions.forEach(element => { + if (repoNameWithOwner.toLowerCase() === element.repoNameWithOwner.toLowerCase()) { + date = element.createdAt.toLocaleString('default', { day: 'numeric', month: 'short', year: 'numeric' }); + } + }); + return date; +} + +export function userMarkdown(origin: PullRequestDefaults, user: User): vscode.MarkdownString { + const markdown: vscode.MarkdownString = new vscode.MarkdownString(undefined, true); + markdown.appendMarkdown( + `![Avatar](${user.avatarUrl}|height=50,width=50) ${user.name ? `**${user.name}** ` : ''}[${user.login}](${user.url})`, + ); + if (user.bio) { + markdown.appendText(' \r\n' + user.bio.replace(/\r\n/g, ' ')); + } + + const date = repoCommitDate(user, origin.owner + '/' + origin.repo); + if (user.location || date) { + markdown.appendMarkdown(' \r\n\r\n---'); + } + if (user.location) { + markdown.appendMarkdown(` \r\n${vscode.l10n.t('{0} {1}', '$(location)', user.location)}`); + } + if (date) { + markdown.appendMarkdown(` \r\n${vscode.l10n.t('{0} Committed to this repository on {1}', '$(git-commit)', date)}`); + } + if (user.company) { + markdown.appendMarkdown(` \r\n${vscode.l10n.t({ message: '{0} Member of {1}', args: ['$(jersey)', user.company], comment: ['An organization that the user is a member of.', 'The first placeholder is an icon and shouldn\'t be localized.', 'The second placeholder is the name of the organization.'] })}`); + } + return markdown; +} + +async function findAndModifyString( + text: string, + find: RegExp, + transformer: (match: RegExpMatchArray) => Promise, +): Promise { + let searchResult = text.search(find); + let position = 0; + while (searchResult >= 0 && searchResult < text.length) { + let newBodyFirstPart: string | undefined; + if (searchResult === 0 || text.charAt(searchResult - 1) !== '&') { + const match = text.substring(searchResult).match(find)!; + if (match) { + const transformed = await transformer(match); + if (transformed) { + newBodyFirstPart = text.slice(0, searchResult) + transformed; + text = newBodyFirstPart + text.slice(searchResult + match[0].length); + } + } + } + position = newBodyFirstPart ? newBodyFirstPart.length : searchResult + 1; + const newSearchResult = text.substring(position).search(find); + searchResult = newSearchResult > 0 ? position + newSearchResult : newSearchResult; + } + return text; +} + +function findLinksInIssue(body: string, issue: IssueModel): Promise { + return findAndModifyString(body, ISSUE_OR_URL_EXPRESSION, async (match: RegExpMatchArray) => { + const tryParse = parseIssueExpressionOutput(match); + if (tryParse) { + const issueNumberLabel = getIssueNumberLabelFromParsed(tryParse); // get label before setting owner and name. + if (!tryParse.owner || !tryParse.name) { + tryParse.owner = issue.remote.owner; + tryParse.name = issue.remote.repositoryName; + } + return `[${issueNumberLabel}](https://github.com/${tryParse.owner}/${tryParse.name}/issues/${tryParse.issueNumber})`; + } + return undefined; + }); +} + +async function findCodeLinksInIssue(body: string, repositoriesManager: RepositoriesManager) { + return findAndModifyString(body, CODE_PERMALINK, async (match: RegExpMatchArray) => { + const codeLink = await findCodeLinkLocally(match, repositoriesManager); + if (codeLink) { + Logger.trace('finding code links in issue', 'Issues'); + const textDocument = await vscode.workspace.openTextDocument(codeLink?.file); + const endingTextDocumentLine = textDocument.lineAt( + codeLink.end < textDocument.lineCount ? codeLink.end : textDocument.lineCount - 1, + ); + const query = [ + codeLink.file, + { + selection: { + start: { + line: codeLink.start, + character: 0, + }, + end: { + line: codeLink.end, + character: endingTextDocumentLine.text.length, + }, + }, + }, + ]; + const openCommand = vscode.Uri.parse(`command:vscode.open?${encodeURIComponent(JSON.stringify(query))}`); + return `[${match[0]}](${openCommand} "Open ${codeLink.file.fsPath}")`; + } + return undefined; + }); +} + +export const ISSUE_BODY_LENGTH: number = 200; +export async function issueMarkdown( + issue: IssueModel, + context: vscode.ExtensionContext, + repositoriesManager: RepositoriesManager, + commentNumber?: number, + prChecks?: UnsatisfiedChecks +): Promise { + const markdown: vscode.MarkdownString = new vscode.MarkdownString(undefined, true); + markdown.supportHtml = true; + const date = new Date(issue.createdAt); + const ownerName = `${issue.remote.owner}/${issue.remote.repositoryName}`; + markdown.appendMarkdown( + `[${ownerName}](https://github.com/${ownerName}) on ${date.toLocaleString('default', { + day: 'numeric', + month: 'short', + year: 'numeric', + })} \n`, + ); + const titleWithDraft = (issue instanceof PullRequestModel && issue.isDraft) ? `\[DRAFT\] ${issue.title}` : issue.title; + const title = marked + .parse(titleWithDraft, { + renderer: new PlainTextRenderer(), + smartypants: true, + }) + .trim(); + markdown.appendMarkdown( + `${getIconMarkdown(issue)} **${title}** [#${issue.number}](${issue.html_url}) \n`, + ); + let body = marked.parse(issue.body, { + renderer: new PlainTextRenderer(), + smartypants: true, + }); + markdown.appendMarkdown(' \n'); + body = body.length > ISSUE_BODY_LENGTH ? body.substr(0, ISSUE_BODY_LENGTH) + '...' : body; + body = await findLinksInIssue(body, issue); + body = await findCodeLinksInIssue(body, repositoriesManager); + + markdown.appendMarkdown(body + ' \n'); + + if (issue.item.labels.length > 0) { + await ensureEmojis(context); + markdown.appendMarkdown('  \n'); + issue.item.labels.forEach(label => { + markdown.appendMarkdown( + `[${makeLabel(label)}](https://github.com/${ownerName}/labels/${encodeURIComponent( + label.name, + )}) `, + ); + }); + } + + if (issue.item.comments && commentNumber) { + for (const comment of issue.item.comments) { + if (comment.databaseId === commentNumber) { + markdown.appendMarkdown(' \r\n\r\n---\r\n'); + markdown.appendMarkdown('  \n'); + markdown.appendMarkdown( + `![Avatar](${comment.author.avatarUrl}|height=15,width=15)   **${comment.author.login}** commented`, + ); + markdown.appendMarkdown('  \n'); + let commentText = marked.parse( + comment.body.length > ISSUE_BODY_LENGTH + ? comment.body.substr(0, ISSUE_BODY_LENGTH) + '...' + : comment.body, + { renderer: new PlainTextRenderer(), smartypants: true }, + ); + commentText = await findLinksInIssue(commentText, issue); + markdown.appendMarkdown(commentText); + } + } + } + + if (prChecks) { + const statusMessage = getStatusDecoration(prChecks)?.tooltip; + if (statusMessage) { + markdown.appendMarkdown(' \r\n\r\n'); + markdown.appendMarkdown(`_${statusMessage}_`); + } + } + + return markdown; +} + +export class PlainTextRenderer extends marked.Renderer { + private allowSimpleMarkdown: boolean; + + constructor(allowSimpleMarkdown: boolean = false) { + super(); + this.allowSimpleMarkdown = allowSimpleMarkdown; + } + + override code(code: string, _infostring: string | undefined): string { + return code; + } + override blockquote(quote: string): string { + return quote; + } + override html(_html: string): string { + return ''; + } + override heading(text: string, _level: 1 | 2 | 3 | 4 | 5 | 6, _raw: string, _slugger: marked.Slugger): string { + return text + ' '; + } + override hr(): string { + return ''; + } + override list(body: string, _ordered: boolean, _start: number): string { + return body; + } + override listitem(text: string): string { + return ' ' + text; + } + override checkbox(_checked: boolean): string { + return ''; + } + override paragraph(text: string): string { + return text.replace(/\/g, '\\\>') + ' '; + } + override table(header: string, body: string): string { + return header + ' ' + body; + } + override tablerow(content: string): string { + return content; + } + override tablecell( + content: string, + _flags: { + header: boolean; + align: 'center' | 'left' | 'right' | null; + }, + ): string { + return content; + } + override strong(text: string): string { + return text; + } + override em(text: string): string { + return text; + } + override codespan(code: string): string { + if (this.allowSimpleMarkdown) { + return `\`${code}\``; + } + return `\\\`${code}\\\``; + } + override br(): string { + return ' '; + } + override del(text: string): string { + return text; + } + override image(_href: string, _title: string, _text: string): string { + return ''; + } + override text(text: string): string { + return text; + } + override link(href: string, title: string, text: string): string { + return text + ' '; + } +} + +export function getStatusDecoration(status: UnsatisfiedChecks): vscode.FileDecoration2 | undefined { + if ((status & UnsatisfiedChecks.CIFailed) && (status & UnsatisfiedChecks.ReviewRequired)) { + return { + propagate: false, + badge: new vscode.ThemeIcon('close', new vscode.ThemeColor('list.errorForeground')), + tooltip: 'Review required and some checks have failed' + }; + } else if (status & UnsatisfiedChecks.CIFailed) { + return { + propagate: false, + badge: new vscode.ThemeIcon('close', new vscode.ThemeColor('list.errorForeground')), + tooltip: 'Some checks have failed' + }; + } else if (status & UnsatisfiedChecks.ChangesRequested) { + return { + propagate: false, + badge: new vscode.ThemeIcon('request-changes', new vscode.ThemeColor('list.errorForeground')), + tooltip: 'Changes requested' + }; + } else if (status & UnsatisfiedChecks.CIPending) { + return { + propagate: false, + badge: new vscode.ThemeIcon('sync', new vscode.ThemeColor('list.warningForeground')), + tooltip: 'Checks pending' + }; + } else if (status & UnsatisfiedChecks.ReviewRequired) { + return { + propagate: false, + badge: new vscode.ThemeIcon('circle-filled', new vscode.ThemeColor('list.warningForeground')), + tooltip: 'Review required' + }; + } else if (status === UnsatisfiedChecks.None) { + return { + propagate: false, + badge: new vscode.ThemeIcon('check-all', new vscode.ThemeColor('issues.open')), + tooltip: 'All checks passed' + }; + } + +} \ No newline at end of file diff --git a/src/github/notifications.ts b/src/github/notifications.ts deleted file mode 100644 index 78450d051f..0000000000 --- a/src/github/notifications.ts +++ /dev/null @@ -1,392 +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 Logger from '../common/logger'; -import { NOTIFICATION_SETTING } 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 { SETTINGS_NAMESPACE } from './folderRepositoryManager'; -import { GitHubRepository } from './githubRepository'; -import { PullRequestState } from './graphql'; -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 implements vscode.Disposable { - 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 disposables: vscode.Disposable[] = []; - - private _onDidChangeNotifications: vscode.EventEmitter = new vscode.EventEmitter(); - public onDidChangeNotifications = this._onDidChangeNotifications.event; - - constructor( - gitHubPrsTree: PullRequestsTreeDataProvider, - credentialStore: CredentialStore, - reposManager: RepositoriesManager - ) { - 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.disposables.push( - manager.onDidChangeGithubRepositories(() => { - this.refreshOrLaunchPolling(); - }) - ); - }; - - this.disposables.push( - gitHubPrsTree.onDidChangeTreeData((node) => { - if (NotificationProvider.isPRNotificationsOn()) { - this.adaptPRNotifications(node); - } - }) - ); - this.disposables.push( - gitHubPrsTree.onDidChange(() => { - if (NotificationProvider.isPRNotificationsOn()) { - this.adaptPRNotifications(); - } - }) - ); - - this.disposables.push( - vscode.workspace.onDidChangeConfiguration((e) => { - if (e.affectsConfiguration(`${SETTINGS_NAMESPACE}.${NOTIFICATION_SETTING}`)) { - this.checkNotificationSetting(); - } - }) - ); - } - - private static isPRNotificationsOn() { - return vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get(NOTIFICATION_SETTING) === 'pullRequests'; - } - - private registerAuthProvider(credentialStore: CredentialStore) { - if (credentialStore.isAuthenticated(AuthProvider['github-enterprise']) && hasEnterpriseUri()) { - this._authProvider = AuthProvider['github-enterprise']; - } else if (credentialStore.isAuthenticated(AuthProvider.github)) { - this._authProvider = AuthProvider.github; - } - - this.disposables.push( - vscode.authentication.onDidChangeSessions(_ => { - if (credentialStore.isAuthenticated(AuthProvider['github-enterprise']) && hasEnterpriseUri()) { - this._authProvider = AuthProvider['github-enterprise']; - } - - if (credentialStore.isAuthenticated(AuthProvider.github)) { - this._authProvider = AuthProvider.github; - } - }) - ); - } - - private getPrIdentifier(pullRequest: PullRequestModel | OctokitResponse['data']): string { - if (pullRequest instanceof PullRequestModel) { - 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: PullRequestModel | string): boolean { - const identifier = pullRequest instanceof PullRequestModel ? - 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 | void) { - if (this._pollingHandler === undefined) { - this.startPolling(); - } - - if (node instanceof PRNode) { - const prNotifications = this._notifications.get(this.getPrIdentifier(node.pullRequestModel)); - if (prNotifications) { - for (const prNotification of prNotifications) { - if (prNotification) { - prNotification.pullRequestModel = node.pullRequestModel; - return; - } - } - } - } - - this._gitHubPrsTree.cachedChildren().then(async (catNodes: CategoryTreeNode[]) => { - let allPrs: PullRequestModel[] = []; - - for (const catNode of catNodes) { - if (catNode.id === 'All Open') { - if (catNode.prs.length === 0) { - for (const prNode of await catNode.cachedChildren()) { - if (prNode instanceof PRNode) { - allPrs.push(prNode.pullRequestModel); - } - } - } - else { - allPrs = catNode.prs; - } - - } - } - - 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: PullRequestModel) { - 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'); - clearInterval(this._pollingHandler); - Logger.appendLine(`Notifications: Starting new polling interval with ${this._pollingDuration}`); - 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, - }, - }); - - // We only consider open PullRequests as these are displayed in the AllOpen PR category. - // Other categories could have querries with closed PRs, but its hard to figure out if a PR - // belongs to a querry without loading each PR of that querry. - 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 - ); - } - - public dispose() { - if (this._pollingHandler) { - clearInterval(this._pollingHandler); - } - this.disposables.forEach(displosable => displosable.dispose()); - } -} \ No newline at end of file diff --git a/src/github/overviewRestorer.ts b/src/github/overviewRestorer.ts new file mode 100644 index 0000000000..fdb59e64b7 --- /dev/null +++ b/src/github/overviewRestorer.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CredentialStore } from './credentials'; +import { FolderRepositoryManager } from './folderRepositoryManager'; +import { GitHubRepository } from './githubRepository'; +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'; + + constructor(private readonly _repositoriesManager: RepositoriesManager, + private readonly _telemetry: ITelemetry, + private readonly _extensionUri: vscode.Uri, + private readonly _credentialStore: CredentialStore + ) { + super(); + this._register(vscode.window.registerWebviewPanelSerializer(IssueOverviewPanel.viewType, this)); + this._register(vscode.window.registerWebviewPanelSerializer(PullRequestOverviewPanel.viewType, this)); + } + + async deserializeWebviewPanel(webviewPanel: vscode.WebviewPanel, state: PullRequest): Promise { + await this.waitForAuth(); + await this.waitForAnyGitHubRepos(this._repositoriesManager); + + if (!state || !state.number || this._repositoriesManager.folderManagers.length === 0) { + webviewPanel.dispose(); + return; + } + + let repo: GitHubRepository | undefined; + let folderManager: FolderRepositoryManager | undefined; + for (const manager of this._repositoriesManager.folderManagers) { + const githubRepository = manager.findExistingGitHubRepository({ owner: state.owner, repositoryName: state.repo }); + if (githubRepository) { + repo = githubRepository; + folderManager = manager; + break; + } + } + + if (!repo || !folderManager) { + folderManager = this._repositoriesManager.folderManagers[0]; + repo = await folderManager.createGitHubRepositoryFromOwnerName(state.owner, state.repo); + } + + const identity = { owner: state.owner, repo: state.repo, number: state.number }; + if (state.isIssue) { + const issueModel = await repo.getIssue(state.number, true); + if (!issueModel) { + webviewPanel.dispose(); + return; + } + return IssueOverviewPanel.createOrShow(this._telemetry, this._extensionUri, folderManager, identity, issueModel, undefined, true, webviewPanel); + } else { + const pullRequestModel = await repo.getPullRequest(state.number, true); + if (!pullRequestModel) { + webviewPanel.dispose(); + return; + } + return PullRequestOverviewPanel.createOrShow(this._telemetry, this._extensionUri, folderManager, identity, pullRequestModel, undefined, true, webviewPanel); + } + } + + protected async waitForAuth(): Promise { + if (this._credentialStore.isAnyAuthenticated()) { + return; + } + return new Promise(resolve => this._credentialStore.onDidGetSession(() => resolve())); + } + + protected async waitForAnyGitHubRepos(reposManager: RepositoriesManager): Promise { + // Check if any folder manager already has GitHub repositories + if (reposManager.folderManagers.some(manager => manager.gitHubRepositories.length > 0)) { + return; + } + + Logger.appendLine('Waiting for GitHub repositories.', OverviewRestorer.ID); + return new Promise(resolve => { + const disposable = reposManager.onDidChangeAnyGitHubRepository(() => { + Logger.appendLine('Found GitHub repositories.', OverviewRestorer.ID); + disposable.dispose(); + resolve(); + }); + }); + } +} \ No newline at end of file diff --git a/src/github/prComment.ts b/src/github/prComment.ts index b347d34917..bd7970b75e 100644 --- a/src/github/prComment.ts +++ b/src/github/prComment.ts @@ -5,13 +5,17 @@ import * as path from 'path'; import * as vscode from 'vscode'; -import { IComment } from '../common/comment'; -import { stringReplaceAsync } from '../common/utils'; import { GitHubRepository } from './githubRepository'; import { IAccount } from './interface'; import { updateCommentReactions } from './utils'; - -export interface GHPRCommentThread extends vscode.CommentThread { +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'; + +export interface GHPRCommentThread extends vscode.CommentThread2 { gitHubThreadId: string; /** @@ -23,7 +27,7 @@ export interface GHPRCommentThread extends vscode.CommentThread { * The range the comment thread is located within the document. The thread icon will be shown * at the first line of the range. */ - range: vscode.Range; + range: vscode.Range | undefined; /** * The ordered comments of the thread. @@ -41,10 +45,14 @@ export interface GHPRCommentThread extends vscode.CommentThread { */ label?: string; + canReply: boolean | vscode.CommentAuthorInformation; + /** * Whether the thread has been marked as resolved. */ - state: vscode.CommentThreadState; + state?: { resolved: vscode.CommentThreadState; applicability?: vscode.CommentThreadApplicability }; + + reveal(comment?: vscode.Comment, options?: vscode.CommentThreadRevealOptions): Promise; dispose: () => void; } @@ -77,7 +85,12 @@ abstract class CommentBase implements vscode.Comment { /** * The author of the comment */ - public author: vscode.CommentAuthorInformation; + public abstract get author(): vscode.CommentAuthorInformation; + + /** + * The author of the comment, before any modifications we make for display purposes. + */ + public originalAuthor: vscode.CommentAuthorInformation; /** * The label to display on the comment, 'Pending' or nothing @@ -94,6 +107,11 @@ abstract class CommentBase implements vscode.Comment { */ public contextValue: string; + /** + * The state of the comment (Published or Draft) + */ + public state?: vscode.CommentState; + constructor( parent: GHPRCommentThread, ) { @@ -113,12 +131,13 @@ abstract class CommentBase implements vscode.Comment { } protected abstract getCancelEditBody(): string | vscode.MarkdownString; + protected abstract doSetBody(body: string | vscode.MarkdownString, refresh: boolean): Promise; cancelEdit() { this.parent.comments = this.parent.comments.map(cmt => { if (cmt instanceof CommentBase && cmt.commentEditId() === this.commentEditId()) { cmt.mode = vscode.CommentMode.Preview; - cmt.body = this.getCancelEditBody(); + this.doSetBody(this.getCancelEditBody(), true); } return cmt; @@ -154,25 +173,36 @@ export class TemporaryComment extends CommentBase { ) { super(parent); this.mode = vscode.CommentMode.Preview; - this.author = { - name: currentUser.login, + this.originalAuthor = { + name: currentUser.specialDisplayName ?? currentUser.login, iconPath: currentUser.avatarUrl ? vscode.Uri.parse(`${currentUser.avatarUrl}&s=64`) : undefined, }; this.label = isDraft ? vscode.l10n.t('Pending') : undefined; - this.contextValue = 'canEdit,canDelete'; + this.state = isDraft ? vscode.CommentState.Draft : vscode.CommentState.Published; + this.contextValue = 'temporary,canEdit,canDelete'; this.originalBody = originalComment ? originalComment.rawComment.body : undefined; this.reactions = originalComment ? originalComment.reactions : undefined; this.id = TemporaryComment.idPool++; } - set body(input: string | vscode.MarkdownString) { + protected async doSetBody(input: string | vscode.MarkdownString): Promise { if (typeof input === 'string') { this.input = input; } } + set body(input: string | vscode.MarkdownString) { + this.doSetBody(input); + } + 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 { + return this.originalAuthor; } commentEditId() { @@ -184,32 +214,49 @@ export class TemporaryComment extends CommentBase { } } -const SUGGESTION_EXPRESSION = /```suggestion(\r\n|\n)([\s\S]*?)(\r\n|\n)```/; +const SUGGESTION_EXPRESSION = /```suggestion(\u0020*(\r\n|\n))((?[\s\S]*?)(\r\n|\n))?```/; +const IMG_EXPRESSION = /.+?)['"].*?>/g; +const UUID_EXPRESSION = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}/; +export const COMMIT_SHA_EXPRESSION = /(? repo.remote.host === url.authority); + + const avatarUrisPromise = comment.user ? DataUri.avatarCirclesAsImageDataUris(context, [comment.user], 28, 28) : Promise.resolve([]); + this.doSetBody(comment.body, !comment.user).then(async () => { // only refresh if there's no user. If there's a user, we'll refresh in the then. + const avatarUris = await avatarUrisPromise; + if (avatarUris.length > 0) { + this.author.iconPath = avatarUris[0]; + } + this.refresh(); + }); + this.commentId = comment.id.toString(); + updateCommentReactions(this, comment.reactions); this.label = comment.isDraft ? vscode.l10n.t('Pending') : undefined; + this.state = comment.isDraft ? vscode.CommentState.Draft : vscode.CommentState.Published; const contextValues: string[] = []; if (comment.canEdit) { @@ -220,7 +267,7 @@ export class GHPRComment extends CommentBase { contextValues.push('canDelete'); } - if (this.suggestion) { + if (this.suggestion !== undefined) { contextValues.push('hasSuggestion'); } @@ -228,45 +275,137 @@ export class GHPRComment extends CommentBase { this.timestamp = new Date(comment.createdAt); } + get author(): vscode.CommentAuthorInformation { + if (!this.rawComment.user?.specialDisplayName) { + return this.originalAuthor; + } + return { + name: this.rawComment.user.specialDisplayName, + iconPath: this.originalAuthor.iconPath, + }; + } + + update(comment: IComment) { + const oldRawComment = this.rawComment; + this.rawComment = comment; + let refresh: boolean = false; + + if (updateCommentReactions(this, comment.reactions)) { + refresh = true; + } + + const oldLabel = this.label; + this.label = comment.isDraft ? vscode.l10n.t('Pending') : undefined; + const newState = comment.isDraft ? vscode.CommentState.Draft : vscode.CommentState.Published; + if (this.label !== oldLabel || this.state !== newState) { + this.state = newState; + refresh = true; + } + + const contextValues: string[] = []; + if (comment.canEdit) { + contextValues.push('canEdit'); + } + + if (comment.canDelete) { + contextValues.push('canDelete'); + } + + if (this.suggestion !== undefined) { + contextValues.push('hasSuggestion'); + } + + const oldContextValue = this.contextValue; + this.contextValue = contextValues.join(','); + if (oldContextValue !== this.contextValue) { + refresh = true; + } + + // Set the comment body last as it will trigger an update if set. + if (oldRawComment.body !== comment.body) { + this.doSetBody(comment.body, true); + refresh = false; + } + + if (refresh) { + this.refresh(); + } + } + + private refresh() { + // Self assign the comments to trigger an update of the comments in VS Code now that we have replaced the body. + // eslint-disable-next-line no-self-assign + this.parent.comments = this.parent.comments; + } + get suggestion(): string | undefined { - const suggestionBody = this.rawComment.body.match(SUGGESTION_EXPRESSION); - if (suggestionBody?.length === 4) { - return suggestionBody[2]; + const match = this.rawComment.body.match(SUGGESTION_EXPRESSION); + const suggestionBody = match?.groups?.suggestion; + if (match) { + return suggestionBody ? suggestionBody : ''; } + return undefined; } public commentEditId() { return this.commentId; } + private replaceImg(body: string) { + return body.replace(IMG_EXPRESSION, (_substring, _1, _2, _3, { src }) => { + return `![image](${src})`; + }); + } + private replaceSuggestion(body: string) { return body.replace(new RegExp(SUGGESTION_EXPRESSION, 'g'), (_substring: string, ...args: any[]) => { return `*** Suggested change: \`\`\` -${args[1]} +${args[3] ?? ''} \`\`\` ***`; }); } + private async createLocalFilePath(rootUri: vscode.Uri, fileSubPath: string, startLine: number, endLine: number): Promise { + const localFile = vscode.Uri.joinPath(rootUri, fileSubPath); + try { + const stat = await vscode.workspace.fs.stat(localFile); + if (stat.type === vscode.FileType.File) { + return `${localFile.with({ fragment: `${startLine}-${endLine}` }).toString()}`; + } + } catch (e) { + return undefined; + } + } + private async replacePermalink(body: string): Promise { - if (!this.githubRepository) { + const githubRepository = this.githubRepository; + if (!githubRepository) { return body; } - const expression = new RegExp(`https://github.com/${this.githubRepository.remote.owner}/${this.githubRepository.remote.repositoryName}/blob/([0-9a-f]{40})/(.*)#L([0-9]+)-L([0-9]+)`, 'g'); - return stringReplaceAsync(body, expression, async (match: string, sha: string, file: string, start: string, end: string) => { + const repoName = escapeRegExp(githubRepository.remote.repositoryName); + const expression = new RegExp(`https://github.com/(.+)/${repoName}/blob/([0-9a-f]{40})/(.*)#L([0-9]+)(-L([0-9]+))?`, 'g'); + return stringReplaceAsync(body, expression, async (match: string, owner: string, sha: string, file: string, start: string, _endGroup?: string, end?: string, index?: number) => { + if (index && (index > 0) && (body.charAt(index - 1) === '(')) { + return match; + } + const startLine = parseInt(start); - const endLine = parseInt(end); - const lineContents = await this.githubRepository!.getLines(sha, file, startLine, endLine); + const endLine = end ? parseInt(end) : startLine + 1; + const lineContents = await githubRepository.getLines(sha, file, startLine, endLine); if (!lineContents) { return match; } - return `*** -[${file}](${match}) + const localFile = await this.createLocalFilePath(githubRepository.rootUri, file, startLine, endLine); + const lineMessage = end ? `Lines ${startLine} to ${endLine} in \`${sha.substring(0, 7)}\`` : `Line ${startLine} in \`${sha.substring(0, 7)}\``; + return ` +*** +[${file}](${localFile ?? match})${localFile ? ` ([view on GitHub](${match}))` : ''} -Lines ${startLine} to ${endLine} in \`${sha.substring(0, 7)}\` +${lineMessage} \`\`\` ${lineContents} \`\`\` @@ -274,38 +413,142 @@ ${lineContents} }); } + private replaceImages(body: string): string { + const html = this.rawComment.bodyHTML; + if (!html) { + return body; + } + + return replaceImages(body, html, this.githubRepository?.remote.host); + } + + private replaceCommitShas(body: string): string { + const githubRepository = this.githubRepository; + if (!githubRepository) { + return body; + } + + // Match commit SHAs that are: + // - Either 7 or 40 hex characters + // - Not already part of a URL or markdown link + // - Not inside code blocks (backticks) + return body.replace(COMMIT_SHA_EXPRESSION, (match, shortSha, remaining, offset) => { + // Don't replace if inside code blocks + const beforeMatch = body.substring(0, offset); + const backtickCount = (beforeMatch.match(/`/g)?.length ?? 0); + if (backtickCount % 2 === 1) { + return match; + } + + // Don't replace if already part of a markdown link + if (beforeMatch.endsWith('[') || body.substring(offset + match.length).startsWith(']')) { + return match; + } + + const owner = githubRepository.remote.owner; + const repo = githubRepository.remote.repositoryName; + const commitUrl = `https://${githubRepository.remote.host}/${owner}/${repo}/commit/${match}`; + return `[${shortSha}](${commitUrl})`; + }); + } + + private replaceNewlines(body: string) { + return body.replace(/(? { + const emojiPromise = ensureEmojis(this.context); + Logger.trace('Replace comment body', GHPRComment.ID); if (body instanceof vscode.MarkdownString) { const permalinkReplaced = await this.replacePermalink(body.value); - return this.replaceSuggestion(permalinkReplaced); + return this.replaceImg(this.replaceSuggestion(permalinkReplaced)); } - const linkified = body.replace(/([^\[]|^)\@([^\s]+)/, (substring) => { + const imagesReplaced = this.replaceImages(body); + const newLinesReplaced = this.replaceNewlines(imagesReplaced); + const documentLanguage = (await vscode.workspace.openTextDocument(this.parent.uri)).languageId; + const replacerRegex = new RegExp(`([^/\[\`]|^)@(${ALLOWED_USERS})`, 'g'); + // Replace user + const linkified = newLinesReplaced.replace(replacerRegex, (substring, _1, _2, offset) => { + // Do not try to replace user if there's a code block. + if ((newLinesReplaced.substring(0, offset).match(/```/g)?.length ?? 0) % 2 === 1) { + return substring; + } + // Do not try to replace user if it might already be part of a link + if (substring.includes(']') || substring.includes(')')) { + return substring; + } + const username = substring.substring(substring.startsWith('@') ? 1 : 2); - return `${substring.startsWith('@') ? '' : substring.charAt(0)}[@${username}](${path.dirname(this.rawComment.user!.url)}/${username})`; + if ((((documentLanguage === 'javascript') || (documentLanguage === 'typescript')) && JSDOC_NON_USERS.includes(username)) + || ((documentLanguage === 'php') && PHPDOC_NON_USERS.includes(username))) { + return substring; + } + const url = COPILOT_ACCOUNTS[username]?.url ?? `${path.dirname(this.rawComment.user!.url)}/${username}`; + return `${substring.startsWith('@') ? '' : substring.charAt(0)}[@${username}](${url})`; }); - const permalinkReplaced = await this.replacePermalink(linkified); - return this.replaceSuggestion(permalinkReplaced); + const commitShasReplaced = this.replaceCommitShas(linkified); + const permalinkReplaced = await this.replacePermalink(commitShasReplaced); + await emojiPromise; + return this.postpendSpecialAuthorComment(emojify(this.replaceImg(this.replaceSuggestion(permalinkReplaced)))); } - set body(body: string | vscode.MarkdownString) { + protected async doSetBody(body: string | vscode.MarkdownString, refresh: boolean) { this._rawBody = body; - this.replaceBody(body).then(replacedBody => { + const replacedBody = await this.replaceBody(body); + + if (replacedBody !== this.replacedBody) { this.replacedBody = replacedBody; - // Self assign the comments to trigger an update of the comments in VS Code now that we have replaced the body. - // eslint-disable-next-line no-self-assign - this.parent.comments = this.parent.comments; - }); + if (refresh) { + this.refresh(); + } + } + } + + set body(body: string | vscode.MarkdownString) { + this.doSetBody(body, false); } get body(): string | vscode.MarkdownString { 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; + } +} + +export function replaceImages(markdownBody: string, htmlBody: string, host: string = 'github.com') { + const originalExpression = new RegExp(`https:\/\/${host}\/.+\/assets\/([^\/]+\/)?(?${UUID_EXPRESSION.source})`); + let originalMatch = markdownBody.match(originalExpression); + const htmlHost = escapeRegExp(host === 'github.com' ? 'githubusercontent.com' : host); + + while (originalMatch) { + if (originalMatch.groups?.uuid) { + const uuid = escapeRegExp(originalMatch.groups.uuid); + const htmlExpression = new RegExp(`https:\/\/([^"]*${htmlHost})\/[^?]+${uuid}[^"]+`); + const htmlMatch = htmlBody.match(htmlExpression); + if (htmlMatch && htmlMatch[0]) { + markdownBody = markdownBody.replace(originalMatch[0], htmlMatch[0]); + } else { + return markdownBody; + } + } + originalMatch = markdownBody.match(originalExpression); } + return markdownBody; } diff --git a/src/github/pullRequestGitHelper.ts b/src/github/pullRequestGitHelper.ts index 5b67cb4557..74239cf974 100644 --- a/src/github/pullRequestGitHelper.ts +++ b/src/github/pullRequestGitHelper.ts @@ -7,15 +7,17 @@ * 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 { GitErrorCodes } from '../api/api1'; import Logger from '../common/logger'; import { Protocol } from '../common/protocol'; import { parseRepositoryRemotes, Remote } from '../common/remote'; -import { IResolvedPullRequestModel, PullRequestModel } from './pullRequestModel'; +import { PR_SETTINGS_NAMESPACE, PULL_PR_BRANCH_BEFORE_CHECKOUT, PullPRBranchVariants } from '../common/settingKeys'; const PullRequestRemoteMetadataKey = 'github-pr-remote'; export const PullRequestMetadataKey = 'github-pr-owner-number'; +const BaseBranchMetadataKey = 'github-pr-base-branch'; +const VscodeBaseBranchMetadataKey = 'vscode-merge-base'; const PullRequestBranchRegex = /branch\.(.+)\.github-pr-owner-number/; const PullRequestRemoteRegex = /branch\.(.+)\.remote/; @@ -25,6 +27,19 @@ export interface PullRequestMetadata { prNumber: number; } +export interface BaseBranchMetadata { + owner: string; + repositoryName: string; + branch: string; +} + +export type BranchInfo = { + branch: string; + remote?: string; + createdForPullRequest?: boolean; + remoteInUse?: boolean; +}; + export class PullRequestGitHelper { static ID = 'PullRequestGitHelper'; static async checkoutFromFork( @@ -54,14 +69,12 @@ export class PullRequestGitHelper { const ref = `${pullRequest.head.ref}:${localBranchName}`; Logger.debug(`Fetch ${remoteName}/${pullRequest.head.ref}:${localBranchName} - start`, PullRequestGitHelper.ID); progress.report({ message: vscode.l10n.t('Fetching branch {0}', ref) }); - await repository.fetch(remoteName, ref, 1); + await repository.fetch(remoteName, ref); Logger.debug(`Fetch ${remoteName}/${pullRequest.head.ref}:${localBranchName} - done`, PullRequestGitHelper.ID); progress.report({ message: vscode.l10n.t('Checking out {0}', ref) }); await repository.checkout(localBranchName); // set remote tracking branch for the local branch await repository.setBranchUpstream(localBranchName, `refs/remotes/${remoteName}/${pullRequest.head.ref}`); - // Don't await unshallow as the whole point of unshallowing and only fetching to depth 1 above is so that we can unshallow without slowwing down checkout later. - this.unshallow(repository); await PullRequestGitHelper.associateBranchWithPullRequest(repository, pullRequest, localBranchName); } @@ -81,89 +94,67 @@ export class PullRequestGitHelper { return PullRequestGitHelper.checkoutFromFork(repository, pullRequest, remote && remote.remoteName, progress); } - const branchName = pullRequest.head.ref; + const originalBranchName = pullRequest.head.ref; const remoteName = remote.remoteName; let branch: Branch; + let localBranchName = originalBranchName; // This will be the branch we actually checkout + + // Always fetch the remote branch first to ensure we have the latest commits + const trackedBranchName = `refs/remotes/${remoteName}/${originalBranchName}`; + Logger.appendLine(`Fetch tracked branch ${trackedBranchName}`, PullRequestGitHelper.ID); + progress.report({ message: vscode.l10n.t('Fetching branch {0}', originalBranchName) }); + await repository.fetch(remoteName, originalBranchName); + const trackedBranch = await repository.getBranch(trackedBranchName); try { - branch = await repository.getBranch(branchName); + branch = await repository.getBranch(localBranchName); + // Check if local branch is pointing to the same commit as the remote + if (branch.commit !== trackedBranch.commit) { + Logger.appendLine(`Local branch ${localBranchName} commit ${branch.commit} differs from remote commit ${trackedBranch.commit}. Creating new branch to avoid overwriting user's work.`, PullRequestGitHelper.ID); + // Instead of deleting the user's branch, create a unique branch name to avoid conflicts + const uniqueBranchName = await PullRequestGitHelper.calculateUniqueBranchNameForPR(repository, pullRequest); + Logger.appendLine(`Creating branch ${uniqueBranchName} for PR checkout`, PullRequestGitHelper.ID); + progress.report({ message: vscode.l10n.t('Creating branch {0} for pull request', uniqueBranchName) }); + await repository.createBranch(uniqueBranchName, false, trackedBranch.commit); + await repository.setBranchUpstream(uniqueBranchName, trackedBranchName); + // Use the unique branch name for checkout + localBranchName = uniqueBranchName; + branch = await repository.getBranch(localBranchName); + } + // Make sure we aren't already on this branch if (repository.state.HEAD?.name === branch.name) { - Logger.appendLine(`Tried to checkout ${branchName}, but branch is already checked out.`, PullRequestGitHelper.ID); + Logger.appendLine(`Tried to checkout ${localBranchName}, but branch is already checked out.`, PullRequestGitHelper.ID); return; } - Logger.debug(`Checkout ${branchName}`, PullRequestGitHelper.ID); - progress.report({ message: vscode.l10n.t('Checking out {0}', branchName) }); - await repository.checkout(branchName); + + Logger.debug(`Checkout ${localBranchName}`, PullRequestGitHelper.ID); + progress.report({ message: vscode.l10n.t('Checking out {0}', localBranchName) }); + await repository.checkout(localBranchName); if (!branch.upstream) { // this branch is not associated with upstream yet - const trackedBranchName = `refs/remotes/${remoteName}/${branchName}`; - await repository.setBranchUpstream(branchName, trackedBranchName); + await repository.setBranchUpstream(localBranchName, trackedBranchName); } if (branch.behind !== undefined && branch.behind > 0 && branch.ahead === 0) { Logger.debug(`Pull from upstream`, PullRequestGitHelper.ID); - progress.report({ message: vscode.l10n.t('Pulling {0}', branchName) }); + progress.report({ message: vscode.l10n.t('Pulling {0}', localBranchName) }); await repository.pull(); } } catch (err) { - // there is no local branch with the same name, so we are good to fetch, create and checkout the remote branch. + // there is no local branch with the same name, so we are good to create and checkout the remote branch. Logger.appendLine( - `Branch ${remoteName}/${branchName} doesn't exist on local disk yet.`, + `Branch ${localBranchName} doesn't exist on local disk yet. Creating from remote.`, PullRequestGitHelper.ID, ); - const trackedBranchName = `refs/remotes/${remoteName}/${branchName}`; - Logger.appendLine(`Fetch tracked branch ${trackedBranchName}`, PullRequestGitHelper.ID); - progress.report({ message: vscode.l10n.t('Fetching branch {0}', branchName) }); - await repository.fetch(remoteName, branchName, 1); - const trackedBranch = await repository.getBranch(trackedBranchName); // create branch - progress.report({ message: vscode.l10n.t('Creating and checking out branch {0}', branchName) }); - await repository.createBranch(branchName, true, trackedBranch.commit); - await repository.setBranchUpstream(branchName, trackedBranchName); - - // Don't await unshallow as the whole point of unshallowing and only fetching to depth 1 above is so that we can unshallow without slowwing down checkout later. - this.unshallow(repository); + progress.report({ message: vscode.l10n.t('Creating and checking out branch {0}', localBranchName) }); + await repository.createBranch(localBranchName, true, trackedBranch.commit); + await repository.setBranchUpstream(localBranchName, trackedBranchName); } - await PullRequestGitHelper.associateBranchWithPullRequest(repository, pullRequest, branchName); - } - - /** - * Attempt to unshallow the repository. If it has been unshallowed in the interim, running with `--unshallow` - * will fail, so fall back to a normal pull. - */ - static async unshallow(repository: Repository): Promise { - let error: Error & { gitErrorCode?: GitErrorCodes }; - try { - await repository.pull(true); - return; - } catch (e) { - Logger.appendLine(`Unshallowing failed: ${e}.`); - if (e.stderr && (e.stderr as string).includes('would clobber existing tag')) { - // ignore this error - return; - } - error = e; - } - try { - if (error.gitErrorCode === GitErrorCodes.DirtyWorkTree) { - Logger.appendLine(`Getting status and trying unshallow again.`); - await repository.status(); - await repository.pull(true); - return; - } - } catch (e) { - Logger.appendLine(`Unshallowing still failed: ${e}.`); - } - try { - Logger.appendLine(`Falling back to git pull.`); - await repository.pull(false); - } catch (e) { - Logger.error(`Pull after failed unshallow still failed: ${e}`); - throw e; - } + await PullRequestGitHelper.associateBranchWithPullRequest(repository, pullRequest, localBranchName); } static async checkoutExistingPullRequestBranch(repository: Repository, pullRequest: PullRequestModel, progress: vscode.Progress<{ message?: string; increment?: number }>) { @@ -188,14 +179,21 @@ export class PullRequestGitHelper { const branchName = branchInfos[0].branch!; progress.report({ message: vscode.l10n.t('Checking out branch {0}', branchName) }); await repository.checkout(branchName); - const remote = readConfig(`branch.${branchName}.remote`); - const ref = readConfig(`branch.${branchName}.merge`); - progress.report({ message: vscode.l10n.t('Fetching branch {0}', branchName) }); - await repository.fetch(remote, ref); + + // respect the git setting to fetch before checkout + const settingValue = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(PULL_PR_BRANCH_BEFORE_CHECKOUT, 'pull'); + if (settingValue === 'pull' || settingValue === 'pullAndMergeBase' || settingValue === 'pullAndUpdateBase' || settingValue === true) { + const remote = readConfig(`branch.${branchName}.remote`); + const ref = readConfig(`branch.${branchName}.merge`); + progress.report({ message: vscode.l10n.t('Fetching branch {0}', branchName) }); + await repository.fetch(remote, ref); + } + const branchStatus = await repository.getBranch(branchInfos[0].branch!); if (branchStatus.upstream === undefined) { return false; } + if (branchStatus.behind !== undefined && branchStatus.behind > 0 && branchStatus.ahead === 0) { Logger.debug(`Pull from upstream`, PullRequestGitHelper.ID); progress.report({ message: vscode.l10n.t('Pulling branch {0}', branchName) }); @@ -211,30 +209,26 @@ export class PullRequestGitHelper { static async getBranchNRemoteForPullRequest( repository: Repository, pullRequest: PullRequestModel, - ): Promise<{ - branch: string; - remote?: string; - createdForPullRequest?: boolean; - remoteInUse?: boolean; - } | null> { - const key = PullRequestGitHelper.buildPullRequestMetadata(pullRequest); - const configs = await repository.getConfigs(); - - const branchInfo = configs - .map(config => { - const matches = PullRequestBranchRegex.exec(config.key); - return { - branch: matches && matches.length ? matches[1] : null, - value: config.value, - }; - }) - .find(c => !!c.branch && c.value === key); - - if (branchInfo) { - // we find the branch - const branchName = branchInfo.branch; + ): Promise { + let branchName: string | null = null; + try { + const key = PullRequestGitHelper.buildPullRequestMetadata(pullRequest); + const configs = await repository.getConfigs(); + + const branchInfo = configs + .map(config => { + const matches = PullRequestBranchRegex.exec(config.key); + return { + branch: matches && matches.length ? matches[1] : null, + value: config.value, + }; + }) + .find(c => !!c.branch && c.value === key); + + if (branchInfo) { + // we find the branch + branchName = branchInfo.branch; - try { const configKey = `branch.${branchName}.remote`; const branchRemotes = configs.filter(config => config.key === configKey).map(config => config.value); let remoteName: string | undefined = undefined; @@ -275,20 +269,43 @@ export class PullRequestGitHelper { createdForPullRequest, remoteInUse, }; - } catch (_) { + } + + } catch (e) { + if (branchName) { return { branch: branchName!, }; + } else { + Logger.error(`getBranchNRemoteForPullRequest failed ${e}`, PullRequestGitHelper.ID); + return null; } } - return null; } - static buildPullRequestMetadata(pullRequest: PullRequestModel) { + static async getEmail(repository: Repository): Promise { + try { + const email = await repository.getConfig('user.email'); + if (email) { + return email; + } + const globalEmail = await repository.getGlobalConfig('user.email'); + return globalEmail; + } catch (e) { + // email config doesn't exist + return undefined; + } + } + + private static buildPullRequestMetadata(pullRequest: PullRequestModel) { return `${pullRequest.base.repositoryCloneUrl.owner}#${pullRequest.base.repositoryCloneUrl.repositoryName}#${pullRequest.number}`; } + private static buildBaseBranchMetadata(owner: string, repository: string, baseBranch: string) { + return `${owner}#${repository}#${baseBranch}`; + } + static parsePullRequestMetadata(value: string): PullRequestMetadata | undefined { if (value) { const matches = /(.*)#(.*)#(.*)/g.exec(value); @@ -304,7 +321,7 @@ export class PullRequestGitHelper { return undefined; } - static getMetadataKeyForBranch(branchName: string): string { + private static getMetadataKeyForBranch(branchName: string): string { return `branch.${branchName}.${PullRequestMetadataKey}`; } @@ -314,8 +331,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; } @@ -342,10 +360,7 @@ export class PullRequestGitHelper { static async isRemoteCreatedForPullRequest(repository: Repository, remoteName: string) { try { - Logger.debug( - `Check if remote '${remoteName}' is created for pull request - start`, - PullRequestGitHelper.ID, - ); + Logger.debug(`Check if remote '${remoteName}' is created for pull request - start`, PullRequestGitHelper.ID); const isForPR = await repository.getConfig(`remote.${remoteName}.${PullRequestRemoteMetadataKey}`); Logger.debug(`Check if remote '${remoteName}' is created for pull request - end`, PullRequestGitHelper.ID); return isForPR === 'true'; @@ -398,11 +413,47 @@ export class PullRequestGitHelper { static async associateBranchWithPullRequest( repository: Repository, - pullRequest: PullRequestModel, + pullRequest: PullRequestModel | undefined, branchName: string, ) { - Logger.appendLine(`associate ${branchName} with Pull Request #${pullRequest.number}`, PullRequestGitHelper.ID); - const prConfigKey = `branch.${branchName}.${PullRequestMetadataKey}`; - await repository.setConfig(prConfigKey, PullRequestGitHelper.buildPullRequestMetadata(pullRequest)); + try { + if (pullRequest) { + Logger.appendLine(`associate ${branchName} with Pull Request #${pullRequest.number}`, PullRequestGitHelper.ID); + } + const prConfigKey = `branch.${branchName}.${PullRequestMetadataKey}`; + if (pullRequest) { + await repository.setConfig(prConfigKey, PullRequestGitHelper.buildPullRequestMetadata(pullRequest)); + } else if (repository.unsetConfig) { + await repository.unsetConfig(prConfigKey); + } + } catch (e) { + if (pullRequest) { + Logger.error(`associate ${branchName} with Pull Request #${pullRequest.number} failed`, PullRequestGitHelper.ID); + } + } + } + + static async associateBaseBranchWithBranch( + repository: Repository, + branch: string, + base: { + owner: string, + repo: string, + branch: string + } | undefined + ) { + try { + const prConfigKey = `branch.${branch}.${BaseBranchMetadataKey}`; + if (base) { + Logger.appendLine(`associate ${branch} with base branch ${base.owner}/${base.repo}#${base.branch}`, PullRequestGitHelper.ID); + await repository.setConfig(prConfigKey, PullRequestGitHelper.buildBaseBranchMetadata(base.owner, base.repo, base.branch)); + } else if (repository.unsetConfig) { + await repository.unsetConfig(prConfigKey); + const vscodeBaseBranchConfigKey = `branch.${branch}.${VscodeBaseBranchMetadataKey}`; + await repository.unsetConfig(vscodeBaseBranchConfigKey); + } + } catch (e) { + Logger.error(`associate ${branch} with base branch ${base?.owner}/${base?.repo}#${base?.branch} failed`, PullRequestGitHelper.ID); + } } } diff --git a/src/github/pullRequestModel.ts b/src/github/pullRequestModel.ts index d4405fb578..f37f2feda8 100644 --- a/src/github/pullRequestModel.ts +++ b/src/github/pullRequestModel.ts @@ -6,69 +6,98 @@ import * as buffer from 'buffer'; import * as path from 'path'; import equals from 'fast-deep-equal'; +import gql from 'graphql-tag'; import * as vscode from 'vscode'; -import { DiffSide, IComment, IReviewThread, ViewedState } from '../common/comment'; -import { parseDiff } from '../common/diffHunk'; -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 { ReviewEvent as CommonReviewEvent, EventType, TimelineEvent } from '../common/timelineEvent'; -import { resolvePath, toPRUri, toReviewUri } from '../common/uri'; -import { formatError } from '../common/utils'; 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, + AddReviewRequestResponse as AddReviewsResponse, AddReviewThreadResponse, + ConvertPullRequestToDraftResponse, DeleteReactionResponse, DeleteReviewResponse, + DequeuePullRequestResponse, EditCommentResponse, + EnqueuePullRequestResponse, + FileContentResponse, + GetReviewRequestsResponse, + MergeMethod as GraphQLMergeMethod, LatestReviewCommitResponse, - LatestReviewsResponse, MarkPullRequestReadyForReviewResponse, + MergePullRequestInput, + MergePullRequestResponse, PendingReviewIdResponse, PullRequestCommentsResponse, PullRequestFilesResponse, PullRequestMergabilityResponse, ReactionGroup, ResolveReviewThreadResponse, + ReviewThread, StartReviewResponse, SubmitReviewResponse, TimelineEventsResponse, UnresolveReviewThreadResponse, - UpdatePullRequestResponse, + UpdateIssueResponse, + UpdatePullRequestBranchResponse, } from './graphql'; import { - CheckState, + AccountType, + ConvertToDraft, GithubItemStateEnum, IAccount, + IGitTreeItem, IRawFileChange, + IRawFileContent, ISuggestedReviewer, + ITeam, MergeMethod, + MergeQueueEntry, PullRequest, PullRequestChecks, PullRequestMergeability, - ReviewEvent, + PullRequestReviewRequirement, + ReadyForReview, + ReviewEventEnum, } from './interface'; -import { IssueModel } from './issueModel'; +import { IssueChangeEvent, IssueModel } from './issueModel'; +import { compareCommits } from './loggingOctokit'; import { convertRESTPullRequestToRawPullRequest, convertRESTReviewEvent, - convertRESTUserToAccount, getReactionGroup, insertNewCommitsSinceReview, + parseAccount, + parseCombinedTimelineEvents, parseGraphQLComment, parseGraphQLReaction, + parseGraphQLReviewers, parseGraphQLReviewEvent, parseGraphQLReviewThread, - parseGraphQLTimelineEvents, parseMergeability, + parseMergeQueueEntry, + 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; @@ -91,40 +120,44 @@ export interface FileViewedStateChangeEvent { }[]; } -export const REVIEW_REQUIRED_CHECK_ID = 'reviewRequired'; - export type FileViewedState = { [key: string]: ViewedState }; +type TreeDataMode = '100644' | '100755' | '120000'; + +const BATCH_SIZE = 50; + export class PullRequestModel extends IssueModel implements IPullRequestModel { - static ID = 'PullRequestModel'; + static override ID = 'PullRequestModel'; public isDraft?: boolean; + public reviewers?: (IAccount | ITeam)[]; public localBranchName?: string; public mergeBase?: string; + public mergeQueueEntry?: MergeQueueEntry; + public conflicts?: string[]; public suggestedReviewers?: ISuggestedReviewer[]; public hasChangesSinceLastReview?: boolean; private _showChangesSinceReview: boolean; private _hasPendingReview: boolean = false; - private _onDidChangePendingReviewState: vscode.EventEmitter = new vscode.EventEmitter(); + private _onDidChangePendingReviewState: vscode.EventEmitter = this._register(new vscode.EventEmitter()); public onDidChangePendingReviewState = this._onDidChangePendingReviewState.event; - private _reviewThreadsCache: IReviewThread[] = []; + private _reviewThreadsCache: IReviewThread[] | undefined; private _reviewThreadsCacheInitialized = false; - private _onDidChangeReviewThreads = new vscode.EventEmitter(); + private _onDidChangeReviewThreads = this._register(new vscode.EventEmitter()); public onDidChangeReviewThreads = this._onDidChangeReviewThreads.event; private _fileChangeViewedState: FileViewedState = {}; private _viewedFiles: Set = new Set(); private _unviewedFiles: Set = new Set(); - private _onDidChangeFileViewedState = new vscode.EventEmitter(); + private _onDidChangeFileViewedState = this._register(new vscode.EventEmitter()); public onDidChangeFileViewedState = this._onDidChangeFileViewedState.event; - private _onDidChangeChangesSinceReview = new vscode.EventEmitter(); + private _onDidChangeChangesSinceReview = this._register(new vscode.EventEmitter()); public onDidChangeChangesSinceReview = this._onDidChangeChangesSinceReview.event; - private _comments: IComment[] | undefined; - private _onDidChangeComments: vscode.EventEmitter = new vscode.EventEmitter(); - public readonly onDidChangeComments: vscode.Event = this._onDidChangeComments.event; + private _hasComments: boolean; + private _comments: readonly IComment[] | undefined; // Whether the pull request is currently checked out locally private _isActive: boolean; @@ -135,18 +168,17 @@ export class PullRequestModel extends IssueModel implements IPullRe this._isActive = isActive; } - _telemetry: ITelemetry; constructor( + private readonly credentialStore: CredentialStore, telemetry: ITelemetry, githubRepository: GitHubRepository, remote: Remote, item: PullRequest, isActive?: boolean, ) { - super(githubRepository, remote, item, true); + super(telemetry, githubRepository, remote, item, true); - this._telemetry = telemetry; this.isActive = !!isActive; this._showChangesSinceReview = false; @@ -157,26 +189,23 @@ export class PullRequestModel extends IssueModel implements IPullRe public clear() { this.comments = []; this._reviewThreadsCacheInitialized = false; - this._reviewThreadsCache = []; + this._reviewThreadsCache = undefined; } - public async initializeReviewThreadCache(): Promise { - await this.getReviewThreads(); + public async initializeReviewThreadCache(): Promise { + const threads = await this.getReviewThreads(); this._reviewThreadsCacheInitialized = true; + return threads; } public get reviewThreadsCache(): IReviewThread[] { - return this._reviewThreadsCache; + return this._reviewThreadsCache ?? []; } public get reviewThreadsCacheReady(): boolean { return this._reviewThreadsCacheInitialized; } - public get isMerged(): boolean { - return this.state === GithubItemStateEnum.Merged; - } - public get hasPendingReview(): boolean { return this._hasPendingReview; } @@ -193,17 +222,20 @@ export class PullRequestModel extends IssueModel implements IPullRe } public set showChangesSinceReview(isChangesSinceReview: boolean) { - this._showChangesSinceReview = isChangesSinceReview; - this._onDidChangeChangesSinceReview.fire(); + if (this._showChangesSinceReview !== isChangesSinceReview) { + this._showChangesSinceReview = isChangesSinceReview; + this._fileChanges.clear(); + this._onDidChangeChangesSinceReview.fire(); + } } - get comments(): IComment[] { + get comments(): readonly IComment[] { return this._comments ?? []; } - set comments(comments: IComment[]) { + set comments(comments: readonly IComment[]) { this._comments = comments; - this._onDidChangeComments.fire(); + this._onDidChange.fire({ comments: true }); } get fileChangeViewedState(): FileViewedState { @@ -215,32 +247,45 @@ export class PullRequestModel extends IssueModel implements IPullRe public isRemoteBaseDeleted?: boolean; public base: GitHubRef; - protected updateState(state: string) { + protected override stateToStateEnum(state: string) { + let newState = GithubItemStateEnum.Closed; if (state.toLowerCase() === 'open') { - this.state = GithubItemStateEnum.Open; - } else { - this.state = this.item.merged ? GithubItemStateEnum.Merged : GithubItemStateEnum.Closed; + newState = GithubItemStateEnum.Open; + } else if (state.toLowerCase() === 'merged' || this.item.merged) { + newState = GithubItemStateEnum.Merged; } + return newState; } - update(item: PullRequest): void { - super.update(item); - this.isDraft = item.isDraft; + protected override doUpdate(item: PullRequest): IssueChangeEvent { + const changes = super.doUpdate(item) as IssueChangeEvent; + if (this.isDraft !== item.isDraft) { + changes.draft = true; + this.isDraft = item.isDraft; + } + this.suggestedReviewers = item.suggestedReviewers; if (item.isRemoteHeadDeleted != null) { this.isRemoteHeadDeleted = item.isRemoteHeadDeleted; } if (item.head) { - this.head = new GitHubRef(item.head.ref, item.head.label, item.head.sha, item.head.repo.cloneUrl, item.head.repo.owner, item.head.repo.name); + this.head = new GitHubRef(item.head.ref, item.head.label, item.head.sha, item.head.repo.cloneUrl, item.head.repo.owner, item.head.repo.name, item.head.repo.isInOrganization); } if (item.isRemoteBaseDeleted != null) { this.isRemoteBaseDeleted = item.isRemoteBaseDeleted; } if (item.base) { - this.base = new GitHubRef(item.base.ref, item.base!.label, item.base!.sha, item.base!.repo.cloneUrl, item.base.repo.owner, item.base.repo.name); + this.base = new GitHubRef(item.base.ref, item.base!.label, item.base!.sha, item.base!.repo.cloneUrl, item.base.repo.owner, item.base.repo.name, item.base.repo.isInOrganization); } + if (item.mergeQueueEntry !== undefined) { + this.mergeQueueEntry = item.mergeQueueEntry ?? undefined; + } + if (item.hasComments !== undefined) { + this._hasComments = item.hasComments; + } + return changes; } /** @@ -278,21 +323,49 @@ export class PullRequestModel extends IssueModel implements IPullRe return false; } + protected override updateIssueInput(id: string): Object { + return { + pullRequestId: id, + }; + } + + protected override updateIssueSchema(schema: any): any { + return schema.UpdatePullRequest; + } + /** * Approve the pull request. * @param message Optional approval comment text. */ - async approve(message?: string): Promise { - const action: Promise = (await this.getPendingReviewId()) - ? this.submitReview(ReviewEvent.Approve, message) - : this.createReview(ReviewEvent.Approve, message); + async approve(repository: Repository, message?: string): Promise { + // Check that the remote head of the PR branch matches the local head of the PR branch + let remoteHead: string | undefined; + let localHead: string | undefined; + let rejectMessage: string | undefined; + if (this.isActive) { + localHead = repository.state.HEAD?.commit; + remoteHead = (await this.githubRepository.getPullRequest(this.number))?.head?.sha; + rejectMessage = vscode.l10n.t('The remote head of the pull request branch has changed. Please pull the latest changes from the remote branch before approving.'); + } else { + localHead = this.head?.sha; + remoteHead = (await this.githubRepository.getPullRequest(this.number))?.head?.sha; + rejectMessage = vscode.l10n.t('The remote head of the pull request branch has changed. Please refresh the pull request before approving.'); + } + + if (!remoteHead || remoteHead !== localHead) { + return Promise.reject(rejectMessage); + } + + const action: Promise = (await this.getPendingReviewId()) + ? this.submitReview(ReviewEventEnum.Approve, message) + : this.createReview(ReviewEventEnum.Approve, message); return action.then(x => { /* __GDPR__ "pr.approve" : {} */ this._telemetry.sendTelemetryEvent('pr.approve'); - this._onDidChangeComments.fire(); + this._onDidChange.fire({ comments: true, timeline: true }); return x; }); } @@ -301,25 +374,117 @@ export class PullRequestModel extends IssueModel implements IPullRe * Request changes on the pull request. * @param message Optional comment text to leave with the review. */ - async requestChanges(message?: string): Promise { - const action: Promise = (await this.getPendingReviewId()) - ? this.submitReview(ReviewEvent.RequestChanges, message) - : this.createReview(ReviewEvent.RequestChanges, message); + async requestChanges(message?: string): Promise { + const action: ReviewEvent = (await this.getPendingReviewId()) + ? await this.submitReview(ReviewEventEnum.RequestChanges, message) + : await this.createReview(ReviewEventEnum.RequestChanges, message); - return action.then(x => { - /* __GDPR__ - "pr.requestChanges" : {} + /* __GDPR__ + "pr.requestChanges" : {} + */ + this._telemetry.sendTelemetryEvent('pr.requestChanges'); + this._onDidChange.fire({ timeline: true, comments: true }); + 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.requestChanges'); - this._onDidChangeComments.fire(); - return x; - }); + 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.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. */ - async close(): Promise { + override async close(): Promise<{ item: PullRequest; closedEvent: ClosedEvent }> { const { octokit, remote } = await this.githubRepository.ensure(); const ret = await octokit.call(octokit.api.pulls.update, { owner: remote.owner, @@ -332,8 +497,25 @@ export class PullRequestModel extends IssueModel implements IPullRe "pr.close" : {} */ this._telemetry.sendTelemetryEvent('pr.close'); + const user = await this.githubRepository.getAuthenticatedUser(); + this.state = this.stateToStateEnum(ret.data.state); + + // Fire the event with a delay as GitHub needs some time to propagate the changes, we want to make sure any listeners of the event will get the right info when they query + setTimeout(() => this._onDidChange.fire({ state: true }), 1500); - return convertRESTPullRequestToRawPullRequest(ret.data, this.githubRepository); + return { + item: convertRESTPullRequestToRawPullRequest(ret.data, this.githubRepository), + closedEvent: { + createdAt: ret.data.closed_at ?? '', + event: EventType.Closed, + id: `${ret.data.id}`, + actor: { + login: user.login, + avatarUrl: user.avatarUrl, + url: user.url + } + } + }; } /** @@ -341,7 +523,7 @@ export class PullRequestModel extends IssueModel implements IPullRe * @param event The type of review to create, an approval, request for changes, or comment. * @param message The summary comment text. */ - private async createReview(event: ReviewEvent, message?: string): Promise { + private async createReview(event: ReviewEventEnum, message?: string): Promise { const { octokit, remote } = await this.githubRepository.ensure(); const { data } = await octokit.call(octokit.api.pulls.createReview, { @@ -352,7 +534,8 @@ export class PullRequestModel extends IssueModel implements IPullRe body: message, }); - return convertRESTReviewEvent(data, this.githubRepository); + this._onDidChange.fire({ timeline: true }); + return convertRESTReviewEvent(data as OctokitCommon.PullsCreateReviewResponseData, this.githubRepository); } /** @@ -360,11 +543,11 @@ export class PullRequestModel extends IssueModel implements IPullRe * @param event The type of review to create, an approval, request for changes, or comment. * @param body The summary comment text. */ - async submitReview(event?: ReviewEvent, body?: string): Promise { + async submitReview(event?: ReviewEventEnum, body?: string): Promise { let pendingReviewId = await this.getPendingReviewId(); const { mutate, schema } = await this.githubRepository.ensure(); - if (!pendingReviewId && (event === ReviewEvent.Comment)) { + if (!pendingReviewId && (event === ReviewEventEnum.Comment)) { // Create a new review so that we can comment on it. pendingReviewId = await this.startReview(); } @@ -374,7 +557,7 @@ export class PullRequestModel extends IssueModel implements IPullRe mutation: schema.SubmitReview, variables: { id: pendingReviewId, - event: event || ReviewEvent.Comment, + event: event || ReviewEventEnum.Comment, body, }, }); @@ -383,7 +566,7 @@ export class PullRequestModel extends IssueModel implements IPullRe await this.updateDraftModeContext(); const reviewEvent = parseGraphQLReviewEvent(data!.submitPullRequestReview.pullRequestReview, this.githubRepository); - const threadWithComment = this._reviewThreadsCache.find(thread => + const threadWithComment = (this._reviewThreadsCache ?? []).find(thread => thread.comments.length ? (thread.comments[0].pullRequestReviewId === reviewEvent.id) : undefined, ); if (threadWithComment) { @@ -391,47 +574,19 @@ export class PullRequestModel extends IssueModel implements IPullRe threadWithComment.viewerCanResolve = true; this._onDidChangeReviewThreads.fire({ added: [], changed: [threadWithComment], removed: [] }); } + this._onDidChange.fire({ timeline: true, comments: true }); return reviewEvent; } else { throw new Error(`Submitting review failed, no pending review for current pull request: ${this.number}.`); } } - async updateMilestone(id: string): Promise { - const { mutate, schema } = await this.githubRepository.ensure(); - const finalId = id === 'null' ? null : id; - - try { - await mutate({ - mutation: schema.UpdatePullRequest, - variables: { - input: { - pullRequestId: this.item.graphNodeId, - milestoneId: finalId, - }, - }, - }); - } catch (err) { - Logger.error(err, PullRequestModel.ID); - } - } - - async addAssignees(assignees: string[]): Promise { - const { octokit, remote } = await this.githubRepository.ensure(); - await octokit.call(octokit.api.issues.addAssignees, { - owner: remote.owner, - repo: remote.repositoryName, - issue_number: this.number, - assignees, - }); - } - /** * Query to see if there is an existing review. */ async getPendingReviewId(): Promise { const { query, schema } = await this.githubRepository.ensure(); - const currentUser = await this.githubRepository.getAuthenticatedUser(); + const currentUser = (await this.githubRepository.getAuthenticatedUser()).login; try { const { data } = await query({ query: schema.GetPendingReviewId, @@ -449,6 +604,7 @@ export class PullRequestModel extends IssueModel implements IPullRe async getViewerLatestReviewCommit(): Promise<{ sha: string } | undefined> { Logger.debug(`Fetch viewers latest review commit`, IssueModel.ID); const { query, remote, schema } = await this.githubRepository.ensure(); + const currentUser = (await this.githubRepository.getAuthenticatedUser()).login; try { const { data } = await query({ @@ -457,11 +613,17 @@ export class PullRequestModel extends IssueModel implements IPullRe owner: remote.owner, name: remote.repositoryName, number: this.number, + author: currentUser, }, }); - return data.repository.pullRequest.viewerLatestReview ? { - sha: data.repository.pullRequest.viewerLatestReview.commit.oid, + if (data.repository === null) { + Logger.error('Unexpected null repository while getting last review commit', PullRequestModel.ID); + } + + const latestReview = data.repository?.pullRequest.reviews.nodes[0]; + return latestReview ? { + sha: latestReview.commit.oid, } : undefined; } catch (e) { @@ -474,6 +636,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, @@ -483,15 +649,44 @@ export class PullRequestModel extends IssueModel implements IPullRe }); const { comments, databaseId } = data!.deletePullRequestReview.pullRequestReview; + const deletedReviewComments = comments.nodes.map(comment => parseGraphQLComment(comment, false, 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[] = []; + if (!this._reviewThreadsCache) { + this._reviewThreadsCache = []; + } + 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(); - + // 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, }; } @@ -517,7 +712,6 @@ export class PullRequestModel extends IssueModel implements IPullRe throw new Error('Failed to start review'); } this.hasPendingReview = true; - this._onDidChangeComments.fire(); return data.addPullRequestReview.pullRequestReview.id; } @@ -537,8 +731,8 @@ export class PullRequestModel extends IssueModel implements IPullRe async createReviewThread( body: string, commentPath: string, - startLine: number, - endLine: number, + startLine: number | undefined, + endLine: number | undefined, side: DiffSide, suppressDraftModeUpdate?: boolean, ): Promise { @@ -557,11 +751,12 @@ export class PullRequestModel extends IssueModel implements IPullRe pullRequestId: this.graphNodeId, pullRequestReviewId: pendingReviewId, startLine: startLine === endLine ? undefined : startLine, - line: endLine, + line: (endLine === undefined) ? 0 : endLine, side, - }, - }, - }); + subjectType: (startLine === undefined || endLine === undefined) ? SubjectType.FILE : SubjectType.LINE + } + } + }, { mutation: schema.LegacyAddReviewThread, deleteProps: ['subjectType'] }); if (!data) { throw new Error('Creating review thread failed.'); @@ -578,8 +773,12 @@ export class PullRequestModel extends IssueModel implements IPullRe const thread = data.addPullRequestReviewThread.thread; const newThread = parseGraphQLReviewThread(thread, this.githubRepository); + if (!this._reviewThreadsCache) { + this._reviewThreadsCache = []; + } this._reviewThreadsCache.push(newThread); this._onDidChangeReviewThreads.fire({ added: [newThread], changed: [], removed: [] }); + this._onDidChange.fire({ timeline: true }); return newThread; } @@ -625,13 +824,13 @@ export class PullRequestModel extends IssueModel implements IPullRe } const { comment } = data.addPullRequestReviewComment; - const newComment = parseGraphQLComment(comment, false, this.githubRepository); + const newComment = parseGraphQLComment(comment, false, false, this.githubRepository); if (isSingleComment) { newComment.isDraft = false; } - const threadWithComment = this._reviewThreadsCache.find(thread => + const threadWithComment = this._reviewThreadsCache?.find(thread => thread.comments.some(comment => comment.graphNodeId === inReplyTo), ); if (threadWithComment) { @@ -639,6 +838,7 @@ export class PullRequestModel extends IssueModel implements IPullRe this._onDidChangeReviewThreads.fire({ added: [], changed: [threadWithComment], removed: [] }); } + this._onDidChange.fire({ timeline: true, comments: true }); return newComment; } @@ -662,6 +862,138 @@ export class PullRequestModel extends IssueModel implements IPullRe } } + /** + * Get the timeline events of a pull request, including comments, reviews, commits, merges, deletes, and assigns. + */ + async getTimelineEvents(): Promise { + const getTimelineEvents = async () => { + Logger.debug(`Fetch timeline events of PR #${this.number} - enter`, PullRequestModel.ID); + const { query, remote, schema } = await this.githubRepository.ensure(); + try { + const { data } = await query({ + query: schema.TimelineEvents, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: this.number, + }, + }); + + if (data.repository === null) { + Logger.error('Unexpected null repository when fetching timeline', PullRequestModel.ID); + } + return data; + } catch (e) { + Logger.error(`Failed to get pull request timeline events: ${e}`, PullRequestModel.ID); + console.log(e); + return undefined; + } + }; + + 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() + ]); + + + const ret = data?.repository?.pullRequest.timelineItems.nodes ?? []; + const events = await parseCombinedTimelineEvents(ret, await this.getCopilotTimelineEvents(true), this.githubRepository); + + this.addReviewTimelineEventComments(events, reviewThreads); + insertNewCommitsSinceReview(events, latestReviewCommitInfo?.sha, currentUser, this.head); + Logger.debug(`Fetch timeline events of PR #${this.number} - done`, PullRequestModel.ID); + this.timelineEvents = events; + return events; + } + + async getActivityTimelineEvents(): Promise { + Logger.debug(`Fetch timeline events of PR #${this.number} - enter`, PullRequestModel.ID); + const { query, remote, schema } = await this.githubRepository.ensure(); + try { + const { data } = await query({ + query: schema.PullRequestActivityTimelineEvents, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: this.number, + }, + }); + + if (data.repository === null) { + Logger.error('Unexpected null repository when fetching timeline', PullRequestModel.ID); + } + + return parseCombinedTimelineEvents(data.repository?.pullRequest.timelineItems.nodes ?? [], [], this.githubRepository); + } catch (e) { + Logger.error(`Failed to get pull request timeline events: ${e}`, PullRequestModel.ID); + console.log(e); + return []; + } + } + + private addReviewTimelineEventComments(events: TimelineEvent[], reviewThreads: IReviewThread[]): void { + interface CommentNode extends IComment { + childComments?: CommentNode[]; + } + + const reviewEvents = events.filter((e): e is ReviewEvent => e.event === EventType.Reviewed); + const reviewComments = reviewThreads.reduce((previous, current) => (previous as IComment[]).concat(current.comments), []); + + const reviewEventsById = reviewEvents.reduce((index, evt) => { + index[evt.id] = evt; + evt.comments = []; + return index; + }, {} as { [key: number]: ReviewEvent }); + + const commentsById = reviewComments.reduce((index, evt) => { + index[evt.id] = evt; + return index; + }, {} as { [key: number]: CommentNode }); + + const roots: CommentNode[] = []; + let i = reviewComments.length; + while (i-- > 0) { + const c: CommentNode = reviewComments[i]; + if (!c.inReplyToId) { + roots.unshift(c); + continue; + } + const parent = commentsById[c.inReplyToId]; + parent.childComments = parent.childComments || []; + parent.childComments = [c, ...(c.childComments || []), ...parent.childComments]; + } + + roots.forEach(c => { + const review = reviewEventsById[c.pullRequestReviewId!]; + if (review) { + review.comments = review.comments.concat(c).concat(c.childComments || []); + } + }); + + reviewThreads.forEach(thread => { + if (!thread.prReviewDatabaseId || !reviewEventsById[thread.prReviewDatabaseId]) { + return; + } + const prReviewThreadEvent = reviewEventsById[thread.prReviewDatabaseId]; + prReviewThreadEvent.reviewThread = { + threadId: thread.id, + canResolve: thread.viewerCanResolve, + canUnresolve: thread.viewerCanUnresolve, + isResolved: thread.isResolved + }; + + }); + + const pendingReview = reviewEvents.filter(r => r.state?.toLowerCase() === 'pending')[0]; + if (pendingReview) { + // Ensures that pending comments made in reply to other reviews are included for the pending review + pendingReview.comments = reviewComments.filter(c => c.isDraft); + } + } + /** * Edit an existing review comment. * @param comment The comment to edit @@ -669,7 +1001,7 @@ export class PullRequestModel extends IssueModel implements IPullRe */ async editReviewComment(comment: IComment, text: string): Promise { const { mutate, schema } = await this.githubRepository.ensure(); - let threadWithComment = this._reviewThreadsCache.find(thread => + let threadWithComment = this._reviewThreadsCache?.find(thread => thread.comments.some(c => c.graphNodeId === comment.graphNodeId), ); @@ -694,12 +1026,14 @@ export class PullRequestModel extends IssueModel implements IPullRe const newComment = parseGraphQLComment( data.updatePullRequestReviewComment.pullRequestReviewComment, !!comment.isResolved, + !!comment.isOutdated, this.githubRepository ); if (threadWithComment) { const index = threadWithComment.comments.findIndex(c => c.graphNodeId === comment.graphNodeId); threadWithComment.comments.splice(index, 1, newComment); this._onDidChangeReviewThreads.fire({ added: [], changed: [threadWithComment], removed: [] }); + this._onDidChange.fire({ timeline: true }); } return newComment; @@ -713,7 +1047,7 @@ export class PullRequestModel extends IssueModel implements IPullRe try { const { octokit, remote } = await this.githubRepository.ensure(); const id = Number(commentId); - const threadIndex = this._reviewThreadsCache.findIndex(thread => thread.comments.some(c => c.id === id)); + const threadIndex = this._reviewThreadsCache?.findIndex(thread => thread.comments.some(c => c.id === id)) ?? -1; if (threadIndex === -1) { this.deleteIssueComment(commentId); @@ -725,15 +1059,16 @@ export class PullRequestModel extends IssueModel implements IPullRe }); if (threadIndex > -1) { - const threadWithComment = this._reviewThreadsCache[threadIndex]; + const threadWithComment = this._reviewThreadsCache![threadIndex]; const index = threadWithComment.comments.findIndex(c => c.id === id); threadWithComment.comments.splice(index, 1); if (threadWithComment.comments.length === 0) { - this._reviewThreadsCache.splice(threadIndex, 1); + this._reviewThreadsCache?.splice(threadIndex, 1); this._onDidChangeReviewThreads.fire({ added: [], changed: [], removed: [threadWithComment] }); } else { this._onDidChangeReviewThreads.fire({ added: [], changed: [threadWithComment], removed: [] }); } + this._onDidChange.fire({ timeline: true }); } } } catch (e) { @@ -741,142 +1076,428 @@ export class PullRequestModel extends IssueModel implements IPullRe } } - /** - * Get existing requests to review. - */ - async getReviewRequests(): Promise { - const githubRepository = this.githubRepository; - const { remote, octokit } = await githubRepository.ensure(); - const result = await octokit.call(octokit.api.pulls.listRequestedReviewers, { - owner: remote.owner, - repo: remote.repositoryName, - pull_number: this.number, + private async getFileContent(owner: string, sha: string, file: string): Promise { + Logger.debug(`Fetch file content - enter`, GitHubRepository.ID); + const { query, remote, schema } = await this.githubRepository.ensure(); + const { data } = await query({ + query: schema.GetFileContent, + variables: { + owner, + name: remote.repositoryName, + expression: `${sha}:${file}` + } }); - return result.data.users.map((user: any) => convertRESTUserToAccount(user, githubRepository)); - } + if (!data.repository?.object.text) { + return undefined; + } - /** - * Add reviewers to a pull request - * @param reviewers A list of GitHub logins - */ - async requestReview(reviewers: string[]): Promise { - const { octokit, remote } = await this.githubRepository.ensure(); - await octokit.call(octokit.api.pulls.requestReviewers, { - owner: remote.owner, - repo: remote.repositoryName, - pull_number: this.number, - reviewers, - }); - } + Logger.debug(`Fetch file content - end`, GitHubRepository.ID); - /** - * Remove a review request that has not yet been completed - * @param reviewer A GitHub Login - */ - async deleteReviewRequest(reviewers: string[]): Promise { - const { octokit, remote } = await this.githubRepository.ensure(); - await octokit.call(octokit.api.pulls.removeRequestedReviewers, { - owner: remote.owner, - repo: remote.repositoryName, - pull_number: this.number, - reviewers, - }); + return data.repository.object.text; } - async deleteAssignees(assignees: string[]): Promise { + public async compareBaseBranchForMerge(headOwner: string, headRef: string, baseOwner: string, baseRef: string): Promise { const { octokit, remote } = await this.githubRepository.ensure(); - await octokit.call(octokit.api.issues.removeAssignees, { - owner: remote.owner, + + // Get the files that would change as part of the merge + const compareData = await octokit.call(octokit.api.repos.compareCommits, { repo: remote.repositoryName, - issue_number: this.number, - assignees, + owner: headOwner, + base: `${headOwner}:${headRef}`, // flip base and head because we are comparing for a merge to update the PR + head: `${baseOwner}:${baseRef}`, }); + + return compareData?.data?.files?.filter((change): change is IRawFileChange => change !== undefined) ?? []; } - private diffThreads(oldReviewThreads: IReviewThread[], newReviewThreads: IReviewThread[]): void { - const added: IReviewThread[] = []; - const changed: IReviewThread[] = []; - const removed: IReviewThread[] = []; + private async getUpdateBranchFiles(baseCommitSha: string, headTreeSha: string, model: ConflictResolutionModel): Promise { + if (this.item.mergeable === PullRequestMergeability.Conflict && (!model.resolvedConflicts || model.resolvedConflicts.size === 0)) { + throw new Error('Pull Request has conflicts but no resolutions were provided.'); + } + const { octokit } = await this.githubRepository.ensure(); - newReviewThreads.forEach(thread => { - const existingThread = oldReviewThreads.find(t => t.id === thread.id); - if (existingThread) { - if (!equals(thread, existingThread)) { - changed.push(thread); - } - } else { - added.push(thread); + // Get the files that would change as part of the merge + const compareData = await this.compareBaseBranchForMerge(model.prHeadOwner, model.prHeadBranchName, model.prBaseOwner, baseCommitSha); + const baseTreeSha = (await octokit.call(octokit.api.repos.getCommit, { owner: model.prBaseOwner, repo: model.repositoryName, ref: baseCommitSha })).data.commit.tree.sha; + const baseTree = await octokit.call(octokit.api.git.getTree, { owner: model.prBaseOwner, repo: model.repositoryName, tree_sha: baseTreeSha, recursive: 'true' }); + + const files: IGitTreeItem[] = (await Promise.all(compareData.map(async (file) => { + if (!file) { + return; } - }); - oldReviewThreads.forEach(thread => { - if (!newReviewThreads.find(t => t.id === thread.id)) { - removed.push(thread); + const baseTreeData = baseTree.data.tree.find(f => f.path === file.filename); + 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: 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). + // If we've made it this far, we already know that there are no conflicts in the file and it's safe to delete. + return { path: file.filename, sha: null, mode: headTreeData?.mode ?? '100644' }; } - }); - this._onDidChangeReviewThreads.fire({ - added, - changed, - removed, - }); + const treeItem: IGitTreeItem = { + path: file.filename, + mode: baseMode + }; + + const resolvedConflict = model.resolvedConflicts.get(file.filename); + if (resolvedConflict?.resolvedContents !== undefined) { + if (file.status !== 'modified') { + throw new Error(`Only modified file are supported for conflict resolution ${file.filename}: ${file.status}`); + } + + if (baseMode !== headMode) { + throw new Error(`Conflict resolution not supported for file with different modes ${file.filename}: ${baseMode} -> ${headMode}`); + } + + if (file.previous_filename) { + throw new Error('Conflict resolution not supported for renamed files'); + } + treeItem.content = resolvedConflict.resolvedContents; + return treeItem; + } + + if ((!file.previous_filename || !this._fileChanges.has(file.previous_filename)) && !this._fileChanges.has(file.filename)) { + // File is not part of the PR, so we don't need to bother getting any content and can just use the sha + treeItem.sha = file.sha; + return treeItem; + } + + // File is part of the PR. We have to apply the patch of the base to the head content. + const { data: headData }: { data: IRawFileContent } = await octokit.call(octokit.api.repos.getContent, { + owner: model.prHeadOwner, + repo: model.repositoryName, + path: file?.previous_filename ?? file.filename, + ref: model.prHeadBranchName + }) as { data: IRawFileContent }; + + if (file.status === 'modified' && file.patch && headData.content) { + const buff = buffer.Buffer.from(headData.content, 'base64'); + const asString = new TextDecoder().decode(buff); + treeItem.content = getModifiedContentFromDiffHunk(asString, file.patch); + } else { + // binary file or file that otherwise doesn't have a patch + // This cannot be resolved by us and must manually be resolved by the user + Logger.error(`File ${file.filename} has status ${file.status} and can't be merged.`, GitHubRepository.ID); + // We don't want to commit something that's going to break, so throw + throw new Error(`File ${file.filename} has status ${file.status} and can't be merged,`); + } + return treeItem; + + }))).filter((file): file is IGitTreeItem => file !== undefined); + return files; } - async getReviewThreads(): Promise { - const { remote, query, schema } = await this.githubRepository.ensure(); + async getLatestBaseCommitSha(): Promise { + const base = this.base; + if (!base) { + throw new Error('Base branch not yet set.'); + } + const { octokit, remote } = await this.githubRepository.ensure(); + return (await octokit.call(octokit.api.repos.getBranch, { owner: remote.owner, repo: remote.repositoryName, branch: this.base.ref })).data.commit.sha; + } + + async updateBranch(model: ConflictResolutionModel): Promise { + if (!model.resolvedConflicts || model.resolvedConflicts.size === 0) { + throw new Error('Pull Request has conflicts but no resolutions were provided.'); + } + Logger.debug(`Updating branch ${model.prHeadBranchName} to ${model.prBaseBranchName} - enter`, GitHubRepository.ID); + // For Conflict state, use the REST API approach with conflict resolution. + // For Unknown or NotMergeable states, the REST API approach will also be used as a fallback, + // though these states may fail for other reasons (e.g., blocked by branch protection). + return this.updateBranchWithConflictResolution(model); + } + + /** + * Update the branch using the GitHub GraphQL API's UpdatePullRequestBranch mutation. + * This is used when there are no conflicts between the head and base branches. + */ + public async updateBranchWithGraphQL(): Promise { + Logger.debug(`Updating branch using GraphQL UpdatePullRequestBranch mutation - enter`, GitHubRepository.ID); + + if (!this.head?.sha) { + Logger.error(`Cannot update branch: head SHA is not available`, GitHubRepository.ID); + return false; + } + try { - const { data } = await query({ - query: schema.PullRequestComments, + const { mutate, schema } = await this.githubRepository.ensure(); + + await mutate({ + mutation: schema.UpdatePullRequestBranch, variables: { - owner: remote.owner, - name: remote.repositoryName, - number: this.number, - }, + input: { + pullRequestId: this.graphNodeId, + expectedHeadOid: this.head.sha + } + } }); - const reviewThreads = data.repository.pullRequest.reviewThreads.nodes.map(node => { - return parseGraphQLReviewThread(node, this.githubRepository); - }); + Logger.debug(`Updating branch using GraphQL UpdatePullRequestBranch mutation - done`, GitHubRepository.ID); + return true; + } catch (e) { + Logger.error(`Updating branch using GraphQL UpdatePullRequestBranch mutation failed: ${e}`, GitHubRepository.ID); + return false; + } + } + + /** + * Update the branch with conflict resolution using the REST API. + * This is used when there are conflicts between the head and base branches. + */ + private async updateBranchWithConflictResolution(model: ConflictResolutionModel): Promise { + Logger.debug(`Updating branch ${model.prHeadBranchName} with conflict resolution - enter`, GitHubRepository.ID); + try { + const { octokit } = await this.githubRepository.ensure(); + + const lastCommitSha = (await octokit.call(octokit.api.repos.getBranch, { owner: model.prHeadOwner, repo: model.repositoryName, branch: model.prHeadBranchName })).data.commit.sha; + const lastTreeSha = (await octokit.call(octokit.api.repos.getCommit, { owner: model.prHeadOwner, repo: model.repositoryName, ref: lastCommitSha })).data.commit.tree.sha; + + const treeItems: IGitTreeItem[] = await this.getUpdateBranchFiles(model.latestPrBaseSha, lastTreeSha, model); + + const newTreeSha = (await octokit.call(octokit.api.git.createTree, { owner: model.prHeadOwner, repo: model.repositoryName, base_tree: lastTreeSha, tree: treeItems })).data.sha; + let message: string; + if (model.prBaseOwner === model.prHeadOwner) { + message = `Merge branch \`${model.prBaseBranchName}\` into ${model.prHeadBranchName}`; + } else { + message = `Merge branch \`${model.prBaseOwner}:${model.prBaseBranchName}\` into ${model.prHeadBranchName}`; + } + const newCommitSha = (await octokit.call(octokit.api.git.createCommit, { owner: model.prHeadOwner, repo: model.repositoryName, message, tree: newTreeSha, parents: [lastCommitSha, model.latestPrBaseSha] })).data.sha; + await octokit.call(octokit.api.git.updateRef, { owner: model.prHeadOwner, repo: model.repositoryName, ref: `heads/${model.prHeadBranchName}`, sha: newCommitSha }); - const oldReviewThreads = this._reviewThreadsCache; - this._reviewThreadsCache = reviewThreads; - this.diffThreads(oldReviewThreads, reviewThreads); - return reviewThreads; } catch (e) { - Logger.error(`Failed to get pull request review comments: ${e}`, PullRequestModel.ID); - return []; + Logger.error(`Updating branch ${model.prHeadBranchName} with conflict resolution failed: ${e}`, GitHubRepository.ID); + return false; } + Logger.debug(`Updating branch ${model.prHeadBranchName} with conflict resolution - done`, GitHubRepository.ID); + return true; } /** - * Get all review comments. + * Update the base branch of the pull request. + * @param newBaseBranch The new base branch name */ - async initializeReviewComments(): Promise { - const { remote, query, schema } = await this.githubRepository.ensure(); + async updateBaseBranch(newBaseBranch: string): Promise { + Logger.debug(`Updating base branch to ${newBaseBranch} - enter`, PullRequestModel.ID); try { - const { data } = await query({ - query: schema.PullRequestComments, + const { mutate, schema } = await this.githubRepository.ensure(); + + const { data } = await mutate({ + mutation: schema.UpdatePullRequest, variables: { - owner: remote.owner, - name: remote.repositoryName, - number: this.number, + input: { + pullRequestId: this.graphNodeId, + baseRefName: newBaseBranch, + }, }, }); - const comments = data.repository.pullRequest.reviewThreads.nodes - .map(node => node.comments.nodes.map(comment => parseGraphQLComment(comment, node.isResolved, this.githubRepository), remote)) - .reduce((prev, curr) => prev.concat(curr), []) - .sort((a: IComment, b: IComment) => { - return a.createdAt > b.createdAt ? 1 : -1; - }); + if (data?.updateIssue?.issue) { + // Update the local base branch reference by creating a new GitHubRef instance + const cloneUrl = this.base.repositoryCloneUrl.toString() || ''; + this.base = new GitHubRef( + newBaseBranch, + `${this.base.owner}:${newBaseBranch}`, + this.base.sha, + cloneUrl, + this.base.owner, + this.base.name, + this.base.isInOrganization + ); + this._onDidChange.fire({ base: true }); + } + Logger.debug(`Updating base branch to ${newBaseBranch} - done`, PullRequestModel.ID); + } catch (e) { + Logger.error(`Updating base branch to ${newBaseBranch} failed: ${e}`, PullRequestModel.ID); + throw e; + } + } + + /** + * Get existing requests to review. + */ + async getReviewRequests(): Promise<(IAccount | ITeam)[]> { + Logger.debug('Get Review Requests - enter', PullRequestModel.ID); - this.comments = comments; + const githubRepository = this.githubRepository; + const { remote, query, schema } = await githubRepository.ensure(); + + const { data } = await query({ + query: this.credentialStore.isAuthenticatedWithAdditionalScopes(githubRepository.remote.authProviderId) ? schema.GetReviewRequestsAdditionalScopes : schema.GetReviewRequests, + variables: { + number: this.number, + owner: remote.owner, + name: remote.repositoryName + }, + }); + + if (data.repository === null) { + Logger.error('Unexpected null repository while getting review requests', PullRequestModel.ID); + return []; + } + + const reviewers: (IAccount | ITeam)[] = parseGraphQLReviewers(data, githubRepository); + if (this.reviewers?.length !== reviewers.length || (this.reviewers.some(r => !reviewers.some(rr => rr.id === r.id)))) { + this.reviewers = reviewers; + this._onDidChange.fire({ reviewers: true }); + } + Logger.debug('Get Review Requests - done', PullRequestModel.ID); + return reviewers; + } + + /** + * Add reviewers to a pull request + * @param reviewers A list of GitHub logins + */ + 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 + }, + }); + + if (!data?.requestReviews) { + Logger.error('Unexpected null repository while getting review requests', PullRequestModel.ID); + return; + } + + const newReviewers: (IAccount | ITeam)[] = [...reviewers, ...teamReviewers].filter(r => !this.reviewers?.some(rr => rr.id === r.id)); + if (this.reviewers?.length !== newReviewers.length) { + if (!this.reviewers) { + this.reviewers = newReviewers; + } else { + this.reviewers.push(...newReviewers); + } + this.reviewers = [...this.reviewers, ...newReviewers]; + this._onDidChange.fire({ reviewers: true }); + } + } + + /** + * Remove a review request that has not yet been completed + * @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, + repo: remote.repositoryName, + pull_number: this.number, + reviewers: reviewers.map(r => r.id), + team_reviewers: teamReviewers.map(t => t.id), + }); + + this.reviewers = this.reviewers?.filter(r => !reviewers.some(rr => rr.id === r.id) && !teamReviewers.some(t => t.id === r.id)) || []; + this._onDidChange.fire({ reviewers: true }); + } + + private diffThreads(oldReviewThreads: IReviewThread[], newReviewThreads: IReviewThread[]): void { + const added: IReviewThread[] = []; + const changed: IReviewThread[] = []; + const removed: IReviewThread[] = []; + + newReviewThreads.forEach(thread => { + const existingThread = oldReviewThreads.find(t => t.id === thread.id); + if (existingThread) { + if (!equals(thread, existingThread)) { + changed.push(thread); + } + } else { + added.push(thread); + } + }); + + oldReviewThreads.forEach(thread => { + if (!newReviewThreads.find(t => t.id === thread.id)) { + removed.push(thread); + } + }); + + if (added.length > 0 || changed.length > 0 || removed.length > 0) { + this._onDidChangeReviewThreads.fire({ + added, + changed, + removed, + }); + } + } + + async initializeReviewThreadCacheAndReviewComments(): Promise { + const threads = await this.initializeReviewThreadCache(); + + this.comments = threads.map(node => node.comments) + .reduce((prev, curr) => prev.concat(curr), []) + .sort((a: IComment, b: IComment) => { + return a.createdAt > b.createdAt ? 1 : -1; + }); + } + + private setReviewThreadCacheFromRaw(raw: ReviewThread[]): IReviewThread[] { + const reviewThreads: IReviewThread[] = raw.map(thread => parseGraphQLReviewThread(thread, this.githubRepository)); + const oldReviewThreads = this._reviewThreadsCache ?? []; + this._reviewThreadsCache = reviewThreads; + this.diffThreads(oldReviewThreads, reviewThreads); + return reviewThreads; + } + + private async getRawReviewComments(): Promise { + Logger.debug(`Fetching review comments for PR #${this.number} - enter`, PullRequestModel.ID); + + const { remote, query, schema } = await this.githubRepository.ensure(); + let after: string | null = null; + let hasNextPage = false; + const reviewThreads: ReviewThread[] = []; + try { + do { + const { data } = await query({ + query: schema.PullRequestComments, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: this.number, + after + }, + }, false, { query: schema.LegacyPullRequestComments }); + + reviewThreads.push(...data.repository.pullRequest.reviewThreads.nodes); + + hasNextPage = data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage; + after = data.repository.pullRequest.reviewThreads.pageInfo.endCursor; + } while (hasNextPage && reviewThreads.length < 1000); + Logger.debug(`Fetching review comments for PR #${this.number} - exit`, PullRequestModel.ID); + + return reviewThreads; } catch (e) { Logger.error(`Failed to get pull request review comments: ${e}`, PullRequestModel.ID); + return []; } } + async getReviewThreads(): Promise { + const raw = await this.getRawReviewComments(); + return this.setReviewThreadCacheFromRaw(raw); + } + /** * Get a list of the commits within a pull request. */ @@ -884,14 +1505,14 @@ export class PullRequestModel extends IssueModel implements IPullRe try { Logger.debug(`Fetch commits of PR #${this.number} - enter`, PullRequestModel.ID); const { remote, octokit } = await this.githubRepository.ensure(); - const commitData = await octokit.call(octokit.api.pulls.listCommits, { + const commitData = await restPaginate(octokit.api.pulls.listCommits, { pull_number: this.number, owner: remote.owner, repo: remote.repositoryName, }); Logger.debug(`Fetch commits of PR #${this.number} - done`, PullRequestModel.ID); - return commitData.data; + return commitData; } catch (e) { vscode.window.showErrorMessage(`Fetching commits failed: ${formatError(e)}`); return []; @@ -906,20 +1527,14 @@ export class PullRequestModel extends IssueModel implements IPullRe commit: OctokitCommon.PullsListCommitsResponseData[0], ): Promise { try { - Logger.debug( - `Fetch file changes of commit ${commit.sha} in PR #${this.number} - enter`, - PullRequestModel.ID, - ); + Logger.debug(`Fetch file changes of commit ${commit.sha} in PR #${this.number} - enter`, PullRequestModel.ID,); const { octokit, remote } = await this.githubRepository.ensure(); const fullCommit = await octokit.call(octokit.api.repos.getCommit, { owner: remote.owner, repo: remote.repositoryName, ref: commit.sha, }); - Logger.debug( - `Fetch file changes of commit ${commit.sha} in PR #${this.number} - done`, - PullRequestModel.ID, - ); + Logger.debug(`Fetch file changes of commit ${commit.sha} in PR #${this.number} - done`, PullRequestModel.ID,); return fullCommit.data.files ?? []; } catch (e) { @@ -928,174 +1543,98 @@ export class PullRequestModel extends IssueModel implements IPullRe } } - /** - * Gets file content for a file at the specified commit - * @param filePath The file path - * @param commit The commit - */ - async getFile(filePath: string, commit: string) { + async getCoAuthors(): Promise { + // To save time, we only do for Copilot now as that's where we need it + if (!COPILOT_ACCOUNTS[this.item.user.login]) { + return []; + } const { octokit, remote } = await this.githubRepository.ensure(); - const fileContent = await octokit.call(octokit.api.repos.getContent, { + const timeline = await octokit.call(octokit.api.issues.listEventsForTimeline, { + issue_number: this.number, owner: remote.owner, repo: remote.repositoryName, - path: filePath, - ref: commit, + per_page: 100 }); + const workStartedInitiator = (timeline.data.find(event => event.event === 'copilot_work_started') as { actor: RestAccount } | undefined)?.actor; + return workStartedInitiator ? [parseAccount(workStartedInitiator, this.githubRepository)] : []; + } - if (Array.isArray(fileContent.data)) { - throw new Error(`Unexpected array response when getting file ${filePath}`); - } - - const contents = (fileContent.data as any).content ?? ''; - const buff = buffer.Buffer.from(contents, (fileContent.data as any).encoding); - return buff.toString(); + protected override getUpdatesQuery(schema: any): any { + return schema.LatestUpdates; } /** - * Get the timeline events of a pull request, including comments, reviews, commits, merges, deletes, and assigns. + * Get the status checks of the pull request, those for the last commit. */ - async getTimelineEvents(): Promise { - Logger.debug(`Fetch timeline events of PR #${this.number} - enter`, PullRequestModel.ID); - const { query, remote, schema } = await this.githubRepository.ensure(); - - try { - const [{ data }, latestReviewCommitInfo, currentUser, reviewThreads] = await Promise.all([ - query({ - query: schema.TimelineEvents, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: this.number, - }, - }), - this.getViewerLatestReviewCommit(), - this.githubRepository.getAuthenticatedUser(), - this.getReviewThreads() - ]); - - const ret = data.repository.pullRequest.timelineItems.nodes; - const events = parseGraphQLTimelineEvents(ret, this.githubRepository); - - this.addReviewTimelineEventComments(events, reviewThreads); - insertNewCommitsSinceReview(events, latestReviewCommitInfo?.sha, currentUser, this.head); - - return events; - } catch (e) { - console.log(e); - return []; - } + async getStatusChecks(): Promise<[PullRequestChecks | null, PullRequestReviewRequirement | null]> { + return this.githubRepository.getStatusChecks(this.number); } - private addReviewTimelineEventComments(events: TimelineEvent[], reviewThreads: IReviewThread[]): void { - interface CommentNode extends IComment { - childComments?: CommentNode[]; + static async openChanges(folderManager: FolderRepositoryManager, pullRequestModel: PullRequestModel, openToTheSide?: boolean): Promise { + const changeModels = await PullRequestModel.getChangeModels(folderManager, pullRequestModel); + const args: [vscode.Uri, vscode.Uri | undefined, vscode.Uri | undefined][] = []; + for (const changeModel of changeModels) { + args.push([changeModel.filePath, changeModel.parentFilePath, changeModel.filePath]); } - const reviewEvents = events.filter((e): e is CommonReviewEvent => e.event === EventType.Reviewed); - const reviewComments = reviewThreads.reduce((previous, current) => (previous as IComment[]).concat(current.comments), []); - - const reviewEventsById = reviewEvents.reduce((index, evt) => { - index[evt.id] = evt; - evt.comments = []; - return index; - }, {} as { [key: number]: CommonReviewEvent }); - - const commentsById = reviewComments.reduce((index, evt) => { - index[evt.id] = evt; - return index; - }, {} as { [key: number]: CommentNode }); + /* __GDPR__ + "pr.openChanges" : {} + */ + folderManager.telemetry.sendTelemetryEvent('pr.openChanges'); - const roots: CommentNode[] = []; - let i = reviewComments.length; - while (i-- > 0) { - const c: CommentNode = reviewComments[i]; - if (!c.inReplyToId) { - roots.unshift(c); - continue; + if (openToTheSide) { + if (vscode.window.tabGroups.all.length < 2) { + await vscode.commands.executeCommand('workbench.action.splitEditor'); } - const parent = commentsById[c.inReplyToId]; - parent.childComments = parent.childComments || []; - parent.childComments = [c, ...(c.childComments || []), ...parent.childComments]; + + await vscode.commands.executeCommand('workbench.action.focusSecondEditorGroup'); } - roots.forEach(c => { - const review = reviewEventsById[c.pullRequestReviewId!]; - if (review) { - review.comments = review.comments.concat(c).concat(c.childComments || []); - } - }); + return vscode.commands.executeCommand('vscode.changes', vscode.l10n.t('Changes in Pull Request #{0}', pullRequestModel.number), args); + } - reviewThreads.forEach(thread => { - if (!thread.prReviewDatabaseId || !reviewEventsById[thread.prReviewDatabaseId]) { + static async openCommitChanges(extensionUri: vscode.Uri, githubRepository: GitHubRepository, commitSha: string) { + try { + const parentCommit = await githubRepository.getCommitParent(commitSha); + if (!parentCommit) { + vscode.window.showErrorMessage(vscode.l10n.t('Commit {0} has no parent', commitSha.substring(0, 7))); return; } - const prReviewThreadEvent = reviewEventsById[thread.prReviewDatabaseId]; - prReviewThreadEvent.reviewThread = { - threadId: thread.id, - canResolve: thread.viewerCanResolve, - canUnresolve: thread.viewerCanUnresolve, - isResolved: thread.isResolved - }; - - }); - const pendingReview = reviewEvents.filter(r => r.state.toLowerCase() === 'pending')[0]; - if (pendingReview) { - // Ensures that pending comments made in reply to other reviews are included for the pending review - pendingReview.comments = reviewComments.filter(c => c.isDraft); - } - } - - private async _getReviewRequiredCheck() { - const { query, remote, octokit, schema } = await this.githubRepository.ensure(); - - const [branch, reviewStates] = await Promise.all([ - octokit.call(octokit.api.repos.getBranch, { branch: this.base.ref, owner: remote.owner, repo: remote.repositoryName }), - query({ - query: schema.LatestReviews, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: this.number, - } - }) - ]); - if (branch.data.protected && branch.data.protection.required_status_checks && branch.data.protection.required_status_checks.enforcement_level !== 'off') { - // We need to add the "review required" check manually. - return { - id: REVIEW_REQUIRED_CHECK_ID, - context: 'Branch Protection', - description: vscode.l10n.t('Other requirements have not been met.'), - state: (reviewStates.data as LatestReviewsResponse).repository.pullRequest.latestReviews.nodes.every(node => node.state !== 'CHANGES_REQUESTED') ? CheckState.Neutral : CheckState.Failure, - target_url: this.html_url - }; - } - return undefined; - } + const changes = await githubRepository.compareCommits(parentCommit, commitSha); + if (!changes?.files || changes.files.length === 0) { + // Show a webview with the empty commit message instead of a notification + showEmptyCommitWebview(extensionUri, commitSha); + return; + } - /** - * Get the status checks of the pull request, those for the last commit. - */ - async getStatusChecks(): Promise { - let checks = await this.githubRepository.getStatusChecks(this.number); - - // Fun info: The checks don't include whether a review is required. - // Also, unless you're an admin on the repo, you can't just do octokit.repos.getBranchProtection - if ((this.item.mergeable === PullRequestMergeability.NotMergeable) && (!checks || checks.statuses.every(status => status.state === CheckState.Success))) { - const reviewRequiredCheck = await this._getReviewRequiredCheck(); - if (reviewRequiredCheck) { - if (!checks) { - checks = { - state: CheckState.Failure, - statuses: [] - }; + // Create URI pairs for the multi diff editor using review scheme + const args: [vscode.Uri, vscode.Uri | undefined, vscode.Uri | undefined][] = []; + for (const change of changes.files) { + const rightUri = toGitHubCommitUri(change.filename, { commit: commitSha, owner: githubRepository.remote.owner, repo: githubRepository.remote.repositoryName }); + const leftUri = toGitHubCommitUri(change.previous_filename ?? change.filename, { commit: parentCommit, owner: githubRepository.remote.owner, repo: githubRepository.remote.repositoryName }); + const changeType = getGitChangeType(change.status); + if (changeType === GitChangeType.ADD) { + // For added files, show against empty + args.push([rightUri, undefined, rightUri]); + } else if (changeType === GitChangeType.DELETE) { + // For deleted files, show old version against empty + args.push([rightUri, leftUri, undefined]); + } else { + args.push([rightUri, leftUri, rightUri]); } - checks.statuses.push(reviewRequiredCheck); - checks.state = CheckState.Failure; } - } - return checks; + /* __GDPR__ + "pr.openCommitChanges" : {} + */ + githubRepository.telemetry.sendTelemetryEvent('pr.openCommitChanges'); + + return commands.executeCommand('vscode.changes', vscode.l10n.t('Changes in Commit {0}', commitSha.substring(0, 7)), args); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + vscode.window.showErrorMessage(vscode.l10n.t('Failed to open commit changes: {0}', errorMessage)); + } } static async openDiffFromComment( @@ -1112,7 +1651,8 @@ export class PullRequestModel extends IssueModel implements IPullRe } const pathSegments = comment.path!.split('/'); - this.openDiff(folderManager, pullRequestModel, change, pathSegments[pathSegments.length - 1]); + const line = (comment.diffHunks && comment.diffHunks.length > 0) ? comment.diffHunks[0].newLineNumber : undefined; + this.openDiff(folderManager, pullRequestModel, change, pathSegments[pathSegments.length - 1], line); } static async openFirstDiff( @@ -1132,10 +1672,9 @@ export class PullRequestModel extends IssueModel implements IPullRe folderManager: FolderRepositoryManager, pullRequestModel: PullRequestModel, change: SlimFileChange | InMemFileChange, - diffTitle: string + diffTitle: string, + line?: number ): Promise { - - let headUri, baseUri: vscode.Uri; if (!pullRequestModel.equals(folderManager.activePullRequest)) { const headCommit = pullRequestModel.head!.sha; @@ -1193,7 +1732,7 @@ export class PullRequestModel extends IssueModel implements IPullRe baseUri, headUri, `${diffTitle} (Pull Request)`, - {}, + line ? { selection: { start: { line, character: 0 }, end: { line, character: 0 } } } : {}, ); } @@ -1202,6 +1741,11 @@ export class PullRequestModel extends IssueModel implements IPullRe return this._fileChanges; } + private _rawFileChangesCache: IRawFileChange[] | undefined; + get rawFileChanges(): IRawFileChange[] | undefined { + return this._rawFileChangesCache; + } + async getFileChangesInfo() { this._fileChanges.clear(); const data = await this.getRawFileChangesInfo(); @@ -1213,18 +1757,54 @@ export class PullRequestModel extends IssueModel implements IPullRe return parsed; } + async getPatch(): Promise { + const githubRepository = this.githubRepository; + const { octokit, remote } = await githubRepository.ensure(); + + const { data } = await octokit.call(octokit.api.pulls.get, { + owner: remote.owner, + repo: remote.repositoryName, + pull_number: this.number, + mediaType: { + format: 'diff' + } + }); + + if (typeof data === 'string') { + return data; + } else { + throw new Error('Expected diff data to be a string'); + } + } + + public static async getChangeModels(folderManager: FolderRepositoryManager, pullRequestModel: PullRequestModel): Promise<(RemoteFileChangeModel | InMemFileChangeModel)[]> { + const isCurrentPR = folderManager.activePullRequest?.number === pullRequestModel.number; + const changes = pullRequestModel.fileChanges.size > 0 ? pullRequestModel.fileChanges.values() : await pullRequestModel.getFileChangesInfo(); + const changeModels: (RemoteFileChangeModel | InMemFileChangeModel)[] = []; + for (const change of changes) { + let changeModel; + if (change instanceof SlimFileChange) { + changeModel = new RemoteFileChangeModel(folderManager, change, pullRequestModel); + } else { + changeModel = new InMemFileChangeModel(folderManager, pullRequestModel as (PullRequestModel & IResolvedPullRequestModel), change, isCurrentPR, pullRequestModel.mergeBase!); + } + changeModels.push(changeModel); + } + return changeModels; + } + /** * List the changed files in a pull request. */ - private async getRawFileChangesInfo(): Promise { - Logger.debug( - `Fetch file changes, base, head and merge base of PR #${this.number} - enter`, - PullRequestModel.ID, - ); + public async getRawFileChangesInfo(): Promise { + Logger.debug(`Fetch file changes, base, head and merge base of PR #${this.number} - enter`, PullRequestModel.ID); + const githubRepository = this.githubRepository; const { octokit, remote } = await githubRepository.ensure(); if (!this.base) { + Logger.appendLine('No base branch found for PR, fetching it now', PullRequestModel.ID); + Logger.trace(`Fetching from ${remote.owner}/${remote.repositoryName}. PR #${this.number}`, PullRequestModel.ID); const info = await octokit.call(octokit.api.pulls.get, { owner: remote.owner, repo: remote.repositoryName, @@ -1243,6 +1823,7 @@ export class PullRequestModel extends IssueModel implements IPullRe } if (this.item.merged) { + Logger.appendLine('PR is merged, fetching all file changes', PullRequestModel.ID); const response = await restPaginate(octokit.api.pulls.listFiles, { repo: remote.repositoryName, owner: remote.owner, @@ -1251,47 +1832,20 @@ export class PullRequestModel extends IssueModel implements IPullRe // Use the original base to compare against for merged PRs this.mergeBase = this.base.sha; - + this._rawFileChangesCache = response; return response; } - const { data } = await octokit.call(octokit.api.repos.compareCommits, { - repo: remote.repositoryName, - owner: remote.owner, - base: `${this.base.repositoryCloneUrl.owner}:${compareWithBaseRef}`, - head: `${this.head!.repositoryCloneUrl.owner}:${this.head!.sha}`, - }); - - this.mergeBase = data.merge_base_commit.sha; - - const MAX_FILE_CHANGES_IN_COMPARE_COMMITS = 100; - let files: IRawFileChange[] = []; - - if (data.files && data.files.length >= MAX_FILE_CHANGES_IN_COMPARE_COMMITS) { - // compareCommits will return a maximum of 100 changed files - // If we have (maybe) more than that, we'll need to fetch them with listFiles API call - Logger.debug( - `More than ${MAX_FILE_CHANGES_IN_COMPARE_COMMITS} files changed, fetching all file changes of PR #${this.number}`, - PullRequestModel.ID, - ); - files = await restPaginate(octokit.api.pulls.listFiles, { - owner: this.base.repositoryCloneUrl.owner, - pull_number: this.number, - repo: remote.repositoryName, - }); - } else { - // if we're under the limit, just use the result from compareCommits, don't make additional API calls. - files = data.files ? data.files as IRawFileChange[] : []; - } + Logger.debug(`Comparing commits for ${remote.owner}/${remote.repositoryName} with base ${this.base.repositoryCloneUrl.owner}:${compareWithBaseRef} and head ${this.head!.repositoryCloneUrl.owner}:${this.head!.sha}`, PullRequestModel.ID); + const { files, mergeBaseSha } = await compareCommits(remote, octokit, this.base, this.head!, compareWithBaseRef, this.number, PullRequestModel.ID); + this.mergeBase = mergeBaseSha; if (oldHasChangesSinceReview !== undefined && oldHasChangesSinceReview !== this.hasChangesSinceLastReview && this.hasChangesSinceLastReview && this._showChangesSinceReview) { this._onDidChangeChangesSinceReview.fire(); } - Logger.debug( - `Fetch file changes and merge base of PR #${this.number} - done, total files ${files.length} `, - PullRequestModel.ID, - ); + Logger.debug(`Fetch file changes and merge base of PR #${this.number} - done, total files ${files.length} `, PullRequestModel.ID,); + this._rawFileChangesCache = files; return files; } @@ -1307,36 +1861,67 @@ export class PullRequestModel extends IssueModel implements IPullRe return !!this.item.allowAutoMerge; } + get mergeCommitMeta(): { title: string; description: string } | undefined { + return this.item.mergeCommitMeta; + } + + get squashCommitMeta(): { title: string; description: string } | undefined { + return this.item.squashCommitMeta; + } + + get hasComments(): boolean { + return this._hasComments; + } + /** * Get the current mergeability of the pull request. */ - async getMergeability(): Promise { + async getMergeability(): Promise<{ mergeability: PullRequestMergeability, conflicts?: string[] }> { try { Logger.debug(`Fetch pull request mergeability ${this.number} - enter`, PullRequestModel.ID); const { query, remote, schema } = await this.githubRepository.ensure(); - const { data } = await query({ - query: schema.PullRequestMergeability, + // hard code the users for selfhost purposes + const { data } = /*(schema.PullRequestMergeabilityMergeRequirements && ((await this.credentialStore.getCurrentUser(this.remote.authProviderId))?.login === 'alexr00')) ? await query({ + query: schema.PullRequestMergeabilityMergeRequirements, variables: { owner: remote.owner, name: remote.repositoryName, number: this.number, }, + context: { + headers: { + 'GraphQL-Features': 'pull_request_merge_requirements_api' // This flag allows specific users to test a private field. + } + } + }) : */await query({ + query: schema.PullRequestMergeability, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: this.number, + } }); + if (data.repository === null) { + Logger.error('Unexpected null repository while getting mergeability', PullRequestModel.ID); + } + Logger.debug(`Fetch pull request mergeability ${this.number} - done`, PullRequestModel.ID); - const mergeability = parseMergeability(data.repository.pullRequest.mergeable, data.repository.pullRequest.mergeStateStatus); + const mergeability = parseMergeability(data.repository?.pullRequest.mergeable, data.repository?.pullRequest.mergeStateStatus); this.item.mergeable = mergeability; - return mergeability; + this.conflicts = data.repository?.pullRequest.mergeRequirements?.conditions.find(condition => condition.__typename === 'PullRequestMergeConflictStateCondition')?.conflicts; + this.update(this.item); + return { mergeability, conflicts: this.conflicts }; } catch (e) { Logger.error(`Unable to fetch PR Mergeability: ${e}`, PullRequestModel.ID); - return PullRequestMergeability.Unknown; + return { mergeability: PullRequestMergeability.Unknown }; } } /** * Set a draft pull request as ready to be reviewed. */ - async setReadyForReview(): Promise { + async setReadyForReview(): Promise { try { const { mutate, schema } = await this.githubRepository.ensure(); @@ -1354,7 +1939,16 @@ export class PullRequestModel extends IssueModel implements IPullRe */ this._telemetry.sendTelemetryEvent('pr.readyForReview.success'); - return data!.markPullRequestReadyForReview.pullRequest.isDraft; + const result: ReadyForReview = { + isDraft: data!.markPullRequestReadyForReview.pullRequest.isDraft, + mergeable: parseMergeability(data!.markPullRequestReadyForReview.pullRequest.mergeable, data!.markPullRequestReadyForReview.pullRequest.mergeStateStatus), + allowAutoMerge: data!.markPullRequestReadyForReview.pullRequest.viewerCanEnableAutoMerge || data!.markPullRequestReadyForReview.pullRequest.viewerCanDisableAutoMerge + }; + this.item.isDraft = result.isDraft; + this.item.mergeable = result.mergeable; + this.item.allowAutoMerge = result.allowAutoMerge; + this._onDidChange.fire({ draft: true }); + return result; } catch (e) { /* __GDPR__ "pr.readyForReview.failure" : {} @@ -1364,8 +1958,46 @@ export class PullRequestModel extends IssueModel implements IPullRe } } + /** + * Convert a pull request to draft. + */ + async convertToDraft(): Promise { + try { + const { mutate, schema } = await this.githubRepository.ensure(); + + const { data } = await mutate({ + mutation: schema.ConvertToDraft, + variables: { + input: { + pullRequestId: this.graphNodeId, + }, + }, + }); + + /* __GDPR__ + "pr.convertToDraft.success" : {} + */ + this._telemetry.sendTelemetryEvent('pr.convertToDraft.success'); + + const result: ConvertToDraft = { + isDraft: data!.convertPullRequestToDraft.pullRequest.isDraft, + mergeable: parseMergeability(data!.convertPullRequestToDraft.pullRequest.mergeable, data!.convertPullRequestToDraft.pullRequest.mergeStateStatus), + }; + this.item.isDraft = result.isDraft; + this.item.mergeable = result.mergeable; + this._onDidChange.fire({ draft: true }); + return result; + } catch (e) { + /* __GDPR__ + "pr.convertToDraft.failure" : {} + */ + this._telemetry.sendTelemetryErrorEvent('pr.convertToDraft.failure'); + throw e; + } + } + private updateCommentReactions(graphNodeId: string, reactionGroups: ReactionGroup[]) { - const reviewThread = this._reviewThreadsCache.find(thread => + const reviewThread = this._reviewThreadsCache?.find(thread => thread.comments.some(c => c.graphNodeId === graphNodeId), ); if (reviewThread) { @@ -1432,49 +2064,98 @@ export class PullRequestModel extends IssueModel implements IPullRe return data; } + private undoOptimisticResolveState(oldThread: IReviewThread | undefined) { + if (oldThread) { + oldThread.isResolved = !oldThread.isResolved; + oldThread.viewerCanResolve = !oldThread.viewerCanResolve; + oldThread.viewerCanUnresolve = !oldThread.viewerCanUnresolve; + this._onDidChangeReviewThreads.fire({ added: [], changed: [oldThread], removed: [] }); + } + } + async resolveReviewThread(threadId: string): Promise { - const { mutate, schema } = await this.githubRepository.ensure(); - const { data } = await mutate({ - mutation: schema.ResolveReviewThread, - variables: { - input: { - threadId, + const oldThread = this._reviewThreadsCache?.find(thread => thread.id === threadId); + + try { + Logger.debug(`Resolve review thread - enter`, PullRequestModel.ID); + + const { mutate, schema } = await this.githubRepository.ensure(); + + // optimistically update + if (oldThread && oldThread.viewerCanResolve) { + oldThread.isResolved = true; + oldThread.viewerCanResolve = false; + oldThread.viewerCanUnresolve = true; + this._onDidChangeReviewThreads.fire({ added: [], changed: [oldThread], removed: [] }); + } + + const { data } = await mutate({ + mutation: schema.ResolveReviewThread, + variables: { + input: { + threadId, + }, }, - }, - }); + }, { mutation: schema.LegacyResolveReviewThread, deleteProps: [] }); - if (!data) { - throw new Error('Resolve review thread failed.'); - } + if (!data) { + this.undoOptimisticResolveState(oldThread); + throw new Error('Resolve review thread failed.'); + } - const index = this._reviewThreadsCache.findIndex(thread => thread.id === threadId); - if (index > -1) { - const thread = parseGraphQLReviewThread(data.resolveReviewThread.thread, this.githubRepository); - this._reviewThreadsCache.splice(index, 1, thread); - this._onDidChangeReviewThreads.fire({ added: [], changed: [thread], removed: [] }); + const index = this._reviewThreadsCache?.findIndex(thread => thread.id === threadId) ?? -1; + if (index > -1) { + const thread = parseGraphQLReviewThread(data.resolveReviewThread.thread, this.githubRepository); + this._reviewThreadsCache?.splice(index, 1, thread); + this._onDidChangeReviewThreads.fire({ added: [], changed: [thread], removed: [] }); + } + Logger.debug(`Resolve review thread - done`, PullRequestModel.ID); + } catch (e) { + Logger.error(`Resolve review thread failed: ${e}`, PullRequestModel.ID); + this.undoOptimisticResolveState(oldThread); } } async unresolveReviewThread(threadId: string): Promise { - const { mutate, schema } = await this.githubRepository.ensure(); - const { data } = await mutate({ - mutation: schema.UnresolveReviewThread, - variables: { - input: { - threadId, + const oldThread = this._reviewThreadsCache?.find(thread => thread.id === threadId); + + try { + Logger.debug(`Unresolve review thread - enter`, PullRequestModel.ID); + + const { mutate, schema } = await this.githubRepository.ensure(); + + // optimistically update + if (oldThread && oldThread.viewerCanUnresolve) { + oldThread.isResolved = false; + oldThread.viewerCanUnresolve = false; + oldThread.viewerCanResolve = true; + this._onDidChangeReviewThreads.fire({ added: [], changed: [oldThread], removed: [] }); + } + + const { data } = await mutate({ + mutation: schema.UnresolveReviewThread, + variables: { + input: { + threadId, + }, }, - }, - }); + }, { mutation: schema.LegacyUnresolveReviewThread, deleteProps: [] }); - if (!data) { - throw new Error('Unresolve review thread failed.'); - } + if (!data) { + this.undoOptimisticResolveState(oldThread); + throw new Error('Unresolve review thread failed.'); + } - const index = this._reviewThreadsCache.findIndex(thread => thread.id === threadId); - if (index > -1) { - const thread = parseGraphQLReviewThread(data.unresolveReviewThread.thread, this.githubRepository); - this._reviewThreadsCache.splice(index, 1, thread); - this._onDidChangeReviewThreads.fire({ added: [], changed: [thread], removed: [] }); + const index = this._reviewThreadsCache?.findIndex(thread => thread.id === threadId) ?? -1; + if (index > -1) { + const thread = parseGraphQLReviewThread(data.unresolveReviewThread.thread, this.githubRepository); + this._reviewThreadsCache?.splice(index, 1, thread); + this._onDidChangeReviewThreads.fire({ added: [], changed: [thread], removed: [] }); + } + Logger.debug(`Unresolve review thread - done`, PullRequestModel.ID); + } catch (e) { + Logger.error(`Unresolve review thread failed: ${e}`, PullRequestModel.ID); + this.undoOptimisticResolveState(oldThread); } } @@ -1530,6 +2211,55 @@ export class PullRequestModel extends IssueModel implements IPullRe } } + async dequeuePullRequest(): Promise { + Logger.debug(`Dequeue pull request ${this.number} - enter`, GitHubRepository.ID); + const { mutate, schema } = await this.githubRepository.ensure(); + if (!schema.DequeuePullRequest) { + return false; + } + try { + await mutate({ + mutation: schema.DequeuePullRequest, + variables: { + input: { + id: this.graphNodeId + } + } + }); + + Logger.debug(`Dequeue pull request ${this.number} - done`, GitHubRepository.ID); + this.mergeQueueEntry = undefined; + return true; + } catch (e) { + Logger.error(`Dequeueing pull request failed: ${e}`, GitHubRepository.ID); + return false; + } + } + + async enqueuePullRequest(): Promise { + Logger.debug(`Enqueue pull request ${this.number} - enter`, GitHubRepository.ID); + const { mutate, schema } = await this.githubRepository.ensure(); + if (!schema.EnqueuePullRequest) { + return; + } + try { + const { data } = await mutate({ + mutation: schema.EnqueuePullRequest, + variables: { + input: { + pullRequestId: this.graphNodeId + } + } + }); + + Logger.debug(`Enqueue pull request ${this.number} - done`, GitHubRepository.ID); + const temp = parseMergeQueueEntry(data?.enqueuePullRequest.mergeQueueEntry) ?? undefined; + return temp; + } catch (e) { + Logger.error(`Enqueuing pull request failed: ${e}`, GitHubRepository.ID); + } + } + async initializePullRequestFileViewState(): Promise { const { query, schema, remote } = await this.githubRepository.ensure(); @@ -1566,46 +2296,89 @@ export class PullRequestModel extends IssueModel implements IPullRe } } - async markFileAsViewed(filePathOrSubpath: string): Promise { - const { mutate, schema } = await this.githubRepository.ensure(); - const fileName = filePathOrSubpath.startsWith(this.githubRepository.rootUri.path) ? - filePathOrSubpath.substring(this.githubRepository.rootUri.path.length + 1) : filePathOrSubpath; - await mutate({ - mutation: schema.MarkFileAsViewed, - variables: { - input: { - path: fileName, - pullRequestId: this.graphNodeId, - }, - }, - }); - - this.setFileViewedState(fileName, ViewedState.VIEWED, true); + private markFilesInProgressRefCount: Map = new Map(); + private updateMarkFilesInProgressRefCount(filePathOrSubpaths: string[], direction: 'increment' | 'decrement'): string[] { + const completed: string[] = []; + for (const f of filePathOrSubpaths) { + let count = this.markFilesInProgressRefCount.get(f) || 0; + if (direction === 'increment') { + count++; + } else { + count--; + } + if (count === 0) { + this.markFilesInProgressRefCount.delete(f); + completed.push(f); + } else { + this.markFilesInProgressRefCount.set(f, count); + } + } + return completed; } - async unmarkFileAsViewed(filePathOrSubpath: string): Promise { - const { mutate, schema } = await this.githubRepository.ensure(); - const fileName = filePathOrSubpath.startsWith(this.githubRepository.rootUri.path) ? - filePathOrSubpath.substring(this.githubRepository.rootUri.path.length + 1) : filePathOrSubpath; - await mutate({ - mutation: schema.UnmarkFileAsViewed, - variables: { - input: { - path: fileName, - pullRequestId: this.graphNodeId, - }, - }, - }); + async markFiles(filePathOrSubpaths: string[], event: boolean, state: 'viewed' | 'unviewed'): Promise { + const allFilenames = filePathOrSubpaths + .map((f) => + isDescendant(this.githubRepository.rootUri.path, f, false, '/') + ? f.substring(this.githubRepository.rootUri.path.length + 1) + : f + ); + + this.updateMarkFilesInProgressRefCount(allFilenames, 'increment'); + + const { mutate } = await this.githubRepository.ensure(); + const pullRequestId = this.graphNodeId; + + const mutationName = state === 'viewed' + ? 'markFileAsViewed' + : 'unmarkFileAsViewed'; + + // We only ever send 100 mutations at once. Any more than this and + // we risk a timeout from GitHub. + for (let i = 0; i < allFilenames.length; i += BATCH_SIZE) { + try { + const batch = allFilenames.slice(i, i + BATCH_SIZE); + // See below for an example of what a mutation produced by this + // will look like + const mutation = gql`mutation Batch${mutationName}{ + ${batch.map((filename, i) => + `alias${i}: ${mutationName}( + input: {path: "${filename}", pullRequestId: "${pullRequestId}"} + ) { clientMutationId } + ` + )} + }`; + await mutate({ mutation }); + } catch (e) { + Logger.error(`Error marking files as ${state}: ${e}`, PullRequestModel.ID); + } + } - this.setFileViewedState(fileName, ViewedState.UNVIEWED, true); + // mutation BatchUnmarkFileAsViewedInline { + // alias0: unmarkFileAsViewed( + // input: { path: "some_folder/subfolder/A.txt", pullRequestId: "PR_someid" } + // ) { + // clientMutationId + // } + // alias1: unmarkFileAsViewed( + // input: { path: "some_folder/subfolder/B.txt", pullRequestId: "PR_someid" } + // ) { + // clientMutationId + // } + // } + + // We keep a ref count of the files who's states are in the process of being modified so that we don't have UI flickering + const completed = this.updateMarkFilesInProgressRefCount(allFilenames, 'decrement'); + completed.forEach(path => this.setFileViewedState(path, state === 'viewed' ? ViewedState.VIEWED : ViewedState.UNVIEWED, event)); } - async unmarkAllFilesAsViewed(): Promise { - return Promise.all(Array.from(this.fileChanges.keys()).map(change => this.unmarkFileAsViewed(change))); + async unmarkAllFilesAsViewed(): Promise { + return this.markFiles(Array.from(this.fileChanges.keys()), true, 'unviewed'); } private setFileViewedState(fileSubpath: string, viewedState: ViewedState, event: boolean) { - const filePath = vscode.Uri.joinPath(this.githubRepository.rootUri, fileSubpath).fsPath; + const uri = vscode.Uri.joinPath(this.githubRepository.rootUri, fileSubpath); + const filePath = (this.githubRepository.rootUri.scheme === Schemes.VscodeVfs) ? uri.path : uri.fsPath; switch (viewedState) { case ViewedState.DISMISSED: { this._viewedFiles.delete(filePath); @@ -1635,3 +2408,18 @@ export class PullRequestModel extends IssueModel implements IPullRe }; } } + +export async function isCopilotOnMyBehalf(pullRequestModel: PullRequestModel, currentUser: IAccount, coAuthors?: IAccount[]): Promise { + if (!COPILOT_ACCOUNTS[pullRequestModel.author.login]) { + return false; + } + + if (!coAuthors) { + coAuthors = await pullRequestModel.getCoAuthors(); + } + if (!coAuthors || coAuthors.length === 0) { + return false; + } + + return coAuthors.some(c => c.login === currentUser.login); +} diff --git a/src/github/pullRequestOverview.ts b/src/github/pullRequestOverview.ts index 730c7653fc..c393c25d02 100644 --- a/src/github/pullRequestOverview.ts +++ b/src/github/pullRequestOverview.ts @@ -5,52 +5,80 @@ 'use strict'; import * as vscode from 'vscode'; -import { onDidUpdatePR, openPullRequestOnGitHub } from '../commands'; -import { IComment } from '../common/comment'; -import Logger from '../common/logger'; -import { ReviewEvent as CommonReviewEvent } from '../common/timelineEvent'; -import { asPromise, dispose, formatError } from '../common/utils'; -import { IRequestMessage, PULL_REQUEST_OVERVIEW_VIEW_TYPE } from '../common/webview'; +import { OpenCommitChangesArgs } from '../../common/views'; +import { openPullRequestOnGitHub } from '../commands'; +import { getCopilotApi } from './copilotApi'; +import { SessionIdForPr } from './copilotRemoteAgent'; import { FolderRepositoryManager } from './folderRepositoryManager'; import { GithubItemStateEnum, IAccount, - IMilestone, - ISuggestedReviewer, + isITeam, + ITeam, MergeMethod, MergeMethodsAvailability, - ReviewEvent, + PullRequestMergeability, + ReviewEventEnum, ReviewState, } from './interface'; import { IssueOverviewPanel } from './issueOverview'; -import { PullRequestModel } from './pullRequestModel'; -import { PullRequestView } from './pullRequestOverviewCommon'; -import { isInCodespaces, parseReviewers, vscodeDevPrLink } from './utils'; - -type MilestoneQuickPickItem = vscode.QuickPickItem & { id: string; milestone: IMilestone }; - -function isMilestoneQuickPickItem(x: vscode.QuickPickItem | MilestoneQuickPickItem): x is MilestoneQuickPickItem { - return !!(x as MilestoneQuickPickItem).id && !!(x as MilestoneQuickPickItem).milestone; -} +import { isCopilotOnMyBehalf, PullRequestModel } from './pullRequestModel'; +import { PullRequestReviewCommon, ReviewContext } from './pullRequestReviewCommon'; +import { branchPicks, pickEmail, reviewersQuickPick } from './quickPicks'; +import { parseReviewers } from './utils'; +import { CancelCodingAgentReply, ChangeBaseReply, ChangeReviewersReply, DeleteReviewResult, MergeArguments, MergeResult, PullRequest, ReviewType, UnresolvedIdentity } from './views'; +import { debounce } from '../common/async'; +import { COPILOT_ACCOUNTS, IComment } from '../common/comment'; +import { COPILOT_REVIEWER, COPILOT_REVIEWER_ACCOUNT, 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 { CHECKOUT_DEFAULT_BRANCH, CHECKOUT_PULL_REQUEST_BASE_BRANCH, DEFAULT_MERGE_METHOD, DELETE_BRANCH_AFTER_MERGE, POST_DONE, 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 ID: string = 'PullRequestOverviewPanel'; + public static override ID: string = 'PullRequestOverviewPanel'; + public static override readonly viewType = PULL_REQUEST_OVERVIEW_VIEW_TYPE; /** * Track the currently panel. Only allow a single panel to exist at a time. */ - public static currentPanel?: PullRequestOverviewPanel; + public static override currentPanel?: PullRequestOverviewPanel; + + /** + * Event emitter for when a PR overview becomes active + */ + private static _onVisible = new vscode.EventEmitter(); + public static readonly onVisible = PullRequestOverviewPanel._onVisible.event; private _repositoryDefaultBranch: string; private _existingReviewers: ReviewState[] = []; + private _teamsCount = 0; + private _assignableUsers: { [key: string]: IAccount[] } = {}; private _prListeners: vscode.Disposable[] = []; + private _updatingPromise: Promise | undefined; - public static async createOrShow( + public static override async createOrShow( + telemetry: ITelemetry, extensionUri: vscode.Uri, folderRepositoryManager: FolderRepositoryManager, - issue: PullRequestModel, - toTheSide: Boolean = false, + identity: UnresolvedIdentity, + issue?: PullRequestModel, + toTheSide: boolean = false, + preserveFocus: boolean = true, + existingPanel?: vscode.WebviewPanel ) { + + /* __GDPR__ + "pr.openDescription" : { + "isCopilot" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + telemetry.sendTelemetryEvent('pr.openDescription', { isCopilot: (issue?.author.login === COPILOT_SWE_AGENT) ? 'true' : 'false' }); + const activeColumn = toTheSide ? vscode.ViewColumn.Beside : vscode.window.activeTextEditor @@ -60,25 +88,27 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { - if (pr) { - this._item.update(pr); - } - this._postMessage({ - command: 'update-state', - state: this._item.state, - }); - }, - null, - this._disposables, - ); + this.setVisibilityContext(); - this._disposables.push( - folderRepositoryManager.onDidMergePullRequest(_ => { - this._postMessage({ - command: 'update-state', - state: GithubItemStateEnum.Merged, - }); - }), - ); + this._register(vscode.commands.registerCommand('pr.readyForReviewDescription', async () => { + return this.readyForReviewCommand(); + })); + this._register(vscode.commands.registerCommand('pr.readyForReviewAndMergeDescription', async (context: { mergeMethod: MergeMethod }) => { + return this.readyForReviewAndMergeCommand(context); + })); + 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._telemetry); + })); + this._register(vscode.commands.registerCommand('review.requestChangesOnDotComDescription', () => { + return openPullRequestOnGitHub(this._item, this._telemetry); + })); } - registerPrListeners() { - dispose(this._prListeners); - this._prListeners = []; + protected override registerPrListeners() { + disposeAll(this._prListeners); this._prListeners.push(this._folderRepositoryManager.onDidChangeActivePullRequest(_ => { if (this._folderRepositoryManager && this._item) { const isCurrentlyCheckedOut = this._item.equals(this._folderRepositoryManager.activePullRequest); @@ -138,153 +174,264 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { - this.refreshPanel(); + this._prListeners.push(this._item.onDidChange(e => { + if ((e.state || e.comments) && !this._updatingPromise) { + this.refreshPanel(); + } })); } } + protected override onDidChangeViewState(e: vscode.WebviewPanelOnDidChangeViewStateEvent): void { + super.onDidChangeViewState(e); + this.setVisibilityContext(); + + // If the panel becomes visible and we have an item, notify that this PR is active + if (this._panel.visible && this._item) { + PullRequestOverviewPanel._onVisible.fire(this._item); + } + } + + private setVisibilityContext() { + return commands.setContext(contexts.PULL_REQUEST_DESCRIPTION_VISIBLE, this._panel.visible); + } + /** * Find currently configured user's review status for the current PR * @param reviewers All the reviewers who have been requested to review the current PR * @param pullRequestModel Model of the PR */ private getCurrentUserReviewState(reviewers: ReviewState[], currentUser: IAccount): string | undefined { - const review = reviewers.find(r => r.reviewer.login === currentUser.login); - // There will always be a review. If not then the PR shouldn't have been or fetched/shown for the current user - return review?.state; - } - - private async updatePullRequest(pullRequestModel: PullRequestModel): Promise { - return Promise.all([ - this._folderRepositoryManager.resolvePullRequest( - pullRequestModel.remote.owner, - pullRequestModel.remote.repositoryName, - pullRequestModel.number, - ), - pullRequestModel.getTimelineEvents(), - this._folderRepositoryManager.getPullRequestRepositoryDefaultBranch(pullRequestModel), - pullRequestModel.getStatusChecks(), - pullRequestModel.getReviewRequests(), - this._folderRepositoryManager.getPullRequestRepositoryAccessAndMergeMethods(pullRequestModel), - this._folderRepositoryManager.getBranchNameForPullRequest(pullRequestModel), - this._folderRepositoryManager.getCurrentUser(pullRequestModel.githubRepository), - pullRequestModel.canEdit() - ]) - .then(result => { - const [ - pullRequest, - timelineEvents, - defaultBranch, - status, - requestedReviewers, - repositoryAccess, - branchInfo, - currentUser, - viewerCanEdit - ] = result; - if (!pullRequest) { - throw new Error( - `Fail to resolve Pull Request #${pullRequestModel.number} in ${pullRequestModel.remote.owner}/${pullRequestModel.remote.repositoryName}`, - ); - } + return PullRequestReviewCommon.getCurrentUserReviewState(reviewers, currentUser); + } - this._item = pullRequest; - this.registerPrListeners(); - this._repositoryDefaultBranch = defaultBranch!; - this._panel.title = `Pull Request #${pullRequestModel.number.toString()}`; + /** + * Get the review context for helper functions + */ + private getReviewContext(): ReviewContext { + return { + item: this._item, + folderRepositoryManager: this._folderRepositoryManager, + existingReviewers: this._existingReviewers, + postMessage: (message: any) => this._postMessage(message), + replyMessage: (message: IRequestMessage, response: any) => this._replyMessage(message, response), + throwError: (message: IRequestMessage | undefined, error: string) => this._throwError(message, error), + getTimeline: () => this._getTimeline() + }; + } - const isCurrentlyCheckedOut = pullRequestModel.equals(this._folderRepositoryManager.activePullRequest); - const hasWritePermission = repositoryAccess!.hasWritePermission; - const mergeMethodsAvailability = repositoryAccess!.mergeMethodsAvailability; - const canEdit = hasWritePermission || viewerCanEdit; + private isUpdateBranchWithGitHubEnabled(): boolean { + // With the GraphQL UpdatePullRequestBranch API, we can update branches even when not checked out + // (as long as there are no conflicts). If there are conflicts, we need the branch to be checked out. + const hasConflicts = this._item.item.mergeable === PullRequestMergeability.Conflict; + if (hasConflicts) { + return this._item.isActive; + } + return true; + } - const defaultMergeMethod = getDefaultMergeMethod(mergeMethodsAvailability); - this._existingReviewers = parseReviewers(requestedReviewers!, timelineEvents!, pullRequest.author); + protected override continueOnGitHub() { + const isCrossRepository: boolean = + !!this._item.base && + !!this._item.head && + !this._item.base.repositoryCloneUrl.equals(this._item.head.repositoryCloneUrl); + return super.continueOnGitHub() && isCrossRepository; + } - const isCrossRepository = - pullRequest.base && - pullRequest.head && - !pullRequest.base.repositoryCloneUrl.equals(pullRequest.head.repositoryCloneUrl); + private preLoadInfoNotRequiredForOverview(pullRequest: PullRequestModel): void { + // Load some more info in the background, don't await. + pullRequest.getFileChangesInfo(); + } - const continueOnGitHub = isCrossRepository && isInCodespaces(); - const reviewState = this.getCurrentUserReviewState(this._existingReviewers, currentUser); - Logger.debug('pr.initialize', PullRequestOverviewPanel.ID); - this._postMessage({ - command: 'pr.initialize', - pullrequest: { - number: pullRequest.number, - title: pullRequest.title, - titleHTML: pullRequest.titleHTML, - url: pullRequest.html_url, - createdAt: pullRequest.createdAt, - body: pullRequest.body, - bodyHTML: pullRequest.bodyHTML, - labels: pullRequest.item.labels, - author: { - login: pullRequest.author.login, - name: pullRequest.author.name, - avatarUrl: pullRequest.userAvatar, - url: pullRequest.author.url, - }, - state: pullRequest.state, - events: timelineEvents, - isCurrentlyCheckedOut: isCurrentlyCheckedOut, - isRemoteBaseDeleted: pullRequest.isRemoteBaseDeleted, - base: pullRequest.base.label, - isRemoteHeadDeleted: pullRequest.isRemoteHeadDeleted, - isLocalHeadDeleted: !branchInfo, - head: pullRequest.head?.label ?? '', - repositoryDefaultBranch: defaultBranch, - canEdit: canEdit, - hasWritePermission, - status: status ? status : { statuses: [] }, - mergeable: pullRequest.item.mergeable, - reviewers: this._existingReviewers, - isDraft: pullRequest.isDraft, - mergeMethodsAvailability, - defaultMergeMethod, - autoMerge: pullRequest.autoMerge, - allowAutoMerge: pullRequest.allowAutoMerge, - autoMergeMethod: pullRequest.autoMergeMethod, - isIssue: false, - milestone: pullRequest.milestone, - assignees: pullRequest.assignees, - continueOnGitHub, - isAuthor: currentUser.login === pullRequest.author.login, - currentUserReviewState: reviewState, - isDarkTheme: vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark - }, - }); - if (pullRequest.isResolved()) { - this._folderRepositoryManager.checkBranchUpToDate(pullRequest, true); + protected override async updateItem(pullRequestModel: PullRequestModel): Promise { + const isSamePullRequest = pullRequestModel.equals(this._item); + if (this._updatingPromise && isSamePullRequest) { + Logger.error('Already updating pull request webview', PullRequestOverviewPanel.ID); + return; + } else if (this._updatingPromise && !isSamePullRequest) { + this._item = pullRequestModel; + await this._updatingPromise; + } else { + this._item = pullRequestModel; + } + + try { + const updatingPromise = Promise.all([ + this._folderRepositoryManager.resolvePullRequest( + pullRequestModel.remote.owner, + pullRequestModel.remote.repositoryName, + pullRequestModel.number, + ), + pullRequestModel.getTimelineEvents(), + this._folderRepositoryManager.getPullRequestRepositoryDefaultBranch(pullRequestModel), + pullRequestModel.getStatusChecks(), + pullRequestModel.getReviewRequests(), + this._folderRepositoryManager.getPullRequestRepositoryAccessAndMergeMethods(pullRequestModel), + this._folderRepositoryManager.getBranchNameForPullRequest(pullRequestModel), + this._folderRepositoryManager.getCurrentUser(pullRequestModel.githubRepository), + pullRequestModel.canEdit(), + this._folderRepositoryManager.getOrgTeamsCount(pullRequestModel.githubRepository), + this._folderRepositoryManager.mergeQueueMethodForBranch(pullRequestModel.base.ref, pullRequestModel.remote.owner, pullRequestModel.remote.repositoryName), + this._folderRepositoryManager.isHeadUpToDateWithBase(pullRequestModel), + pullRequestModel.getMergeability(), + this._folderRepositoryManager.getPreferredEmail(pullRequestModel), + pullRequestModel.getCoAuthors(), + pullRequestModel.validateDraftMode(), + this._folderRepositoryManager.getAssignableUsers() + ]); + const clearingPromise = updatingPromise.finally(() => { + if (this._updatingPromise === clearingPromise) { + this._updatingPromise = undefined; } - }) - .catch(e => { - vscode.window.showErrorMessage(formatError(e)); }); - } + this._updatingPromise = clearingPromise; + + const [ + pullRequest, + timelineEvents, + defaultBranch, + status, + requestedReviewers, + repositoryAccess, + branchInfo, + currentUser, + viewerCanEdit, + orgTeamsCount, + mergeQueueMethod, + isBranchUpToDateWithBase, + mergeability, + emailForCommit, + coAuthors, + hasReviewDraft, + assignableUsers + ] = await updatingPromise; + + if (!pullRequest) { + throw new Error( + `Fail to resolve Pull Request #${pullRequestModel.number} in ${pullRequestModel.remote.owner}/${pullRequestModel.remote.repositoryName}`, + ); + } - public async update( - folderRepositoryManager: FolderRepositoryManager, - pullRequestModel: PullRequestModel, - ): Promise { - if (this._folderRepositoryManager !== folderRepositoryManager) { - this._folderRepositoryManager = folderRepositoryManager; + if (!this._item.equals(pullRequestModel)) { + // Updated PR is no longer the current one + return; + } + + this._item = pullRequest; this.registerPrListeners(); + this._repositoryDefaultBranch = defaultBranch!; + this._teamsCount = orgTeamsCount; + this._assignableUsers = assignableUsers; + this.setPanelTitle(`Pull Request #${pullRequestModel.number.toString()}`); + + const isCurrentlyCheckedOut = pullRequestModel.equals(this._folderRepositoryManager.activePullRequest); + const mergeMethodsAvailability = repositoryAccess!.mergeMethodsAvailability; + + const defaultMergeMethod = getDefaultMergeMethod(mergeMethodsAvailability); + this._existingReviewers = parseReviewers(requestedReviewers!, timelineEvents!, pullRequest.author); + + const isUpdateBranchWithGitHubEnabled: boolean = this.isUpdateBranchWithGitHubEnabled(); + const reviewState = this.getCurrentUserReviewState(this._existingReviewers, currentUser); + + Logger.debug('pr.initialize', PullRequestOverviewPanel.ID); + const users = this._assignableUsers[pullRequestModel.remote.remoteName] ?? []; + const copilotUser = users.find(user => COPILOT_ACCOUNTS[user.login]); + const isCopilotAlreadyReviewer = this._existingReviewers.some(reviewer => !isITeam(reviewer.reviewer) && reviewer.reviewer.login === COPILOT_REVIEWER); + const baseContext = this.getInitializeContext(currentUser, pullRequest, timelineEvents, repositoryAccess, viewerCanEdit, users); + + this.preLoadInfoNotRequiredForOverview(pullRequest); + + const postDoneAction = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(POST_DONE, CHECKOUT_DEFAULT_BRANCH); + const doneCheckoutBranch = postDoneAction.startsWith(CHECKOUT_PULL_REQUEST_BASE_BRANCH) + ? pullRequest.base.ref + : defaultBranch; + + const context: Partial = { + ...baseContext, + canRequestCopilotReview: copilotUser !== undefined && !isCopilotAlreadyReviewer, + isCurrentlyCheckedOut: isCurrentlyCheckedOut, + isRemoteBaseDeleted: pullRequest.isRemoteBaseDeleted, + base: `${pullRequest.base.owner}/${pullRequest.remote.repositoryName}:${pullRequest.base.ref}`, + isRemoteHeadDeleted: pullRequest.isRemoteHeadDeleted, + isLocalHeadDeleted: !branchInfo, + head: pullRequest.head ? `${pullRequest.head.owner}/${pullRequest.remote.repositoryName}:${pullRequest.head.ref}` : '', + repositoryDefaultBranch: defaultBranch, + doneCheckoutBranch: doneCheckoutBranch, + status: status[0], + reviewRequirement: status[1], + canUpdateBranch: pullRequest.item.viewerCanUpdate && !isBranchUpToDateWithBase && isUpdateBranchWithGitHubEnabled, + mergeable: mergeability.mergeability, + reviewers: this._existingReviewers, + isDraft: pullRequest.isDraft, + mergeMethodsAvailability, + defaultMergeMethod, + hasReviewDraft, + autoMerge: pullRequest.autoMerge, + allowAutoMerge: pullRequest.allowAutoMerge, + autoMergeMethod: pullRequest.autoMergeMethod, + mergeQueueMethod, + mergeQueueEntry: pullRequest.mergeQueueEntry, + mergeCommitMeta: pullRequest.mergeCommitMeta, + squashCommitMeta: pullRequest.squashCommitMeta, + isIssue: false, + emailForCommit, + currentUserReviewState: reviewState, + revertable: pullRequest.state === GithubItemStateEnum.Merged, + isCopilotOnMyBehalf: await isCopilotOnMyBehalf(pullRequest, currentUser, coAuthors), + generateDescriptionTitle: this.getGenerateDescriptionTitle() + }; + this._postMessage({ + command: 'pr.initialize', + pullrequest: context + }); + if (pullRequest.isResolved()) { + this._folderRepositoryManager.checkBranchUpToDate(pullRequest, true); + } + } catch (e) { + vscode.window.showErrorMessage(`Error updating pull request description: ${formatError(e)}`); } + } - this._postMessage({ - command: 'set-scroll', - scrollPosition: this._scrollPosition, - }); + /** + * Override to resolve pull requests instead of issues. + */ + protected override async resolveModel(identity: UnresolvedIdentity): Promise { + return this._folderRepositoryManager.resolvePullRequest( + identity.owner, + identity.repo, + identity.number + ); + } + + protected override getItemTypeName(): string { + return 'Pull Request'; + } - this._panel.webview.html = this.getHtmlForWebview(pullRequestModel.number.toString()); + public override async updateWithIdentity( + folderRepositoryManager: FolderRepositoryManager, + identity: UnresolvedIdentity, + pullRequestModel?: PullRequestModel, + progressLocation?: string + ): Promise { + await super.updateWithIdentity(folderRepositoryManager, identity, pullRequestModel, progressLocation); - return this.updatePullRequest(pullRequestModel); + // Notify that this PR overview is now active + PullRequestOverviewPanel._onVisible.fire(this._item); } - protected async _onDidReceiveMessage(message: IRequestMessage) { + public override async update( + folderRepositoryManager: FolderRepositoryManager, + pullRequestModel: PullRequestModel, + ): Promise { + const identity: UnresolvedIdentity = { + owner: pullRequestModel.remote.owner, + repo: pullRequestModel.remote.repositoryName, + number: pullRequestModel.number + }; + return this.updateWithIdentity(folderRepositoryManager, identity, pullRequestModel, 'pr:github'); + } + + protected override async _onDidReceiveMessage(message: IRequestMessage) { const result = await super._onDidReceiveMessage(message); if (result !== this.MESSAGE_UNHANDLED) { return; @@ -294,239 +441,116 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { - if (!suggestedReviewers) { - return []; - } - - const allAssignableUsers = await this._folderRepositoryManager.getAssignableUsers(); - const assignableUsers = allAssignableUsers[this._item.remote.remoteName] ?? []; - - // used to track logins that shouldn't be added to pick list - // e.g. author, existing and already added reviewers - const skipList: Set = new Set([ - this._item.author.login, - ...this._existingReviewers.map(reviewer => reviewer.reviewer.login), - ]); - - const reviewers: (vscode.QuickPickItem & { reviewer?: IAccount })[] = []; - - // Start will all existing reviewers so they show at the top - for (const reviewer of this._existingReviewers) { - reviewers.push({ - label: reviewer.reviewer.login, - description: reviewer.reviewer.name, - reviewer: reviewer.reviewer, - picked: true - }); - } - - for (const user of suggestedReviewers) { - const { login, name, isAuthor, isCommenter } = user; - if (skipList.has(login)) { - continue; - } - - const suggestionReason: string = - isAuthor && isCommenter - ? vscode.l10n.t('Recently edited and reviewed changes to these files') - : isAuthor - ? vscode.l10n.t('Recently edited these files') - : isCommenter - ? vscode.l10n.t('Recently reviewed changes to these files') - : vscode.l10n.t('Suggested reviewer'); - - reviewers.push({ - label: login, - description: name, - detail: suggestionReason, - reviewer: user, - }); - // this user shouldn't be added later from assignable users list - skipList.add(login); - } - - for (const user of assignableUsers) { - if (skipList.has(user.login)) { - continue; - } - - reviewers.push({ - label: user.login, - description: user.name, - reviewer: user, - }); - } - - if (reviewers.length === 0) { - reviewers.push({ - label: vscode.l10n.t('No reviewers available for this repository') - }); - } - - return reviewers; - } - - private async getAssigneesQuickPickItems(): - Promise<(vscode.QuickPickItem & { assignee?: IAccount })[]> { - - const [allAssignableUsers, { participants, viewer }] = await Promise.all([ - this._folderRepositoryManager.getAssignableUsers(), - this._folderRepositoryManager.getPullRequestParticipants(this._item.githubRepository, this._item.number) - ]); - - let assignableUsers = allAssignableUsers[this._item.remote.remoteName]; - - assignableUsers = assignableUsers ?? []; - // used to track logins that shouldn't be added to pick list - // e.g. author, existing and already added reviewers - const skipList: Set = new Set([...(this._item.assignees?.map(assignee => assignee.login) ?? [])]); - - const assignees: (vscode.QuickPickItem & { assignee?: IAccount })[] = []; - // Start will all currently assigned so they show at the top - for (const current of (this._item.assignees ?? [])) { - assignees.push({ - label: current.login, - description: current.name, - assignee: current, - picked: true - }); - } - - // Check if the viewer is allowed to be assigned to the PR - if (!skipList.has(viewer.login) && (assignableUsers.findIndex((assignableUser: IAccount) => assignableUser.login === viewer.login) !== -1)) { - assignees.push({ - label: viewer.login, - description: viewer.name, - assignee: viewer, - }); - skipList.add(viewer.login); - } - - for (const suggestedReviewer of participants) { - if (skipList.has(suggestedReviewer.login)) { - continue; - } - - assignees.push({ - label: suggestedReviewer.login, - description: suggestedReviewer.name, - assignee: suggestedReviewer, - }); - // this user shouldn't be added later from assignable users list - skipList.add(suggestedReviewer.login); - } - - if (assignees.length !== 0) { - assignees.unshift({ - kind: vscode.QuickPickItemKind.Separator, - label: vscode.l10n.t('Suggestions') - }); - } - - assignees.push({ - kind: vscode.QuickPickItemKind.Separator, - label: vscode.l10n.t('Users') - }); - - for (const user of assignableUsers) { - if (skipList.has(user.login)) { - continue; - } - - assignees.push({ - label: user.login, - description: user.name, - assignee: user, - }); - } - - if (assignees.length === 0) { - assignees.push({ - label: vscode.l10n.t('No assignees available for this repository') - }); + private gotoChangesSinceReview(message: IRequestMessage): Promise { + if (!this._item.showChangesSinceReview) { + this._item.showChangesSinceReview = true; + } else { + PullRequestModel.openChanges(this._folderRepositoryManager, this._item); } - - return assignees; + return this._replyMessage(message, {}); } private async changeReviewers(message: IRequestMessage): Promise { - const quickPick = vscode.window.createQuickPick(); + let quickPick: vscode.QuickPick | undefined; + try { - quickPick.busy = true; - quickPick.canSelectMany = true; - quickPick.matchOnDescription = true; - quickPick.show(); - quickPick.items = await this.getReviewersQuickPickItems(this._item.suggestedReviewers); - quickPick.selectedItems = quickPick.items.filter(item => item.picked); + quickPick = await reviewersQuickPick(this._folderRepositoryManager, this._item.remote.remoteName, this._item.base.isInOrganization, this._teamsCount, this._item.author, this._existingReviewers, this._item.suggestedReviewers); quickPick.busy = false; - const acceptPromise = asPromise(quickPick.onDidAccept).then(() => { - return quickPick.selectedItems.filter(item => item.reviewer) as (vscode.QuickPickItem & { reviewer: IAccount })[] | undefined; + const acceptPromise: Promise<(IAccount | ITeam)[]> = asPromise(quickPick.onDidAccept).then(() => { + const pickedReviewers: (IAccount | ITeam)[] | undefined = quickPick?.selectedItems.filter(item => item.user).map(item => item.user) as (IAccount | ITeam)[]; + return pickedReviewers; }); const hidePromise = asPromise(quickPick.onDidHide); - const allReviewers = await Promise.race<(vscode.QuickPickItem & { reviewer: IAccount })[] | void>([acceptPromise, hidePromise]); + const allReviewers = await Promise.race<(IAccount | ITeam)[] | void>([acceptPromise, hidePromise]); quickPick.busy = true; + quickPick.enabled = false; + if (allReviewers) { - const newReviewers = allReviewers.map(r => r.label); - const removedReviewers = this._existingReviewers.filter(existing => !newReviewers.find(newReviewer => newReviewer === existing.reviewer.login)); - await this._item.requestReview(newReviewers); - await this._item.deleteReviewRequest(removedReviewers.map(reviewer => reviewer.reviewer.login)); + const newUserReviewers: IAccount[] = []; + const newTeamReviewers: ITeam[] = []; + allReviewers.forEach(reviewer => { + const newReviewers: (IAccount | ITeam)[] = isITeam(reviewer) ? newTeamReviewers : newUserReviewers; + newReviewers.push(reviewer); + }); + + const removedUserReviewers: IAccount[] = []; + const removedTeamReviewers: ITeam[] = []; + this._existingReviewers.forEach(existing => { + let newReviewers: (IAccount | ITeam)[] = isITeam(existing.reviewer) ? newTeamReviewers : newUserReviewers; + let removedReviewers: (IAccount | ITeam)[] = isITeam(existing.reviewer) ? removedTeamReviewers : removedUserReviewers; + if (!newReviewers.find(newTeamReviewer => newTeamReviewer.id === existing.reviewer.id)) { + removedReviewers.push(existing.reviewer); + } + }); + + await this._item.requestReview(newUserReviewers, newTeamReviewers); + await this._item.deleteReviewRequest(removedUserReviewers, removedTeamReviewers); const addedReviewers: ReviewState[] = allReviewers.map(selected => { return { - reviewer: selected.reviewer, + reviewer: selected, state: 'REQUESTED', }; }); @@ -537,202 +561,108 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel): Promise { + private async applyPatch(message: IRequestMessage<{ comment: IComment }>): Promise { try { - const githubRepository = this._item.githubRepository; - async function getMilestoneOptions(): Promise<(MilestoneQuickPickItem | vscode.QuickPickItem)[]> { - const milestones = await githubRepository.getMilestones(); - if (!milestones.length) { - return [ - { - label: vscode.l10n.t('No milestones created for this repository.'), - }, - ]; - } - - return milestones.map(result => { - return { - label: result.title, - id: result.id, - milestone: result, - }; - }); - } - - const quickPick = vscode.window.createQuickPick(); - quickPick.busy = true; - quickPick.canSelectMany = false; - quickPick.title = vscode.l10n.t('Select a milestone to add'); - quickPick.buttons = [{ - iconPath: new vscode.ThemeIcon('add'), - tooltip: 'Create', - }]; - quickPick.onDidTriggerButton((_) => { - quickPick.hide(); - - const inputBox = vscode.window.createInputBox(); - inputBox.title = vscode.l10n.t('Create new milestone'); - inputBox.placeholder = vscode.l10n.t('New milestone title'); - if (quickPick.value !== '') { - inputBox.value = quickPick.value; - } - inputBox.show(); - inputBox.onDidAccept(async () => { - inputBox.hide(); - if (inputBox.value === '') { - return; - } - if (inputBox.value.length > 255) { - vscode.window.showErrorMessage(vscode.l10n.t(`Failed to create milestone: The title can contain a maximum of 255 characters`)); - return; - } - // Check if milestone already exists (only check open ones) - for (const existingMilestone of quickPick.items) { - if (existingMilestone.label === inputBox.value) { - vscode.window.showErrorMessage(vscode.l10n.t('Failed to create milestone: The milestone \'{0}\' already exists', inputBox.value)); - return; - } - } - try { - const milestone = await this._folderRepositoryManager.createMilestone(githubRepository, inputBox.value); - if (milestone !== undefined) { - await this.updateMilestone(milestone, message); - } - } catch (e) { - if (e.errors && Array.isArray(e.errors) && e.errors.find(error => error.code === 'already_exists') !== undefined) { - vscode.window.showErrorMessage(vscode.l10n.t('Failed to create milestone: The milestone already exists and might be closed')); - } - else { - vscode.window.showErrorMessage(`Failed to create milestone: ${formatError(e)}`); - } - } - }); - }); + const comment = message.args.comment; + const regex = /```diff\n([\s\S]*)\n```/g; + const matches = regex.exec(comment.body); - quickPick.show(); - quickPick.items = await getMilestoneOptions(); - quickPick.busy = false; + const tempUri = vscode.Uri.joinPath(this._folderRepositoryManager.repository.rootUri, '.git', `${comment.id}.diff`); - quickPick.onDidAccept(async () => { - quickPick.hide(); - const milestoneToAdd = quickPick.selectedItems[0]; - if (milestoneToAdd && isMilestoneQuickPickItem(milestoneToAdd)) { - await this.updateMilestone(milestoneToAdd.milestone, message); - } - }); + const encoder = new TextEncoder(); + await vscode.workspace.fs.writeFile(tempUri, encoder.encode(matches![1])); + await this._folderRepositoryManager.repository.apply(tempUri.fsPath); + await vscode.workspace.fs.delete(tempUri); + vscode.window.showInformationMessage('Patch applied!'); } catch (e) { - vscode.window.showErrorMessage(`Failed to add milestone: ${formatError(e)}`); + Logger.error(`Applying patch failed: ${e}`, PullRequestOverviewPanel.ID); + vscode.window.showErrorMessage(`Applying patch failed: ${formatError(e)}`); } } - private async updateMilestone(milestone: IMilestone, message: IRequestMessage) { - await this._item.updateMilestone(milestone.id); - this._replyMessage(message, { - added: milestone, - }); + protected override _getTimeline(): Promise { + return this._item.getTimelineEvents(); } - private async removeMilestone(message: IRequestMessage): Promise { + private async openDiff(message: IRequestMessage<{ comment: IComment }>): Promise { try { - await this._item.updateMilestone('null'); - this._replyMessage(message, {}); + const comment = message.args.comment; + return PullRequestModel.openDiffFromComment(this._folderRepositoryManager, this._item, comment); } catch (e) { - vscode.window.showErrorMessage(formatError(e)); + Logger.error(`Open diff view failed: ${formatError(e)}`, PullRequestOverviewPanel.ID); } } - private async changeAssignees(message: IRequestMessage): Promise { - const quickPick = vscode.window.createQuickPick(); - + private async openSessionLog(message: IRequestMessage<{ link: SessionLinkInfo }>): Promise { try { - quickPick.busy = true; - quickPick.canSelectMany = true; - quickPick.matchOnDescription = true; - quickPick.show(); - quickPick.items = await this.getAssigneesQuickPickItems(); - quickPick.selectedItems = quickPick.items.filter(item => item.picked); - - quickPick.busy = false; - const acceptPromise = asPromise(quickPick.onDidAccept).then(() => { - return quickPick.selectedItems.filter(item => item.assignee) as (vscode.QuickPickItem & { assignee: IAccount })[] | undefined; - }); - const hidePromise = asPromise(quickPick.onDidHide); - const allAssignees = await Promise.race<(vscode.QuickPickItem & { assignee: IAccount })[] | void>([acceptPromise, hidePromise]); - quickPick.busy = true; - - if (allAssignees) { - const newAssignees: IAccount[] = allAssignees.map(item => item.assignee); - const removeAssignees: IAccount[] = this._item.assignees?.filter(currentAssignee => !newAssignees.find(newAssignee => newAssignee.login === currentAssignee.login)) ?? []; - this._item.assignees = newAssignees; - - await this._item.addAssignees(newAssignees.map(assignee => assignee.login)); - await this._item.deleteAssignees(removeAssignees.map(assignee => assignee.login)); - await this._replyMessage(message, { - assignees: newAssignees, - }); - } + const resource = SessionIdForPr.getResource(this._item.number, message.args.link.sessionIndex); + return vscode.commands.executeCommand('vscode.open', resource); } catch (e) { - vscode.window.showErrorMessage(formatError(e)); - } finally { - quickPick.hide(); - quickPick.dispose(); + Logger.error(`Open session log view failed: ${formatError(e)}`, PullRequestOverviewPanel.ID); } } - private async addAssigneeYourself(message: IRequestMessage): Promise { + private async cancelCodingAgent(message: IRequestMessage): Promise { try { - const currentUser = await this._folderRepositoryManager.getCurrentUser(); - - this._item.assignees = this._item.assignees?.concat(currentUser); - - await this._item.addAssignees([currentUser.login]); - - this._replyMessage(message, { - assignees: this._item.assignees, - }); + let result = false; + if (message.args.event !== EventType.CopilotStarted) { + return this._replyMessage(message, { success: false, error: 'Invalid event type' }); + } else { + const copilotApi = await getCopilotApi(this._folderRepositoryManager.credentialStore, this._telemetry, this._item.remote.authProviderId); + if (copilotApi) { + const session = (await copilotApi.getAllSessions(this._item.id))[0]; + if (session.state !== 'completed') { + result = await this._item.githubRepository.cancelWorkflow(session.workflow_run_id); + } + } + } + // need to wait until we get the updated timeline events + let events: TimelineEvent[] = []; + if (result) { + do { + events = await this._getTimeline(); + } while (copilotEventToStatus(mostRecentCopilotEvent(events)) !== CopilotPRStatus.Completed && await new Promise(c => setTimeout(() => c(true), 2000))); + } + const reply: CancelCodingAgentReply = { + events + }; + this._replyMessage(message, reply); } catch (e) { - vscode.window.showErrorMessage(formatError(e)); + Logger.error(`Cancelling coding agent failed: ${formatError(e)}`, PullRequestOverviewPanel.ID); + vscode.window.showErrorMessage(vscode.l10n.t('Cannot cancel coding agent')); + const reply: CancelCodingAgentReply = { + events: [], + }; + this._replyMessage(message, reply); } } - private async applyPatch(message: IRequestMessage<{ comment: IComment }>): Promise { + private async openCommitChanges(message: IRequestMessage): Promise { try { - const comment = message.args.comment; - const regex = /```diff\n([\s\S]*)\n```/g; - const matches = regex.exec(comment.body); - - const tempUri = vscode.Uri.joinPath(this._folderRepositoryManager.repository.rootUri, '.git', `${comment.id}.diff`); - - const encoder = new TextEncoder(); - - await vscode.workspace.fs.writeFile(tempUri, encoder.encode(matches![1])); - await this._folderRepositoryManager.repository.apply(tempUri.fsPath); - await vscode.workspace.fs.delete(tempUri); - vscode.window.showInformationMessage('Patch applied!'); - } catch (e) { - Logger.error(`Applying patch failed: ${e}`, PullRequestOverviewPanel.ID); - vscode.window.showErrorMessage(`Applying patch failed: ${formatError(e)}`); + const { commitSha } = message.args; + 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); + vscode.window.showErrorMessage(vscode.l10n.t('Failed to open commit changes: {0}', formatError(error))); } } - private async openDiff(message: IRequestMessage<{ comment: IComment }>): Promise { - try { - const comment = message.args.comment; - return PullRequestModel.openDiffFromComment(this._folderRepositoryManager, this._item, comment); - } catch (e) { - Logger.error(`Open diff view failed: ${formatError(e)}`, PullRequestOverviewPanel.ID); - } + private async openChanges(message?: IRequestMessage<{ openToTheSide?: boolean }>): Promise { + const openToTheSide = message?.args?.openToTheSide || false; + return PullRequestModel.openChanges(this._folderRepositoryManager, this._item, openToTheSide); } - private async resolveComentThread(message: IRequestMessage<{ threadId: string, toResolve: boolean, thread: IComment[] }>) { + private async resolveCommentThread(message: IRequestMessage<{ threadId: string, toResolve: boolean, thread: IComment[] }>) { try { if (message.args.toResolve) { await this._item.resolveReviewThread(message.args.threadId); @@ -740,7 +670,7 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel, - ): void { - const { title, description, method } = message.args; - this._folderRepositoryManager - .mergePullRequest(this._item, title, description, method) - .then(result => { - vscode.commands.executeCommand('pr.refreshList'); + private async mergePullRequest( + message: IRequestMessage, + ): Promise { + const { title, description, method, email } = message.args; + try { + const result = await this._item.merge(this._folderRepositoryManager.repository, title, description, method, email); - if (!result.merged) { - vscode.window.showErrorMessage(`Merging PR failed: ${result.message}`); + if (!result.merged) { + vscode.window.showErrorMessage(`Merging pull request failed: ${result.message}`); + } else { + // Check if auto-delete branch setting is enabled + const deleteBranchAfterMerge = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(DELETE_BRANCH_AFTER_MERGE, false); + if (deleteBranchAfterMerge) { + // Automatically delete the branch after successful merge + await PullRequestReviewCommon.autoDeleteBranchesAfterMerge(this._folderRepositoryManager, this._item); } + } - this._replyMessage(message, { - state: result.merged ? GithubItemStateEnum.Merged : GithubItemStateEnum.Open, - }); - }) - .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 { + const email = await pickEmail(this._item.githubRepository, message.args); + if (email) { + this._folderRepositoryManager.saveLastUsedEmail(email); + } + return this._replyMessage(message, email ?? message.args); } private async deleteBranch(message: IRequestMessage) { - const result = await PullRequestView.deleteBranch(this._folderRepositoryManager, this._item); + const result = await PullRequestReviewCommon.deleteBranch(this._folderRepositoryManager, this._item); if (result.isReply) { this._replyMessage(message, result.message); } else { @@ -794,118 +739,120 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel): void { - this._item - .setReadyForReview() - .then(isDraft => { - vscode.commands.executeCommand('pr.refreshList'); + private async setReadyForReview(message: IRequestMessage<{}>): Promise { + return PullRequestReviewCommon.setReadyForReview(this.getReviewContext(), message); + } - this._replyMessage(message, { isDraft }); - }) - .catch(e => { - vscode.window.showErrorMessage(`Unable to set PR ready for review. ${formatError(e)}`); - this._throwError(message, {}); - }); + private async setReadyForReviewAndMerge(message: IRequestMessage<{ mergeMethod: MergeMethod }>): Promise { + return PullRequestReviewCommon.setReadyForReviewAndMerge(this.getReviewContext(), message); + } + + private async setConvertToDraft(message: IRequestMessage<{}>): Promise { + return PullRequestReviewCommon.setConvertToDraft(this.getReviewContext(), message); + } + + private async readyForReviewCommand(): Promise { + return PullRequestReviewCommon.readyForReviewCommand(this.getReviewContext()); + } + + private async readyForReviewAndMergeCommand(context: { mergeMethod: MergeMethod }): Promise { + return PullRequestReviewCommon.readyForReviewAndMergeCommand(this.getReviewContext(), context); } private async checkoutDefaultBranch(message: IRequestMessage): Promise { - try { - const prBranch = this._folderRepositoryManager.repository.state.HEAD?.name; - await this._folderRepositoryManager.checkoutDefaultBranch(message.args); - if (prBranch) { - await this._folderRepositoryManager.cleanupAfterPullRequest(prBranch, this._item); - } - } finally { - // Complete webview promise so that button becomes enabled again - this._replyMessage(message, {}); + return PullRequestReviewCommon.checkoutDefaultBranch(this.getReviewContext(), message); + } + + private async doReviewCommand(context: { body: string }, reviewType: ReviewType, action: (body: string) => Promise) { + const result = await PullRequestReviewCommon.doReviewCommand( + this.getReviewContext(), + context, + reviewType, + true, + action, + ); + if (result) { + this.tryScheduleCopilotRefresh(result.body, result.state); } } - private updateReviewers(review?: CommonReviewEvent): void { - if (review) { - const existingReviewer = this._existingReviewers.find( - reviewer => review.user.login === reviewer.reviewer.login, - ); - if (existingReviewer) { - existingReviewer.state = review.state; - } else { - this._existingReviewers.push({ - reviewer: review.user, - state: review.state, - }); - } + private async doReviewMessage(message: IRequestMessage, action: (body) => Promise) { + const result = await PullRequestReviewCommon.doReviewMessage( + this.getReviewContext(), + message, + true, + action, + ); + if (result) { + this.tryScheduleCopilotRefresh(result.body, result.state); } } - private approvePullRequest(message: IRequestMessage): void { - this._item.approve(message.args).then( - review => { - this.updateReviewers(review); - this._replyMessage(message, { - review: review, - reviewers: this._existingReviewers, - }); - //refresh the pr list as this one is approved - vscode.commands.executeCommand('pr.refreshList'); - }, - e => { - vscode.window.showErrorMessage(`Approving pull request failed. ${formatError(e)}`); + private approvePullRequest(body: string): Promise { + return this._item.approve(this._folderRepositoryManager.repository, body); + } - this._throwError(message, `${formatError(e)}`); - }, - ); + private approvePullRequestMessage(message: IRequestMessage): Promise { + return this.doReviewMessage(message, (body) => this.approvePullRequest(body)); } - private requestChanges(message: IRequestMessage): void { - this._item.requestChanges(message.args).then( - review => { - this.updateReviewers(review); - this._replyMessage(message, { - review: review, - reviewers: this._existingReviewers, - }); - }, - e => { - vscode.window.showErrorMessage(`Requesting changes failed. ${formatError(e)}`); - this._throwError(message, `${formatError(e)}`); - }, - ); + private approvePullRequestCommand(context: { body: string }): Promise { + return this.doReviewCommand(context, ReviewType.Approve, (body) => this.approvePullRequest(body)); } - private submitReview(message: IRequestMessage): void { - this._item.submitReview(ReviewEvent.Comment, message.args).then( - review => { - this.updateReviewers(review); - this._replyMessage(message, { - review: review, - reviewers: this._existingReviewers, - }); - }, - e => { - vscode.window.showErrorMessage(`Submitting review failed. ${formatError(e)}`); - this._throwError(message, `${formatError(e)}`); - }, - ); + private requestChanges(body: string): Promise { + return this._item.requestChanges(body); + } + + private requestChangesCommand(context: { body: string }): Promise { + return this.doReviewCommand(context, ReviewType.RequestChanges, (body) => this.requestChanges(body)); + } + + private requestChangesMessage(message: IRequestMessage): Promise { + return this.doReviewMessage(message, (body) => this.requestChanges(body)); + } + + private submitReview(body: string): Promise { + return this._item.submitReview(ReviewEventEnum.Comment, body); + } + + private submitReviewCommand(context: { body: string }) { + return this.doReviewCommand(context, ReviewType.Comment, (body) => this.submitReview(body)); + } + + protected override submitReviewMessage(message: IRequestMessage) { + return this.doReviewMessage(message, (body) => this.submitReview(body)); } private reRequestReview(message: IRequestMessage): void { - this._item.requestReview([message.args]).then(() => { - const reviewer = this._existingReviewers.find(reviewer => reviewer.reviewer.login === message.args); - if (reviewer) { - reviewer.state = 'REQUESTED'; - } - this._replyMessage(message, { - reviewers: this._existingReviewers, - }); - }); + return PullRequestReviewCommon.reRequestReview(this.getReviewContext(), message); } - private async copyPrLink(): Promise { - return vscode.env.clipboard.writeText(this._item.html_url); + private async addReviewerCopilot(message: IRequestMessage): Promise { + try { + const copilotUser = this._assignableUsers[this._item.remote.remoteName]?.find(user => COPILOT_ACCOUNTS[user.login]); + if (copilotUser) { + await this._item.requestReview([COPILOT_REVIEWER_ACCOUNT], []); + const newReviewers = await this._item.getReviewRequests(); + this._existingReviewers = parseReviewers(newReviewers!, await this._item.getTimelineEvents(), this._item.author); + const reply: ChangeReviewersReply = { + reviewers: this._existingReviewers + }; + this._replyMessage(message, reply); + } else { + this._throwError(message, 'Copilot reviewer not found.'); + } + } catch (e) { + vscode.window.showErrorMessage(formatError(e)); + this._throwError(message, formatError(e)); + } } - private async copyVscodeDevLink(): Promise { - return vscode.env.clipboard.writeText(vscodeDevPrLink(this._item)); + private async revert(message: IRequestMessage): Promise { + await this._folderRepositoryManager.createPullRequestHelper.revert(this._telemetry, this._extensionUri, this._folderRepositoryManager, this._item, async (pullRequest) => { + const result: Partial = { revertable: !pullRequest }; + return this._replyMessage(message, result); + }); } private async updateAutoMerge(message: IRequestMessage<{ autoMerge?: boolean, autoMergeMethod: MergeMethod }>): Promise { @@ -916,30 +863,201 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { + private async dequeue(message: IRequestMessage): Promise { + const result = await this._item.dequeuePullRequest(); + this._replyMessage(message, result); + } + + private async enqueue(message: IRequestMessage): Promise { + const result = await this._item.enqueuePullRequest(); + + // Check if auto-delete branch setting is enabled + const deleteBranchAfterMerge = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(DELETE_BRANCH_AFTER_MERGE, false); + if (deleteBranchAfterMerge && result) { + // For merge queues, only delete the local branch since the PR isn't merged yet + try { + await PullRequestReviewCommon.autoDeleteLocalBranchAfterEnqueue(this._folderRepositoryManager, this._item); + } catch (e) { + Logger.appendLine(`Auto-delete local branch after enqueue failed: ${formatError(e)}`, PullRequestOverviewPanel.ID); + void vscode.window.showWarningMessage(vscode.l10n.t('Auto-deleting the local branch after enqueueing to the merge queue failed.')); + } + } + + this._replyMessage(message, { mergeQueueEntry: result }); + } + + private async updateBranch(message: IRequestMessage): Promise { + return PullRequestReviewCommon.updateBranch( + this.getReviewContext(), + message, + () => this.refreshPanel(), + () => this.isUpdateBranchWithGitHubEnabled() + ); + } + + protected override editCommentPromise(comment: IComment, text: string): Promise { return this._item.editReviewComment(comment, text); } - protected deleteCommentPromise(comment: IComment): Promise { + protected override deleteCommentPromise(comment: IComment): Promise { return this._item.deleteReviewComment(comment.id.toString()); } - dispose() { + private async deleteReview(message: IRequestMessage) { + 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)}`); + } + } + + private getGenerateDescriptionTitle(): string | undefined { + const provider = this._folderRepositoryManager.getTitleAndDescriptionProvider(); + return provider ? `Generate description with ${provider.title}` : undefined; + } + + private generatingDescriptionCancellationToken: vscode.CancellationTokenSource | undefined; + + private async generateDescription(message: IRequestMessage): Promise { + if (this.generatingDescriptionCancellationToken) { + this.generatingDescriptionCancellationToken.cancel(); + } + this.generatingDescriptionCancellationToken = new vscode.CancellationTokenSource(); + + try { + const provider = this._folderRepositoryManager.getTitleAndDescriptionProvider(); + if (!provider) { + return this._replyMessage(message, { description: undefined }); + } + + // Get commits and raw file changes for the PR + const [commits, rawFileChanges] = await Promise.all([ + this._item.getCommits(), + this._item.getRawFileChangesInfo() + ]); + + const commitMessages = commits.map(commit => commit.commit.message); + const patches = rawFileChanges + .filter(file => file.patch !== undefined) + .map(file => { + const fileUri = vscode.Uri.joinPath(this._folderRepositoryManager.repository.rootUri, file.filename).toString(); + const previousFileUri = file.previous_filename ? + vscode.Uri.joinPath(this._folderRepositoryManager.repository.rootUri, file.previous_filename).toString() : + undefined; + return { patch: file.patch!, fileUri, previousFileUri }; + }); + + // Get the PR template + const templateContent = await this._folderRepositoryManager.getPullRequestTemplateBody(this._item.remote.owner); + + const result = await provider.provider.provideTitleAndDescription( + { commitMessages, patches, issues: [], template: templateContent }, + this.generatingDescriptionCancellationToken.token + ); + + this.generatingDescriptionCancellationToken = undefined; + return this._replyMessage(message, { description: result?.description }); + } catch (e) { + Logger.error(`Error generating description: ${formatError(e)}`, PullRequestOverviewPanel.ID); + this.generatingDescriptionCancellationToken = undefined; + return this._replyMessage(message, { description: undefined }); + } + } + + private async cancelGenerateDescription(): Promise { + if (this.generatingDescriptionCancellationToken) { + this.generatingDescriptionCancellationToken.cancel(); + this.generatingDescriptionCancellationToken = undefined; + } + } + + private async changeBaseBranch(message: IRequestMessage): Promise { + const quickPick = vscode.window.createQuickPick(); + let updateCounter = 0; + const updateItems = async (prefix: string | undefined) => { + const currentUpdate = ++updateCounter; + quickPick.busy = true; + const items = await branchPicks(this._item.githubRepository, this._folderRepositoryManager, undefined, true, prefix); + if (currentUpdate === updateCounter) { + quickPick.items = items; + quickPick.busy = false; + } + }; + const debounced = debounce(updateItems, 300); + const onDidChangeValueDisposable = quickPick.onDidChangeValue(async value => { + return debounced(value); + }); + + try { + quickPick.busy = true; + quickPick.canSelectMany = false; + quickPick.placeholder = vscode.l10n.t('Select a new base branch'); + quickPick.show(); + await updateItems(undefined); + + quickPick.busy = false; + const acceptPromise = asPromise(quickPick.onDidAccept).then(() => { + return quickPick.selectedItems[0]?.branch; + }); + const hidePromise = asPromise(quickPick.onDidHide); + const selectedBranch = await Promise.race([acceptPromise, hidePromise]); + quickPick.busy = true; + quickPick.enabled = false; + + if (selectedBranch) { + try { + await this._item.updateBaseBranch(selectedBranch); + const events = await this._getTimeline(); + const reply: ChangeBaseReply = { + base: selectedBranch, + events + }; + await this._replyMessage(message, reply); + } catch (e) { + Logger.error(formatError(e), PullRequestOverviewPanel.ID); + vscode.window.showErrorMessage(vscode.l10n.t('Changing base branch failed. {0}', formatError(e))); + this._throwError(message, `${formatError(e)}`); + } + } + } catch (e) { + Logger.error(formatError(e), PullRequestOverviewPanel.ID); + vscode.window.showErrorMessage(formatError(e)); + } finally { + quickPick.hide(); + onDidChangeValueDisposable.dispose(); + quickPick.dispose(); + } + } + + override dispose() { super.dispose(); - dispose(this._prListeners); + disposeAll(this._prListeners); + } + + /** + * Static dispose method to clean up static resources + */ + public static dispose() { + PullRequestOverviewPanel._onVisible.dispose(); } } export function getDefaultMergeMethod( methodsAvailability: MergeMethodsAvailability, ): MergeMethod { - const userPreferred = vscode.workspace.getConfiguration('githubPullRequests').get('defaultMergeMethod'); + const userPreferred = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(DEFAULT_MERGE_METHOD); // Use default merge method specified by user if it is available if (userPreferred && methodsAvailability.hasOwnProperty(userPreferred) && methodsAvailability[userPreferred]) { return userPreferred; diff --git a/src/github/pullRequestOverviewCommon.ts b/src/github/pullRequestOverviewCommon.ts deleted file mode 100644 index befe97829c..0000000000 --- a/src/github/pullRequestOverviewCommon.ts +++ /dev/null @@ -1,143 +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'; - -import * as vscode from 'vscode'; -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 }> { - const branchInfo = await folderRepositoryManager.getBranchNameForPullRequest(item); - const actions: (vscode.QuickPickItem & { type: 'upstream' | 'local' | 'remote' | 'suspend' })[] = []; - const defaultBranch = await folderRepositoryManager.getPullRequestRepositoryDefaultBranch(item); - - if (item.isResolved()) { - const branchHeadRef = item.head.ref; - - const isDefaultBranch = defaultBranch === item.head.ref; - if (!isDefaultBranch && !item.isRemoteHeadDeleted) { - actions.push({ - label: vscode.l10n.t('Delete remote branch {0}', `${item.remote.remoteName}/${branchHeadRef}`), - description: `${item.remote.normalizedHost}/${item.remote.owner}/${item.remote.repositoryName}`, - type: 'upstream', - picked: true, - }); - } - } - - if (branchInfo) { - const preferredLocalBranchDeletionMethod = vscode.workspace - .getConfiguration('githubPullRequests') - .get('defaultDeletionMethod.selectLocalBranch'); - actions.push({ - label: vscode.l10n.t('Delete local branch {0}', branchInfo.branch), - type: 'local', - picked: !!preferredLocalBranchDeletionMethod, - }); - - const preferredRemoteDeletionMethod = vscode.workspace - .getConfiguration('githubPullRequests') - .get('defaultDeletionMethod.selectRemote'); - - if (branchInfo.remote && branchInfo.createdForPullRequest && !branchInfo.remoteInUse) { - actions.push({ - label: vscode.l10n.t('Delete remote {0}, which is no longer used by any other branch', branchInfo.remote), - type: 'remote', - picked: !!preferredRemoteDeletionMethod, - }); - } - } - - if (vscode.env.remoteName === 'codespaces') { - actions.push({ - label: vscode.l10n.t('Suspend Codespace'), - type: 'suspend' - }); - } - - if (!actions.length) { - vscode.window.showWarningMessage( - vscode.l10n.t('There is no longer an upstream or local branch for Pull Request #{0}', item.number), - ); - return { - isReply: true, - message: { - cancelled: true - } - }; - } - - const selectedActions = await vscode.window.showQuickPick(actions, { - canPickMany: true, - ignoreFocusOut: true, - }); - - const deletedBranchTypes: string[] = []; - - if (selectedActions) { - const isBranchActive = item.equals(folderRepositoryManager.activePullRequest); - - const promises = selectedActions.map(async action => { - switch (action.type) { - case 'upstream': - await folderRepositoryManager.deleteBranch(item); - deletedBranchTypes.push(action.type); - await folderRepositoryManager.repository.fetch({ prune: true }); - // If we're in a remote repository, then we should checkout the default branch. - if (folderRepositoryManager.repository.rootUri.scheme === Schemes.VscodeVfs) { - await folderRepositoryManager.repository.checkout(defaultBranch); - } - return; - case 'local': - if (isBranchActive) { - if (folderRepositoryManager.repository.state.workingTreeChanges.length) { - const yes = vscode.l10n.t('Yes'); - const response = await vscode.window.showWarningMessage( - vscode.l10n.t('Your local changes will be lost, do you want to continue?'), - { modal: true }, - yes, - ); - if (response === yes) { - await vscode.commands.executeCommand('git.cleanAll'); - } else { - return; - } - } - await folderRepositoryManager.repository.checkout(defaultBranch); - } - await folderRepositoryManager.repository.deleteBranch(branchInfo!.branch, true); - return deletedBranchTypes.push(action.type); - case 'remote': - deletedBranchTypes.push(action.type); - return folderRepositoryManager.repository.removeRemote(branchInfo!.remote!); - case 'suspend': - deletedBranchTypes.push(action.type); - return vscode.commands.executeCommand('github.codespaces.disconnectSuspend'); - } - }); - - await Promise.all(promises); - - vscode.commands.executeCommand('pr.refreshList'); - - return { - isReply: false, - message: { - command: 'pr.deleteBranch', - branchTypes: deletedBranchTypes - } - }; - } else { - return { - isReply: true, - message: { - cancelled: true - } - }; - } - } -} \ No newline at end of file diff --git a/src/github/pullRequestReviewCommon.ts b/src/github/pullRequestReviewCommon.ts new file mode 100644 index 0000000000..fb7ea23785 --- /dev/null +++ b/src/github/pullRequestReviewCommon.ts @@ -0,0 +1,527 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import * as vscode from 'vscode'; +import { FolderRepositoryManager } from './folderRepositoryManager'; +import { IAccount, isITeam, ITeam, MergeMethod, PullRequestMergeability, reviewerId, ReviewState } from './interface'; +import { BranchInfo } from './pullRequestGitHelper'; +import { PullRequestModel } from './pullRequestModel'; +import { ConvertToDraftReply, PullRequest, ReadyForReviewReply, ReviewType, SubmitReviewReply } from './views'; +import { DEFAULT_DELETION_METHOD, PR_SETTINGS_NAMESPACE, SELECT_LOCAL_BRANCH, SELECT_REMOTE } from '../common/settingKeys'; +import { ReviewEvent, TimelineEvent } from '../common/timelineEvent'; +import { Schemes } from '../common/uri'; +import { formatError } from '../common/utils'; +import { IRequestMessage } from '../common/webview'; + +/** + * Context required by review utility functions + */ +export interface ReviewContext { + item: PullRequestModel; + folderRepositoryManager: FolderRepositoryManager; + existingReviewers: ReviewState[]; + postMessage(message: any): Promise; + replyMessage(message: IRequestMessage, response: any): void; + throwError(message: IRequestMessage | undefined, error: string): void; + getTimeline(): Promise; +} + +/** + * Utility functions for handling pull request reviews. + * These are shared between PullRequestOverviewPanel and PullRequestViewProvider. + */ +export namespace PullRequestReviewCommon { + /** + * Find currently configured user's review status for the current PR + */ + export function getCurrentUserReviewState(reviewers: ReviewState[], currentUser: IAccount): string | undefined { + const review = reviewers.find(r => reviewerId(r.reviewer) === currentUser.login); + // There will always be a review. If not then the PR shouldn't have been or fetched/shown for the current user + return review?.state; + } + + function updateReviewers(existingReviewers: ReviewState[], review?: ReviewEvent): void { + if (review && review.state) { + const existingReviewer = existingReviewers.find( + reviewer => review.user.login === reviewerId(reviewer.reviewer), + ); + if (existingReviewer) { + existingReviewer.state = review.state; + } else { + existingReviewers.push({ + reviewer: review.user, + state: review.state, + }); + } + } + } + + export async function doReviewCommand( + ctx: ReviewContext, + context: { body: string }, + reviewType: ReviewType, + needsTimelineRefresh: boolean, + action: (body: string) => Promise, + ): Promise { + const submittingMessage = { + command: 'pr.submitting-review', + lastReviewType: reviewType + }; + ctx.postMessage(submittingMessage); + try { + const review = await action(context.body); + updateReviewers(ctx.existingReviewers, review); + const allEvents = needsTimelineRefresh ? await ctx.getTimeline() : []; + const reviewMessage: SubmitReviewReply & { command: string } = { + command: 'pr.append-review', + reviewedEvent: review, + events: allEvents, + reviewers: ctx.existingReviewers + }; + await ctx.postMessage(reviewMessage); + return review; + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Submitting review failed. {0}', formatError(e))); + ctx.throwError(undefined, `${formatError(e)}`); + await ctx.postMessage({ command: 'pr.append-review' }); + } + } + + export async function doReviewMessage( + ctx: ReviewContext, + message: IRequestMessage, + needsTimelineRefresh: boolean, + action: (body: string) => Promise, + ): Promise { + try { + const review = await action(message.args); + updateReviewers(ctx.existingReviewers, review); + const allEvents = needsTimelineRefresh ? await ctx.getTimeline() : []; + const reply: SubmitReviewReply = { + reviewedEvent: review, + events: allEvents, + reviewers: ctx.existingReviewers, + }; + ctx.replyMessage(message, reply); + return review; + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Submitting review failed. {0}', formatError(e))); + ctx.throwError(message, `${formatError(e)}`); + } + } + + export function reRequestReview(ctx: ReviewContext, message: IRequestMessage): void { + let targetReviewer: ReviewState | undefined; + const userReviewers: IAccount[] = []; + const teamReviewers: ITeam[] = []; + + for (const reviewer of ctx.existingReviewers) { + let id = reviewer.reviewer.id; + if (id && ((reviewer.state === 'REQUESTED') || (id === message.args))) { + if (id === message.args) { + targetReviewer = reviewer; + } + } + } + + if (targetReviewer && isITeam(targetReviewer.reviewer)) { + teamReviewers.push(targetReviewer.reviewer); + } else if (targetReviewer && !isITeam(targetReviewer.reviewer)) { + userReviewers.push(targetReviewer.reviewer); + } + + ctx.item.requestReview(userReviewers, teamReviewers, true).then(() => { + if (targetReviewer) { + targetReviewer.state = 'REQUESTED'; + } + ctx.replyMessage(message, { + reviewers: ctx.existingReviewers, + }); + }); + } + + export async function checkoutDefaultBranch(ctx: ReviewContext, message: IRequestMessage): Promise { + try { + const prBranch = ctx.folderRepositoryManager.repository.state.HEAD?.name; + await ctx.folderRepositoryManager.checkoutDefaultBranch(message.args, ctx.item); + if (prBranch) { + await ctx.folderRepositoryManager.cleanupAfterPullRequest(prBranch, ctx.item); + } + } finally { + // Complete webview promise so that button becomes enabled again + ctx.replyMessage(message, {}); + } + } + + export async function updateBranch( + ctx: ReviewContext, + message: IRequestMessage, + refreshAfterUpdate: () => Promise, + checkUpdateEnabled?: () => boolean + ): Promise { + // When there are conflicts and the PR is not checked out, we need local checkout to resolve them + const hasConflicts = ctx.item.item.mergeable === PullRequestMergeability.Conflict; + if (hasConflicts && checkUpdateEnabled && !checkUpdateEnabled()) { + await vscode.window.showErrorMessage(vscode.l10n.t('The pull request branch must be checked out to resolve conflicts.'), { modal: true }); + return ctx.replyMessage(message, {}); + } + + // Working tree/index checks only apply when the PR is checked out + if (ctx.item.isActive && (ctx.folderRepositoryManager.repository.state.workingTreeChanges.length > 0 || ctx.folderRepositoryManager.repository.state.indexChanges.length > 0)) { + await vscode.window.showErrorMessage(vscode.l10n.t('The pull request branch cannot be updated when there are changed files in the working tree or index. Stash or commit all change and then try again.'), { modal: true }); + return ctx.replyMessage(message, {}); + } + const mergeSucceeded = await ctx.folderRepositoryManager.tryMergeBaseIntoHead(ctx.item, true); + if (!mergeSucceeded) { + ctx.replyMessage(message, {}); + } + // The mergability of the PR doesn't update immediately. Poll. + let mergability = PullRequestMergeability.Unknown; + let attemptsRemaining = 5; + do { + mergability = (await ctx.item.getMergeability()).mergeability; + attemptsRemaining--; + await new Promise(c => setTimeout(c, 1000)); + } while (attemptsRemaining > 0 && mergability === PullRequestMergeability.Unknown); + + const result: Partial = { + events: await ctx.getTimeline(), + mergeable: mergability, + }; + await refreshAfterUpdate(); + + ctx.replyMessage(message, result); + } + + export async function setReadyForReview(ctx: ReviewContext, message: IRequestMessage<{}>): Promise { + try { + const result = await ctx.item.setReadyForReview(); + ctx.replyMessage(message, result); + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Unable to set pull request ready for review. {0}', formatError(e))); + ctx.throwError(message, ''); + } + } + + export async function setReadyForReviewAndMerge(ctx: ReviewContext, message: IRequestMessage<{ mergeMethod: MergeMethod }>): Promise { + try { + const readyResult = await ctx.item.setReadyForReview(); + + try { + await ctx.item.approve(ctx.folderRepositoryManager.repository, ''); + } catch (e) { + vscode.window.showErrorMessage(`Pull request marked as ready for review, but failed to approve. ${formatError(e)}`); + ctx.replyMessage(message, readyResult); + return; + } + + try { + await ctx.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)}`); + ctx.replyMessage(message, readyResult); + return; + } + + ctx.replyMessage(message, readyResult); + } catch (e) { + vscode.window.showErrorMessage(`Unable to mark pull request as ready for review. ${formatError(e)}`); + ctx.throwError(message, ''); + } + } + + export async function setConvertToDraft(ctx: ReviewContext, _message: IRequestMessage<{}>): Promise { + try { + const result: ConvertToDraftReply = await ctx.item.convertToDraft(); + ctx.replyMessage(_message, result); + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Unable to convert pull request to draft. {0}', formatError(e))); + ctx.throwError(_message, ''); + } + } + + export async function readyForReviewCommand(ctx: ReviewContext): Promise { + ctx.postMessage({ + command: 'pr.readying-for-review' + }); + try { + const result = await ctx.item.setReadyForReview(); + + const readiedResult: ReadyForReviewReply = { + isDraft: result.isDraft + }; + await ctx.postMessage({ + command: 'pr.readied-for-review', + result: readiedResult + }); + } catch (e) { + vscode.window.showErrorMessage(`Unable to set pull request ready for review. ${formatError(e)}`); + ctx.throwError(undefined, e.message); + } + } + + export async function readyForReviewAndMergeCommand(ctx: ReviewContext, context: { mergeMethod: MergeMethod }): Promise { + ctx.postMessage({ + command: 'pr.readying-for-review' + }); + try { + const [readyResult, approveResult] = await Promise.all([ctx.item.setReadyForReview(), ctx.item.approve(ctx.folderRepositoryManager.repository)]); + await ctx.item.enableAutoMerge(context.mergeMethod); + updateReviewers(ctx.existingReviewers, approveResult); + + const readiedResult: ReadyForReviewReply = { + isDraft: readyResult.isDraft, + autoMerge: true, + reviewEvent: approveResult, + reviewers: ctx.existingReviewers + }; + await ctx.postMessage({ + command: 'pr.readied-for-review', + result: readiedResult + }); + } catch (e) { + vscode.window.showErrorMessage(`Unable to set pull request ready for review. ${formatError(e)}`); + ctx.throwError(undefined, e.message); + } + } + + interface SelectedAction { + type: 'remoteHead' | 'local' | 'remote' | 'suspend' + }; + + export async function deleteBranch(folderRepositoryManager: FolderRepositoryManager, item: PullRequestModel): Promise<{ isReply: boolean, message: any }> { + const branchInfo = await folderRepositoryManager.getBranchNameForPullRequest(item); + const actions: (vscode.QuickPickItem & SelectedAction)[] = []; + const defaultBranch = await folderRepositoryManager.getPullRequestRepositoryDefaultBranch(item); + + if (item.isResolved()) { + const branchHeadRef = item.head.ref; + const headRepo = folderRepositoryManager.findRepo(repo => repo.remote.owner === item.head.owner && repo.remote.repositoryName === item.remote.repositoryName); + + const isDefaultBranch = defaultBranch === item.head.ref; + if (!isDefaultBranch && !item.isRemoteHeadDeleted) { + actions.push({ + label: vscode.l10n.t('Delete remote branch {0}', `${headRepo?.remote.remoteName}/${branchHeadRef}`), + description: `${item.remote.normalizedHost}/${item.head.repositoryCloneUrl.owner}/${item.remote.repositoryName}`, + type: 'remoteHead', + picked: true, + }); + } + } + + if (branchInfo) { + const preferredLocalBranchDeletionMethod = vscode.workspace + .getConfiguration(PR_SETTINGS_NAMESPACE) + .get(`${DEFAULT_DELETION_METHOD}.${SELECT_LOCAL_BRANCH}`); + actions.push({ + label: vscode.l10n.t('Delete local branch {0}', branchInfo.branch), + type: 'local', + picked: !!preferredLocalBranchDeletionMethod, + }); + + const preferredRemoteDeletionMethod = vscode.workspace + .getConfiguration(PR_SETTINGS_NAMESPACE) + .get(`${DEFAULT_DELETION_METHOD}.${SELECT_REMOTE}`); + + if (branchInfo.remote && branchInfo.createdForPullRequest && !branchInfo.remoteInUse) { + actions.push({ + label: vscode.l10n.t('Delete remote {0}, which is no longer used by any other branch', branchInfo.remote), + type: 'remote', + picked: !!preferredRemoteDeletionMethod, + }); + } + } + + if (vscode.env.remoteName === 'codespaces') { + actions.push({ + label: vscode.l10n.t('Suspend Codespace'), + type: 'suspend' + }); + } + + if (!actions.length) { + vscode.window.showWarningMessage( + vscode.l10n.t('There is no longer an upstream or local branch for Pull Request #{0}', item.number), + ); + return { + isReply: true, + message: { + cancelled: true + } + }; + } + + const selectedActions = await vscode.window.showQuickPick(actions, { + canPickMany: true, + ignoreFocusOut: true, + }); + + + if (selectedActions) { + const deletedBranchTypes: string[] = await performBranchDeletion(folderRepositoryManager, item, defaultBranch, branchInfo!, selectedActions); + + return { + isReply: false, + message: { + command: 'pr.deleteBranch', + branchTypes: deletedBranchTypes + } + }; + } else { + return { + isReply: true, + message: { + cancelled: true + } + }; + } + } + + async function performBranchDeletion(folderRepositoryManager: FolderRepositoryManager, item: PullRequestModel, defaultBranch: string, branchInfo: BranchInfo, selectedActions: SelectedAction[]): Promise { + const isBranchActive = item.equals(folderRepositoryManager.activePullRequest) || (folderRepositoryManager.repository.state.HEAD?.name && folderRepositoryManager.repository.state.HEAD.name === branchInfo?.branch); + const deletedBranchTypes: string[] = []; + + const promises = selectedActions.map(async action => { + switch (action.type) { + case 'remoteHead': + await folderRepositoryManager.deleteBranch(item); + deletedBranchTypes.push(action.type); + await folderRepositoryManager.repository.fetch({ prune: true }); + // If we're in a remote repository, then we should checkout the default branch. + if (folderRepositoryManager.repository.rootUri.scheme === Schemes.VscodeVfs) { + await folderRepositoryManager.repository.checkout(defaultBranch); + } + return; + case 'local': + if (isBranchActive) { + if (folderRepositoryManager.repository.state.workingTreeChanges.length) { + const yes = vscode.l10n.t('Yes'); + const response = await vscode.window.showWarningMessage( + vscode.l10n.t('Your local changes will be lost, do you want to continue?'), + { modal: true }, + yes, + ); + if (response === yes) { + await vscode.commands.executeCommand('git.cleanAll'); + } else { + return; + } + } + await folderRepositoryManager.checkoutDefaultBranch(defaultBranch, item); + } + await folderRepositoryManager.repository.deleteBranch(branchInfo!.branch, true); + return deletedBranchTypes.push(action.type); + case 'remote': + deletedBranchTypes.push(action.type); + return folderRepositoryManager.repository.removeRemote(branchInfo!.remote!); + case 'suspend': + deletedBranchTypes.push(action.type); + return vscode.commands.executeCommand('github.codespaces.disconnectSuspend'); + } + }); + + await Promise.all(promises); + return deletedBranchTypes; + } + + /** + * Automatically delete the local branch after adding to a merge queue. + * Only deletes the local branch since the PR isn't merged yet. + */ + export async function autoDeleteLocalBranchAfterEnqueue(folderRepositoryManager: FolderRepositoryManager, item: PullRequestModel): Promise { + const branchInfo = await folderRepositoryManager.getBranchNameForPullRequest(item); + const defaultBranch = await folderRepositoryManager.getPullRequestRepositoryDefaultBranch(item); + + // Get user preference for local branch deletion + const deleteLocalBranch = vscode.workspace + .getConfiguration(PR_SETTINGS_NAMESPACE) + .get(`${DEFAULT_DELETION_METHOD}.${SELECT_LOCAL_BRANCH}`, true); + + if (!branchInfo || !deleteLocalBranch) { + return; + } + + const selectedActions: SelectedAction[] = [{ type: 'local' }]; + + // Execute deletion + const deletedBranchTypes = await performBranchDeletion(folderRepositoryManager, item, defaultBranch, branchInfo, selectedActions); + + // Show notification + if (deletedBranchTypes.includes('local')) { + const branchName = branchInfo.branch || item.head?.ref; + if (branchName) { + vscode.window.showInformationMessage( + vscode.l10n.t('Deleted local branch {0}.', branchName) + ); + } + } + } + + /** + * Automatically delete branches after merge based on user preferences. + * This function does not show any prompts - it uses the default deletion method preferences. + */ + export async function autoDeleteBranchesAfterMerge(folderRepositoryManager: FolderRepositoryManager, item: PullRequestModel): Promise { + const branchInfo = await folderRepositoryManager.getBranchNameForPullRequest(item); + const defaultBranch = await folderRepositoryManager.getPullRequestRepositoryDefaultBranch(item); + + // Get user preferences for automatic deletion + const deleteLocalBranch = vscode.workspace + .getConfiguration(PR_SETTINGS_NAMESPACE) + .get(`${DEFAULT_DELETION_METHOD}.${SELECT_LOCAL_BRANCH}`, true); + + const deleteRemote = vscode.workspace + .getConfiguration(PR_SETTINGS_NAMESPACE) + .get(`${DEFAULT_DELETION_METHOD}.${SELECT_REMOTE}`, true); + + const selectedActions: SelectedAction[] = []; + + // Delete remote head branch if it's not the default branch + if (item.isResolved()) { + const isDefaultBranch = defaultBranch === item.head.ref; + if (!isDefaultBranch && !item.isRemoteHeadDeleted) { + selectedActions.push({ type: 'remoteHead' }); + } + } + + // Delete local branch if preference is set + if (branchInfo && deleteLocalBranch) { + selectedActions.push({ type: 'local' }); + } + + // Delete remote if it's no longer used and preference is set + if (branchInfo && branchInfo.remote && branchInfo.createdForPullRequest && !branchInfo.remoteInUse && deleteRemote) { + selectedActions.push({ type: 'remote' }); + } + + // Execute all deletions in parallel + const deletedBranchTypes = await performBranchDeletion(folderRepositoryManager, item, defaultBranch, branchInfo!, selectedActions); + + // Show notification to the user about what was deleted + if (deletedBranchTypes.length > 0) { + const wasLocalDeleted = deletedBranchTypes.includes('local'); + const wasRemoteDeleted = deletedBranchTypes.includes('remoteHead') || deletedBranchTypes.includes('remote'); + const branchName = branchInfo?.branch || item.head?.ref; + + // Only show notification if we have a branch name + if (branchName) { + if (wasLocalDeleted && wasRemoteDeleted) { + vscode.window.showInformationMessage( + vscode.l10n.t('Deleted local and remote branches for {0}.', branchName) + ); + } else if (wasLocalDeleted) { + vscode.window.showInformationMessage( + vscode.l10n.t('Deleted local branch {0}.', branchName) + ); + } else { + vscode.window.showInformationMessage( + vscode.l10n.t('Deleted remote branch {0}.', branchName) + ); + } + } + } + } +} diff --git a/src/github/queries.gql b/src/github/queries.gql index a4dfff92e9..b4fe0a69fa 100644 --- a/src/github/queries.gql +++ b/src/github/queries.gql @@ -3,159 +3,139 @@ # * Licensed under the MIT License. See License.txt in the project root for license information. # *--------------------------------------------------------------------------------------------*/ -fragment Merged on MergedEvent { +#import "./queriesShared.gql" + +# Queries that are also in the limit file, but are not limited (by scope or API availability) here + +fragment Node on Node { id - actor { - login - avatarUrl - url - } - createdAt - mergeRef { - name - } - commit { - oid - commitUrl - } +} + +fragment Actor on Actor { # We don't want to reference Bot because it is not available on older GHE, so we use Actor instead as it gets us most of the way there. + __typename + login + avatarUrl url } -fragment HeadRefDeleted on HeadRefDeletedEvent { - id - actor { - login - avatarUrl - url - } - createdAt - headRefName +fragment User on User { + __typename + ...Actor + email + name + ...Node } -fragment Ref on Ref { +fragment Organization on Organization { + __typename + ...Actor + email name - repository { - owner { - login - } - url - } - target { - oid - } + ...Node } -fragment Comment on IssueComment { - id - databaseId - authorAssociation - author { - login - avatarUrl - url - ... on User { - email - } - ... on Organization { - email - } - } +fragment Team on Team { # Team is not an Actor + name + avatarUrl url - body - bodyHTML - updatedAt - createdAt - viewerCanUpdate - viewerCanReact - viewerCanDelete + slug + ...Node } -fragment Commit on PullRequestCommit { - id - commit { - author { - user { - login - avatarUrl - url - email +fragment Reactable on Reactable { + reactionGroups { + content + viewerHasReacted + reactors(first: 10) { + nodes { + ... on User { + login + } + ... on Actor { + login + } } + totalCount } - committer { - avatarUrl - name - } - oid - message - authoredDate } - url } -fragment AssignedEvent on AssignedEvent { - id - actor { - login - avatarUrl - url +fragment IssueBase on Issue { + number + url + state + stateReason + body + bodyHTML + title + titleHTML + author { + ...Node + ...Actor + ...User + ...Organization } - user { - login - avatarUrl - url + createdAt + updatedAt + milestone { + title + dueOn + createdAt + id + number + } + assignees: assignedActors(first: 10) { + nodes { + ...Node + ...Actor + ...User + } + } + labels(first: 50) { + nodes { + name + color + } } -} - -fragment Review on PullRequestReview { id databaseId - authorAssociation - url - author { - login - avatarUrl - url - ... on User { - email - } - ... on Organization { - email + reactions(first: 100) { + totalCount + } + ...Reactable + repository { + name + owner { + login } + url } - state - body - bodyHTML - submittedAt - updatedAt - createdAt } -fragment Reactable on Reactable { - reactionGroups { - content - viewerHasReacted - users { - totalCount - } +fragment IssueFragment on Issue { + ...IssueBase + comments(first: 1) { + totalCount } } -fragment ReviewThread on PullRequestReviewThread { - id - isResolved - viewerCanResolve - viewerCanUnresolve - path - diffSide - line - startLine - originalStartLine - originalLine - isOutdated - comments(first: 100) { +fragment IssueWithCommentsFragment on Issue { + ...IssueBase + comments(first: 50) { nodes { - ...ReviewComment + author { + ...Node + ...Actor + ...User + ...Organization + } + body + databaseId + reactions(first: 100) { + totalCount + } } + totalCount } } @@ -165,27 +145,64 @@ fragment PullRequestFragment on PullRequest { state body bodyHTML - titleHTML title + titleHTML author { - login - url - avatarUrl - ... on User { - email - } - ... on Organization { - email - } + ...Node + ...Actor + ...User + ...Organization } createdAt updatedAt + milestone { + title + dueOn + createdAt + id + number + } + assignees: assignedActors(first: 10) { + nodes { + ...Node + ...Actor + ...User + } + } + labels(first: 50) { + nodes { + name + color + } + } + id + databaseId + reactions(first: 100) { + totalCount + } + ...Reactable + comments(first: 1) { + totalCount + } + + comments(first: 1) { + totalCount + } + + commits(first: 50) { + nodes { + commit { + message + } + } + } headRef { ...Ref } headRefName headRefOid headRepository { + isInOrganization owner { login } @@ -197,634 +214,466 @@ fragment PullRequestFragment on PullRequest { baseRefName baseRefOid baseRepository { + isInOrganization owner { login } url - } - labels(first: 50) { - nodes { - name - color - } + squashMergeCommitTitle + squashMergeCommitMessage + mergeCommitMessage + mergeCommitTitle } merged mergeable + mergeQueueEntry { + ...MergeQueueEntryFragment + } mergeStateStatus autoMergeRequest { mergeMethod } + reviewThreads { + totalCount + } viewerCanEnableAutoMerge viewerCanDisableAutoMerge - id - databaseId + viewerCanUpdate isDraft - milestone { - title - dueOn - createdAt - id - } - assignees(first: 10) { - nodes { - login - name - avatarUrl - url - email - } - } suggestedReviewers { isAuthor isCommenter reviewer { - login - avatarUrl - name - url + ...Actor + ...User + ...Node } } + additions + deletions } -query TimelineEvents($owner: String!, $name: String!, $number: Int!, $last: Int = 150) { +query Issue($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - timelineItems(last: $last) { - nodes { - __typename - ...Merged - ...Comment - ...Review - ...Commit - ...AssignedEvent - ...HeadRefDeleted - } - } + issue(number: $number) { + ...IssueFragment } } rateLimit { - limit - cost - remaining - resetAt + ...RateLimit } } -query IssueTimelineEvents($owner: String!, $name: String!, $number: Int!, $last: Int = 150) { +query IssueWithComments($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { - pullRequest: issue(number: $number) { - timelineItems(last: $last) { - nodes { - __typename - ...Comment - ...AssignedEvent - } - } + issue(number: $number) { + ...IssueWithCommentsFragment } } rateLimit { - limit - cost - remaining - resetAt + ...RateLimit } } -query LatestReviewCommit($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - viewerLatestReview { - commit { - oid - } +query Issues($query: String!) { + search(first: 100, type: ISSUE, query: $query) { + issueCount + pageInfo { + hasNextPage + endCursor + } + edges { + node { + ...IssueFragment } } } rateLimit { - limit - cost - remaining - resetAt + ...RateLimit } } -query LatestReviews($owner: String!, $name: String!, $number: Int!) { +query PullRequest($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { - latestReviews (first: 10) { - nodes { - state - } - } + ...PullRequestFragment } } rateLimit { - limit - cost - remaining - resetAt + ...RateLimit } } -fragment ReviewComment on PullRequestReviewComment { - id - databaseId - url - author { - login - avatarUrl - url - ... on User { - email - } - ... on Organization { - email - } - } - path - originalPosition - body - bodyHTML - diffHunk - position - state - pullRequestReview { - databaseId - } - commit { - oid - } - replyTo { - databaseId - } - createdAt - originalCommit { - oid - } - reactionGroups { - content - viewerHasReacted - users { - totalCount - } - } - viewerCanUpdate - viewerCanDelete -} -query GetParticipants($owner: String!, $name: String!, $number: Int!, $first: Int!) { +query PullRequestForHead($owner: String!, $name: String!, $headRefName: String!) { repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - participants(first: $first) { - nodes { - login - avatarUrl - name - url - email - } + pullRequests(first: 3, headRefName: $headRefName, orderBy: { field: CREATED_AT, direction: DESC }) { + nodes { + ...PullRequestFragment } } } rateLimit { - limit - cost - remaining - resetAt + ...RateLimit } } -query GetPendingReviewId($pullRequestId: ID!, $author: String!) { - node(id: $pullRequestId) { - ... on PullRequest { - reviews(first: 1, author: $author, states: [PENDING]) { - nodes { - id +query PullRequestMergeabilityMergeRequirements($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + mergeable + mergeStateStatus + mergeRequirements { # This is a privage field we're testing + state + conditions { + result + ... on PullRequestMergeConflictStateCondition { + __typename + conflicts + isConflictResolvableInWeb + } } } } } rateLimit { - limit - cost - remaining - resetAt + ...RateLimit } } -query PullRequestComments($owner: String!, $name: String!, $number: Int!, $first: Int = 100) { +query LatestUpdates($owner: String!, $name: String!, $number: Int!, $since: DateTime!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { - reviewThreads(first: $first) { + reactions(orderBy:{direction:DESC, field: CREATED_AT}, first: 1) { nodes { - id - isResolved - viewerCanResolve - viewerCanUnresolve - path - diffSide - startLine - line - originalStartLine - originalLine - isOutdated - comments(first: 100) { - edges { - node { - pullRequestReview { - databaseId - } - } - } + createdAt + } + } + updatedAt + comments(orderBy: {direction:DESC, field: UPDATED_AT}, first: 1) { + nodes { + updatedAt + reactions(orderBy:{direction:DESC, field: CREATED_AT}, first: 1) { nodes { - ...ReviewComment + createdAt } } } } - } - } - rateLimit { - limit - cost - remaining - resetAt - } -} - -query Viewer { - viewer { - login - avatarUrl - name - url - email - } - rateLimit { - limit - cost - remaining - resetAt - } -} - -query PullRequest($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - ...PullRequestFragment - } - } - rateLimit { - limit - cost - remaining - resetAt - } -} - -query PullRequestFiles($owner: String!, $name: String!, $number: Int!, $after: String) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - files(first: 100, after: $after) { + timelineItems(since: $since, first: 1) { nodes { - path - viewerViewedState - } - pageInfo { - hasNextPage - endCursor + ... 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 { - limit - cost - remaining - resetAt + ...RateLimit } } -query Issue($owner: String!, $name: String!, $number: Int!) { +query LatestIssueUpdates($owner: String!, $name: String!, $number: Int!, $since: DateTime!) { repository(owner: $owner, name: $name) { pullRequest: issue(number: $number) { - number - url - state - body - bodyHTML - title - author { - login - url - avatarUrl - ... on User { - email - } - ... on Organization { - email - } - } - createdAt - updatedAt - labels(first: 50) { + reactions(orderBy:{direction:DESC, field: CREATED_AT}, first: 1) { nodes { - name - color - } - } - id - databaseId - } - } - rateLimit { - limit - cost - remaining - resetAt - } -} - -query IssueWithComments($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest: issue(number: $number) { - number - url - state - body - bodyHTML - title - author { - login - url - avatarUrl - ... on User { - email - } - ... on Organization { - email + createdAt } } - createdAt updatedAt - labels(first: 50) { - nodes { - name - color - } - } - id - databaseId - comments(first: 50) { + comments(orderBy: {direction:DESC, field: UPDATED_AT}, first: 1) { nodes { - author { - login - url - avatarUrl - ... on User { - email - } - ... on Organization { - email + updatedAt + reactions(orderBy:{direction:DESC, field: CREATED_AT}, first: 1) { + nodes { + createdAt } } - body - databaseId - } - } - } - } - rateLimit { - limit - cost - remaining - resetAt - } -} - -query GetUser($login: String!) { - user(login: $login) { - login - avatarUrl(size: 50) - bio - name - company - location - contributionsCollection { - commitContributionsByRepository(maxRepositories: 50) { - contributions(first: 1) { - nodes { - occurredAt - } - } - repository { - nameWithOwner - } - } - } - url - } - rateLimit { - limit - cost - remaining - resetAt - } -} - -query PullRequestMergeability($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - mergeable - mergeStateStatus - } - } - rateLimit { - limit - cost - remaining - resetAt - } -} - -query PullRequestState($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - title - number - state - } - } - rateLimit { - limit - cost - remaining - resetAt - } -} - -query PullRequestForHead($owner: String!, $name: String!, $headRefName: String!) { - repository(owner: $owner, name: $name) { - pullRequests(first: 3, headRefName: $headRefName, orderBy: { field: CREATED_AT, direction: DESC }) { - nodes { - ...PullRequestFragment - } - } - } - rateLimit { - limit - cost - remaining - resetAt - } -} - -mutation CreatePullRequest($input: CreatePullRequestInput!) { - createPullRequest(input: $input) { - pullRequest { - ...PullRequestFragment - } - } -} - -mutation AddComment($input: AddPullRequestReviewCommentInput!) { - addPullRequestReviewComment(input: $input) { - comment { - ...ReviewComment - } - } -} - -mutation AddReviewThread($input: AddPullRequestReviewThreadInput!) { - addPullRequestReviewThread(input: $input) { - thread { - ...ReviewThread - } - } -} - -mutation EditComment($input: UpdatePullRequestReviewCommentInput!) { - updatePullRequestReviewComment(input: $input) { - pullRequestReviewComment { - ...ReviewComment - } - } -} - -mutation ReadyForReview($input: MarkPullRequestReadyForReviewInput!) { - markPullRequestReadyForReview(input: $input) { - pullRequest { - isDraft - } - } -} - -mutation StartReview($input: AddPullRequestReviewInput!) { - addPullRequestReview(input: $input) { - pullRequestReview { - id - } - } -} - -mutation SubmitReview($id: ID!, $event: PullRequestReviewEvent!, $body: String) { - submitPullRequestReview(input: { event: $event, pullRequestReviewId: $id, body: $body }) { - pullRequestReview { - comments(first: 100) { - nodes { - ...ReviewComment } } - ...Review - } - } -} - -mutation DeleteReview($input: DeletePullRequestReviewInput!) { - deletePullRequestReview(input: $input) { - pullRequestReview { - databaseId - comments(first: 100) { + timelineItems(since: $since, first: 1) { nodes { - ...ReviewComment + ... 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 + } } } } } -} - -mutation AddReaction($input: AddReactionInput!) { - addReaction(input: $input) { - reaction { - content - } - subject { - ...Reactable - } - } -} - -mutation DeleteReaction($input: RemoveReactionInput!) { - removeReaction(input: $input) { - reaction { - content - } - subject { - ...Reactable - } - } -} - -mutation UpdatePullRequest($input: UpdatePullRequestInput!) { - updatePullRequest(input: $input) { - pullRequest { - body - bodyHTML - title - titleHTML - } - } -} - -mutation AddIssueComment($input: AddCommentInput!) { - addComment(input: $input) { - commentEdge { - node { - ...Comment - } - } - } -} - -mutation EditIssueComment($input: UpdateIssueCommentInput!) { - updateIssueComment(input: $input) { - issueComment { - ...Comment - } - } -} - -query GetMentionableUsers($owner: String!, $name: String!, $first: Int!, $after: String) { - repository(owner: $owner, name: $name) { - mentionableUsers(first: $first, after: $after) { - nodes { - login - avatarUrl - name - url - email - } - pageInfo { - hasNextPage - endCursor - } - } - } rateLimit { - limit - cost - remaining - resetAt + ...RateLimit } } @@ -832,31 +681,7 @@ query GetAssignableUsers($owner: String!, $name: String!, $first: Int!, $after: repository(owner: $owner, name: $name) { assignableUsers(first: $first, after: $after) { nodes { - login - avatarUrl - name - url - email - } - pageInfo { - hasNextPage - endCursor - } - } - } - rateLimit { - limit - cost - remaining - resetAt - } -} - -query ListBranches($owner: String!, $name: String!, $first: Int!, $after: String) { - repository(owner: $owner, name: $name) { - refs(first: $first, after: $after, refPrefix: "refs/heads/") { - nodes { - name + ...User } pageInfo { hasNextPage @@ -865,178 +690,35 @@ query ListBranches($owner: String!, $name: String!, $first: Int!, $after: String } } rateLimit { - limit - cost - remaining - resetAt + ...RateLimit } } -query IssuesWithoutMilestone($owner: String!, $name: String!, $assignee: String!) { - repository(owner: $owner, name: $name) { - issues( - first: 100 - states: OPEN - filterBy: { assignee: $assignee, milestone: null } - orderBy: { direction: DESC, field: UPDATED_AT } - ) { - edges { - node { - ... on Issue { - number - url - state - body - bodyHTML - title - assignees(first: 10) { - nodes { - login - url - email - } - } - author { - login - url - avatarUrl(size: 50) - ... on User { - email - } - ... on Organization { - email - } - } - createdAt - updatedAt - labels(first: 50) { - nodes { - name - color - } - } - id - databaseId - milestone { - title - dueOn - } - } - } - } - pageInfo { - hasNextPage - endCursor - } +mutation CreatePullRequest($input: CreatePullRequestInput!) { + createPullRequest(input: $input) { + pullRequest { + ...PullRequestFragment } } - rateLimit { - limit - cost - remaining - resetAt - } } -query MaxIssue($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - issues(first: 1, orderBy: { direction: DESC, field: CREATED_AT }) { - edges { - node { - ... on Issue { - number - } - } - } +mutation RevertPullRequest($input: RevertPullRequestInput!) { + revertPullRequest(input: $input) { + revertPullRequest { + ...PullRequestFragment } } - rateLimit { - limit - cost - remaining - resetAt - } } -query GetMilestones($owner: String!, $name: String!, $states: [MilestoneState!]!) { - repository(owner: $owner, name: $name) { - milestones(first: 100, orderBy: { direction: DESC, field: DUE_DATE }, states: $states) { - nodes { - dueOn - title - createdAt - id - } - } - } - rateLimit { - limit - cost - remaining - resetAt - } -} +# Queries that only exist in this file and in extra -query GetMilestonesWithIssues($owner: String!, $name: String!, $assignee: String!) { +query GetSuggestedActors($owner: String!, $name: String!, $capabilities: [RepositorySuggestedActorFilter!]!, $first: Int!, $after: String) { repository(owner: $owner, name: $name) { - milestones(first: 12, orderBy: { direction: DESC, field: DUE_DATE }, states: OPEN) { + suggestedActors(first: $first, after: $after, capabilities: $capabilities) { nodes { - dueOn - title - createdAt - id - issues( - first: 100 - filterBy: { assignee: $assignee } - orderBy: { direction: DESC, field: UPDATED_AT } - states: OPEN - ) { - edges { - node { - ... on Issue { - number - url - state - body - bodyHTML - title - assignees(first: 10) { - nodes { - avatarUrl - email - login - url - } - } - author { - login - url - avatarUrl(size: 50) - ... on User { - email - } - ... on Organization { - email - } - } - createdAt - updatedAt - labels(first: 50) { - nodes { - name - color - } - } - id - databaseId - milestone { - title - dueOn - } - } - } - } - } + ...Node + ...Actor + ...User } pageInfo { hasNextPage @@ -1045,263 +727,49 @@ query GetMilestonesWithIssues($owner: String!, $name: String!, $assignee: String } } rateLimit { - limit - cost - remaining - resetAt + ...RateLimit } } -query Issues($query: String!) { - search(first: 100, type: ISSUE, query: $query) { - issueCount - pageInfo { - hasNextPage - endCursor +mutation DequeuePullRequest($input: DequeuePullRequestInput!) { + dequeuePullRequest(input: $input) { + mergeQueueEntry { + ...MergeQueueEntryFragment } - edges { - node { - ... on Issue { - number - url - state - body - bodyHTML - title - assignees(first: 10) { - nodes { - avatarUrl - email - login - url - } - } - author { - login - url - avatarUrl(size: 50) - ... on User { - email - } - ... on Organization { - email - } - } - createdAt - updatedAt - labels(first: 50) { - nodes { - name - color - } - } - id - databaseId - milestone { - title - dueOn - id - createdAt - } - repository { - name - owner { - login - } - url - } - } - } - } - } - rateLimit { - limit - cost - remaining - resetAt } } -query GetViewerPermission($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - viewerPermission - } - rateLimit { - limit - cost - remaining - resetAt - } -} - -query GetRepositoryForkDetails($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - isFork - parent { - name - owner { - login - } +mutation EnqueuePullRequest($input: EnqueuePullRequestInput!) { + enqueuePullRequest(input: $input) { + mergeQueueEntry { + ...MergeQueueEntryFragment } } - rateLimit { - limit - cost - remaining - resetAt - } } -query GetChecks($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - commits(last: 1) { +mutation ReplaceActorsForAssignable($input: ReplaceActorsForAssignableInput!) { + replaceActorsForAssignable(input: $input) { + assignable { + assignees: assignedActors(first: 100) { nodes { - commit { - statusCheckRollup { - state - contexts(first: 100) { - nodes { - ... on StatusContext { - id - state - targetUrl - description - context - avatarUrl - } - ... on CheckRun { - id - conclusion - title - detailsUrl - name - resourcePath - checkSuite { - app { - logoUrl - url - } - } - } - } - } - } - } + ...Node + ...Actor + ...User } - } - } - } - rateLimit { - limit - cost - remaining - resetAt - } -} - -query GetChecksWithoutSuite($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - commits(last: 1) { - nodes { - commit { - statusCheckRollup { - state - contexts(first: 100) { - nodes { - ... on StatusContext { - id - state - targetUrl - description - context - avatarUrl - } - ... on CheckRun { - id - conclusion - title - detailsUrl - name - resourcePath - } - } - } - } - } + pageInfo { + hasNextPage + endCursor } } } } - rateLimit { - limit - cost - remaining - resetAt - } -} - -query GetFileContent($owner: String!, $name: String!, $expression: String!) { - repository(owner: $owner, name: $name) { - object(expression: $expression) { - ... on Blob { - text - } - } - } - rateLimit { - limit - cost - remaining - resetAt - } -} - -mutation ResolveReviewThread($input: ResolveReviewThreadInput!) { - resolveReviewThread(input: $input) { - thread { - ...ReviewThread - } - } } -mutation UnresolveReviewThread($input: UnresolveReviewThreadInput!) { - unresolveReviewThread(input: $input) { - thread { - ...ReviewThread - } - } -} - -mutation EnablePullRequestAutoMerge($input: EnablePullRequestAutoMergeInput!) { - enablePullRequestAutoMerge(input: $input) { +mutation UpdatePullRequestBranch($input: UpdatePullRequestBranchInput!) { + updatePullRequestBranch(input: $input) { pullRequest { id + headRefOid } } -} - -mutation DisablePullRequestAutoMerge($input: DisablePullRequestAutoMergeInput!) { - disablePullRequestAutoMerge(input: $input) { - pullRequest { - id - } - } -} - -mutation MarkFileAsViewed($input: MarkFileAsViewedInput!) { - markFileAsViewed(input: $input) { - pullRequest { - id - } - } -} - -mutation UnmarkFileAsViewed($input: UnmarkFileAsViewedInput!) { - unmarkFileAsViewed(input: $input) { - pullRequest { - id - } - } -} +} \ No newline at end of file diff --git a/src/github/queriesExtra.gql b/src/github/queriesExtra.gql new file mode 100644 index 0000000000..ccb1c9f107 --- /dev/null +++ b/src/github/queriesExtra.gql @@ -0,0 +1,818 @@ +# /*--------------------------------------------------------------------------------------------- +# * Copyright (c) Microsoft Corporation. All rights reserved. +# * Licensed under the MIT License. See License.txt in the project root for license information. +# *--------------------------------------------------------------------------------------------*/ + +#import "./queriesShared.gql" + +# Queries that are only available with extra auth scopes + +fragment Node on Node { + id +} + +fragment Actor on Actor { # We don't want to reference Bot because it is not available on older GHE, so we use Actor instead as it gets us most of the way there. + __typename + login + avatarUrl + url +} + +fragment User on User { + __typename + ...Actor + email + name + ...Node +} + +fragment Organization on Organization { + __typename + ...Actor + email + name + ...Node +} + +fragment Team on Team { # Team is not an Actor + name + avatarUrl + url + slug + ...Node +} + +fragment Reactable on Reactable { + reactionGroups { + content + viewerHasReacted + reactors(first: 10) { + nodes { + ... on User { + login + } + ... on Actor { + login + } + } + totalCount + } + } +} + +fragment IssueBase on Issue { + number + url + state + stateReason + body + bodyHTML + title + titleHTML + author { + ...Node + ...Actor + ...User + ...Organization + } + createdAt + updatedAt + milestone { + title + dueOn + createdAt + id + number + } + assignees: assignedActors(first: 10) { + nodes { + ...Node + ...Actor + ...User + } + } + labels(first: 50) { + nodes { + name + color + } + } + id + databaseId + reactions(first: 100) { + totalCount + } + ...Reactable + repository { + name + owner { + login + } + url + } + projectItems(first: 100) { + nodes { + id + project { + title + id + } + } + } +} + +fragment IssueFragment on Issue { + ...IssueBase + comments(first: 1) { + totalCount + } +} + +fragment IssueWithCommentsFragment on Issue { + ...IssueBase + comments(first: 50) { + nodes { + author { + ...Node + ...Actor + ...User + ...Organization + } + body + databaseId + reactions(first: 100) { + totalCount + } + } + totalCount + } +} + +fragment PullRequestFragment on PullRequest { + number + url + state + body + bodyHTML + title + titleHTML + author { + ...Node + ...Actor + ...User + ...Organization + } + createdAt + updatedAt + milestone { + title + dueOn + createdAt + id + number + } + assignees: assignedActors(first: 10) { + nodes { + ...Node + ...Actor + ...User + } + } + labels(first: 50) { + nodes { + name + color + } + } + id + databaseId + reactions(first: 100) { + totalCount + } + ...Reactable + projectItems(first: 100) { + nodes { + id + project { + title + id + } + } + } + comments(first: 1) { + totalCount + } + commits(first: 50) { + nodes { + commit { + message + } + } + } + headRef { + ...Ref + } + headRefName + headRefOid + headRepository { + isInOrganization + owner { + login + } + url + } + baseRef { + ...Ref + } + baseRefName + baseRefOid + baseRepository { + isInOrganization + owner { + login + } + url + squashMergeCommitTitle + squashMergeCommitMessage + mergeCommitMessage + mergeCommitTitle + } + merged + mergeable + mergeQueueEntry { + ...MergeQueueEntryFragment + } + mergeStateStatus + reviewThreads { + totalCount + } + autoMergeRequest { + mergeMethod + } + viewerCanEnableAutoMerge + viewerCanDisableAutoMerge + viewerCanUpdate + isDraft + suggestedReviewers { + isAuthor + isCommenter + reviewer { + ...Actor + ...User + ...Node + } + } +} + +query Issue($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + issue(number: $number) { + ...IssueFragment + } + } + rateLimit { + ...RateLimit + } +} + +query IssueWithComments($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + issue(number: $number) { + ...IssueWithCommentsFragment + } + } + rateLimit { + ...RateLimit + } +} + +query Issues($query: String!) { + search(first: 100, type: ISSUE, query: $query) { + issueCount + pageInfo { + hasNextPage + endCursor + } + edges { + node { + ...IssueFragment + } + } + } + rateLimit { + ...RateLimit + } +} + +query PullRequest($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + ...PullRequestFragment + } + } + rateLimit { + ...RateLimit + } +} + + +query PullRequestForHead($owner: String!, $name: String!, $headRefName: String!) { + repository(owner: $owner, name: $name) { + pullRequests(first: 3, headRefName: $headRefName, orderBy: { field: CREATED_AT, direction: DESC }) { + nodes { + ...PullRequestFragment + } + } + } + rateLimit { + ...RateLimit + } +} + +query PullRequestMergeabilityMergeRequirements($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + mergeable + mergeStateStatus + mergeRequirements { # This is a privage field we're testing + state + conditions { + result + ... on PullRequestMergeConflictStateCondition { + __typename + conflicts + isConflictResolvableInWeb + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetSuggestedActors($owner: String!, $name: String!, $capabilities: [RepositorySuggestedActorFilter!]!, $first: Int!, $after: String) { + repository(owner: $owner, name: $name) { + suggestedActors(first: $first, after: $after, capabilities: $capabilities) { + nodes { + ...Node + ...Actor + ...User + } + pageInfo { + hasNextPage + endCursor + } + } + } + rateLimit { + ...RateLimit + } +} + +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 { + ...PullRequestFragment + } + } +} + +mutation ReplaceActorsForAssignable($input: ReplaceActorsForAssignableInput!) { + replaceActorsForAssignable(input: $input) { + assignable { + assignees: assignedActors(first: 100) { + nodes { + ...Node + ...Actor + ...User + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +} + +# Queries that only exist in this file + +query GetRepoProjects($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + projectsV2(first: 100, query: "state:OPEN") { + nodes { + title + id + } + } + } +} + +query GetOrgProjects($owner: String!, $after: String) { + organization(login: $owner) { + projectsV2(first: 100, after: $after, query: "state:OPEN", orderBy: { field: UPDATED_AT, direction: DESC }) { + nodes { + title + id + } + pageInfo { + hasNextPage + endCursor + } + } + } +} + +mutation AddPullRequestToProject($input: AddProjectV2ItemByIdInput!) { + addProjectV2ItemById(input: $input) { + item { + id + } + } +} + +mutation RemovePullRequestFromProject($input: DeleteProjectV2ItemInput!) { + deleteProjectV2Item(input: $input) { + deletedItemId + } +} + +mutation DequeuePullRequest($input: DequeuePullRequestInput!) { + dequeuePullRequest(input: $input) { + mergeQueueEntry { + ...MergeQueueEntryFragment + } + } +} + +mutation EnqueuePullRequest($input: EnqueuePullRequestInput!) { + enqueuePullRequest(input: $input) { + mergeQueueEntry { + ...MergeQueueEntryFragment + } + } +} + +mutation UpdatePullRequestBranch($input: UpdatePullRequestBranchInput!) { + updatePullRequestBranch(input: $input) { + pullRequest { + id + headRefOid + } + } +} \ No newline at end of file diff --git a/src/github/queriesLimited.gql b/src/github/queriesLimited.gql new file mode 100644 index 0000000000..9ce0dffa9d --- /dev/null +++ b/src/github/queriesLimited.gql @@ -0,0 +1,658 @@ +# /*--------------------------------------------------------------------------------------------- +# * Copyright (c) Microsoft Corporation. All rights reserved. +# * Licensed under the MIT License. See License.txt in the project root for license information. +# *--------------------------------------------------------------------------------------------*/ + +#import "./queriesShared.gql" + +fragment Node on Node { + id +} + +fragment Actor on Actor { # We don't want to reference Bot because it is not available on older GHE, so we use Actor instead as it gets us most of the way there. + __typename + login + avatarUrl + url +} + +fragment User on User { + __typename + ...Actor + email + name + ...Node +} + +fragment Organization on Organization { + __typename + ...Actor + email + name + ...Node +} + +fragment Reactable on Reactable { + reactionGroups { + content + viewerHasReacted + reactors(first: 10) { + nodes { + ... on User { + login + } + ... on Actor { + login + } + } + totalCount + } + } +} + +fragment IssueBase on Issue { + number + url + state + stateReason + body + bodyHTML + title + titleHTML + author { + ...Node + ...Actor + ...User + ...Organization + } + createdAt + updatedAt + milestone { + title + dueOn + createdAt + id + number + } + assignees(first: 10) { + nodes { + ...Node + ...Actor + ...User + } + } + labels(first: 50) { + nodes { + name + color + } + } + id + databaseId + reactions(first: 100) { + totalCount + } + ...Reactable + repository { + name + owner { + login + } + url + } +} + +fragment IssueFragment on Issue { + ...IssueBase + comments(first: 1) { + totalCount + } +} + +fragment IssueWithCommentsFragment on Issue { + ...IssueBase + comments(first: 50) { + nodes { + author { + ...Node + ...Actor + ...User + ...Organization + } + body + databaseId + reactions(first: 100) { + totalCount + } + } + totalCount + } +} + +fragment PullRequestFragment on PullRequest { + number + url + state + body + bodyHTML + title + titleHTML + author { + ...Node + ...Actor + ...User + ...Organization + } + createdAt + updatedAt + milestone { + title + dueOn + createdAt + id + number + } + assignees(first: 10) { + nodes { + ...Node + ...Actor + ...User + } + } + labels(first: 50) { + nodes { + name + color + } + } + id + databaseId + reactions(first: 100) { + totalCount + } + ...Reactable + comments(first: 1) { + totalCount + } + + commits(first: 50) { + nodes { + commit { + message + } + } + } + headRef { + ...Ref + } + headRefName + headRefOid + headRepository { + isInOrganization + owner { + login + } + url + } + baseRef { + ...Ref + } + baseRefName + baseRefOid + baseRepository { + isInOrganization + owner { + login + } + url + } + merged + mergeable + mergeStateStatus + autoMergeRequest { + mergeMethod + } + reviewThreads { + totalCount + } + viewerCanEnableAutoMerge + viewerCanDisableAutoMerge + viewerCanUpdate + isDraft + suggestedReviewers { + isAuthor + isCommenter + reviewer { + ...Actor + ...User + ...Node + } + } +} + +query Issue($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + issue(number: $number) { + ...IssueFragment + } + } + rateLimit { + ...RateLimit + } +} + +query IssueWithComments($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + issue(number: $number) { + ...IssueWithCommentsFragment + } + } + rateLimit { + ...RateLimit + } +} + +query Issues($query: String!) { + search(first: 100, type: ISSUE, query: $query) { + issueCount + pageInfo { + hasNextPage + endCursor + } + edges { + node { + ...IssueFragment + } + } + } + rateLimit { + ...RateLimit + } +} + +query PullRequest($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + ...PullRequestFragment + } + } + rateLimit { + ...RateLimit + } +} + +query PullRequestForHead($owner: String!, $name: String!, $headRefName: String!) { + repository(owner: $owner, name: $name) { + pullRequests(first: 3, headRefName: $headRefName, orderBy: { field: CREATED_AT, direction: DESC }) { + nodes { + ...PullRequestFragment + } + } + } + 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 + } +} + +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 { + ...PullRequestFragment + } + } +} + +mutation RevertPullRequest($input: RevertPullRequestInput!) { + revertPullRequest(input: $input) { + revertPullRequest { + ...PullRequestFragment + } + } +} diff --git a/src/github/queriesShared.gql b/src/github/queriesShared.gql new file mode 100644 index 0000000000..4fd6ad51cd --- /dev/null +++ b/src/github/queriesShared.gql @@ -0,0 +1,1303 @@ +# /*--------------------------------------------------------------------------------------------- +# * Copyright (c) Microsoft Corporation. All rights reserved. +# * Licensed under the MIT License. See License.txt in the project root for license information. +# *--------------------------------------------------------------------------------------------*/ + +fragment Node on Node { + id +} + +fragment Actor on Actor { # We don't want to reference Bot because it is not available on older GHE, so we use Actor instead as it gets us most of the way there. + __typename + login + avatarUrl + url +} + +fragment User on User { + __typename + ...Actor + email + name + ...Node +} + +fragment Organization on Organization { + __typename + ...Actor + email + name + ...Node +} + +fragment Team on Team { # Team is not an Actor + name + avatarUrl + url + slug + ...Node +} + +fragment RateLimit on RateLimit { + limit + cost + remaining + resetAt +} + +fragment Merged on MergedEvent { + id + actor { + ...Node + ...Actor + } + createdAt + mergeRef { + name + } + commit { + oid + commitUrl + } + url +} + +fragment HeadRefDeleted on HeadRefDeletedEvent { + id + actor { + ...Node + ...Actor + } + createdAt + headRefName +} + +fragment Ref on Ref { + name + repository { + owner { + login + } + url + } + target { + oid + } +} + +fragment Comment on IssueComment { + id + databaseId + authorAssociation + author { + ...Node + ...Actor + ...User + ...Organization + } + url + body + bodyHTML + updatedAt + createdAt + viewerCanUpdate + viewerCanReact + viewerCanDelete + ...Reactable +} + +fragment Commit on PullRequestCommit { + id + commit { + author { + user { + ...Node + ...Actor + ...User + } + } + committer { + avatarUrl + name + } + oid + message + committedDate + statusCheckRollup { + state + } + } + url +} + +fragment AssignedEvent on AssignedEvent { + id + actor { + ...Node + ...Actor + } + user { + ...Node + ...Actor + ...User + } + createdAt +} + +fragment UnassignedEvent on UnassignedEvent { + id + actor { + ...Node + ...Actor + } + user { + ...Node + ...Actor + ...User + } + createdAt +} + +fragment CrossReferencedEvent on CrossReferencedEvent { + id + actor { + ...Node + ...Actor + } + createdAt + source { + ... on PullRequest { + number + url + title + repository: baseRepository { + owner { + login + } + name + } + } + ... on Issue { + number + url + title + repository { + owner { + login + } + name + } + } + } + willCloseTarget +} + +fragment ClosedEvent on ClosedEvent { + id + actor { + ...Node + ...Actor + } + createdAt +} + +fragment ReopenedEvent on ReopenedEvent { + id + actor { + ...Node + ...Actor + } + createdAt +} + +fragment BaseRefChangedEvent on BaseRefChangedEvent { + id + actor { + ...Node + ...Actor + } + createdAt + currentRefName + previousRefName +} + +fragment Review on PullRequestReview { + id + databaseId + authorAssociation + url + author { + ...User + ...Organization + ...Node + ...Actor + } + state + body + bodyHTML + submittedAt + updatedAt + createdAt + ...Reactable +} + +fragment Reactable on Reactable { + reactionGroups { + content + viewerHasReacted + reactors(first: 10) { + nodes { + ... on User { + login + } + ... on Actor { + login + } + } + totalCount + } + } +} + + +fragment ReviewThread on PullRequestReviewThread { + id + isResolved + viewerCanResolve + viewerCanUnresolve + path + diffSide + line + startLine + originalStartLine + originalLine + isOutdated + subjectType + comments(first: 100) { + nodes { + ...ReviewComment + } + } +} + +fragment LegacyReviewThread on PullRequestReviewThread { + id + isResolved + viewerCanResolve + viewerCanUnresolve + path + diffSide + line + startLine + originalStartLine + originalLine + isOutdated + comments(first: 100) { + nodes { + ...ReviewComment + } + } +} + +fragment MergeQueueEntryFragment on MergeQueueEntry { + position + state + mergeQueue { + url + } +} + +query TimelineEvents($owner: String!, $name: String!, $number: Int!, $last: Int = 150) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + timelineItems(last: $last) { + nodes { + __typename + ...Merged + ...Comment + ...Review + ...Commit + ...AssignedEvent + ...UnassignedEvent + ...HeadRefDeleted + ...CrossReferencedEvent + ...ClosedEvent + ...ReopenedEvent + ...BaseRefChangedEvent + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query PullRequestActivityTimelineEvents($owner: String!, $name: String!, $number: Int!, $last: Int = 5) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + timelineItems(last: $last) { + nodes { + __typename + ...Merged + ...Comment + ...Review + ...Commit + ...ClosedEvent + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query IssueTimelineEvents($owner: String!, $name: String!, $number: Int!, $last: Int = 150) { + repository(owner: $owner, name: $name) { + pullRequest: issue(number: $number) { + timelineItems(last: $last) { + nodes { + __typename + ...Comment + ...AssignedEvent + ...UnassignedEvent + ...CrossReferencedEvent + ...ClosedEvent + ...ReopenedEvent + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query LatestReviewCommit($owner: String!, $name: String!, $number: Int!, $author: String!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviews(last: 1, author: $author, states: [APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED]) { + nodes { + commit { + oid + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query LatestReviews($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + latestReviews (first: 10) { + nodes { + state + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetOrganizationTeamsCount($login: String!) { + organization(login: $login) { + teams(first: 0, privacy: VISIBLE) { + totalCount + } + } + rateLimit { + ...RateLimit + } +} + +query GetOrganizationTeams($login: String!, $after: String, $repoName: String!) { + organization(login: $login) { + teams(first: 100, after: $after, privacy: VISIBLE) { + nodes { + ...Team + repositories(first: 5, query: $repoName) { + nodes { + name + } + } + } + totalCount + pageInfo { + hasNextPage + endCursor + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetReviewRequestsAdditionalScopes($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewRequests(first: 100) { + nodes { + requestedReviewer { + ...Node + ...Actor + ...User + ...Team + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetReviewRequests($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewRequests(first: 100) { + nodes { + requestedReviewer { + ...Node + ...Actor + ...User + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +fragment ReviewComment on PullRequestReviewComment { + id + databaseId + url + author { + ...Actor + ...Node + ...User + ...Organization + } + path + originalPosition + body + bodyHTML + diffHunk + position + state + pullRequestReview { + databaseId + } + commit { + oid + } + replyTo { + databaseId + } + createdAt + originalCommit { + oid + } + ...Reactable + viewerCanUpdate + viewerCanDelete +} + +query GetParticipants($owner: String!, $name: String!, $number: Int!, $first: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + participants(first: $first) { + nodes { + ...Node + ...Actor + ...User + ...Organization + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetPendingReviewId($pullRequestId: ID!, $author: String!) { + node(id: $pullRequestId) { + ... on PullRequest { + reviews(first: 1, author: $author, states: [PENDING]) { + nodes { + id + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query PullRequestComments($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: 20, after: $after) { + nodes { + id + isResolved + viewerCanResolve + viewerCanUnresolve + path + diffSide + startLine + line + originalStartLine + originalLine + isOutdated + subjectType + comments(first: 100) { + edges { + node { + pullRequestReview { + databaseId + } + } + } + nodes { + ...ReviewComment + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query LegacyPullRequestComments($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: 20, after: $after) { + nodes { + id + isResolved + viewerCanResolve + viewerCanUnresolve + path + diffSide + startLine + line + originalStartLine + originalLine + isOutdated + comments(first: 100) { + edges { + node { + pullRequestReview { + databaseId + } + } + } + nodes { + ...ReviewComment + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query Viewer { + viewer { + ...User + } + rateLimit { + ...RateLimit + } +} + +query PullRequestFiles($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + files(first: 100, after: $after) { + nodes { + path + viewerViewedState + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetUser($login: String!) { + user(login: $login) { + login + avatarUrl(size: 50) + id + bio + name + company + location + contributionsCollection { + commitContributionsByRepository(maxRepositories: 50) { + contributions(first: 1) { + nodes { + occurredAt + } + } + repository { + nameWithOwner + } + } + } + url + } + rateLimit { + ...RateLimit + } +} + +query PullRequestMergeability($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + mergeable + mergeStateStatus + } + } + rateLimit { + ...RateLimit + } +} + +query PullRequestState($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + title + number + state + } + } + rateLimit { + ...RateLimit + } +} + +query PullRequestTemplates($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + pullRequestTemplates { + body + } + } +} + +fragment PullRequestNumberFragment on PullRequest { + number + title + author { + login + } +} + +query PullRequestNumbers($owner: String!, $name: String!, $first: Int!) { + repository(owner: $owner, name: $name) { + pullRequests(first: $first, states: OPEN, orderBy: { field: CREATED_AT, direction: DESC }) { + nodes { + ...PullRequestNumberFragment + } + } + } + rateLimit { + ...RateLimit + } +} + +mutation AddComment($input: AddPullRequestReviewCommentInput!) { + addPullRequestReviewComment(input: $input) { + comment { + ...ReviewComment + } + } +} + +mutation AddReviewThread($input: AddPullRequestReviewThreadInput!) { + addPullRequestReviewThread(input: $input) { + thread { + ...ReviewThread + } + } +} + +mutation LegacyAddReviewThread($input: AddPullRequestReviewThreadInput!) { + addPullRequestReviewThread(input: $input) { + thread { + ...LegacyReviewThread + } + } +} + +mutation AddReviewers($input: RequestReviewsInput!) { + requestReviews(input: $input) { + pullRequest { + id + } + } +} + +mutation EditComment($input: UpdatePullRequestReviewCommentInput!) { + updatePullRequestReviewComment(input: $input) { + pullRequestReviewComment { + ...ReviewComment + } + } +} + +mutation ReadyForReview($input: MarkPullRequestReadyForReviewInput!) { + markPullRequestReadyForReview(input: $input) { + pullRequest { + isDraft + mergeable + mergeStateStatus + viewerCanEnableAutoMerge + viewerCanDisableAutoMerge + } + } +} + +mutation ConvertToDraft($input: ConvertPullRequestToDraftInput!) { + convertPullRequestToDraft(input: $input) { + pullRequest { + isDraft + mergeable + mergeStateStatus + } + } +} + +mutation StartReview($input: AddPullRequestReviewInput!) { + addPullRequestReview(input: $input) { + pullRequestReview { + id + } + } +} + +mutation SubmitReview($id: ID!, $event: PullRequestReviewEvent!, $body: String) { + submitPullRequestReview(input: { event: $event, pullRequestReviewId: $id, body: $body }) { + pullRequestReview { + comments(first: 100) { + nodes { + ...ReviewComment + } + } + ...Review + } + } +} + +mutation DeleteReview($input: DeletePullRequestReviewInput!) { + deletePullRequestReview(input: $input) { + pullRequestReview { + databaseId + comments(first: 100) { + nodes { + ...ReviewComment + } + } + } + } +} + +mutation AddReaction($input: AddReactionInput!) { + addReaction(input: $input) { + reaction { + content + } + subject { + ...Reactable + } + } +} + +mutation DeleteReaction($input: RemoveReactionInput!) { + removeReaction(input: $input) { + reaction { + content + } + subject { + ...Reactable + } + } +} + +mutation UpdateIssue($input: UpdateIssueInput!) { + updateIssue(input: $input) { + issue { + body + bodyHTML + title + titleHTML + milestone { + title + dueOn + createdAt + id + number + } + } + } +} + +mutation UpdatePullRequest($input: UpdatePullRequestInput!) { + updateIssue: updatePullRequest(input: $input) { + issue: pullRequest { + body + bodyHTML + title + titleHTML + milestone { + title + dueOn + createdAt + id + number + } + } + } +} + +mutation AddIssueComment($input: AddCommentInput!) { + addComment(input: $input) { + commentEdge { + node { + ...Comment + } + } + } +} + +mutation EditIssueComment($input: UpdateIssueCommentInput!) { + updateIssueComment(input: $input) { + issueComment { + ...Comment + } + } +} + +query GetMentionableUsers($owner: String!, $name: String!, $first: Int!, $after: String) { + repository(owner: $owner, name: $name) { + mentionableUsers(first: $first, after: $after) { + nodes { + ...Node + ...Actor + ...User + } + pageInfo { + hasNextPage + endCursor + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetBranch($owner: String!, $name: String!, $qualifiedName: String!) { + repository(owner: $owner, name: $name) { + ref(qualifiedName: $qualifiedName) { + target { + oid + } + } + } + rateLimit { + ...RateLimit + } +} + +query ListBranches($owner: String!, $name: String!, $first: Int!, $after: String, $query: String) { + repository(owner: $owner, name: $name) { + refs(first: $first, after: $after, refPrefix: "refs/heads/", query: $query) { + nodes { + name + } + pageInfo { + hasNextPage + endCursor + } + } + } + rateLimit { + ...RateLimit + } +} + +query MaxIssue($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + issues(first: 1, orderBy: { direction: DESC, field: CREATED_AT }) { + edges { + node { + ... on Issue { + number + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query MaxPullRequest($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + issues: pullRequests(first: 1, orderBy: { direction: DESC, field: CREATED_AT }) { + edges { + node { + ... on PullRequest { + number + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetMilestones($owner: String!, $name: String!, $states: [MilestoneState!]!) { + repository(owner: $owner, name: $name) { + milestones(first: 100, orderBy: { direction: DESC, field: DUE_DATE }, states: $states) { + nodes { + dueOn + title + createdAt + id + number + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetViewerPermission($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + viewerPermission + } + rateLimit { + ...RateLimit + } +} + +query GetRepositoryForkDetails($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + isFork + parent { + name + owner { + login + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetChecks($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + url + latestReviews (first: 10) { + nodes { + authorAssociation + authorCanPushToRepository + state + author { + login + } + } + } + reviewsRequestingChanges: reviews (last: 5, states: [CHANGES_REQUESTED]) { + nodes { + authorAssociation + authorCanPushToRepository + state + author { + login + } + } + } + baseRef { + refUpdateRule { + requiredApprovingReviewCount + requiredStatusCheckContexts + requiresCodeOwnerReviews + viewerCanPush + } + } + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + state + contexts(first: 100) { + nodes { + __typename + ... on StatusContext { + id + state + targetUrl + description + context + avatarUrl + isRequired(pullRequestNumber: $number) + } + ... on CheckRun { + id + conclusion + title + detailsUrl + name + resourcePath + isRequired(pullRequestNumber: $number) + checkSuite { + app { + logoUrl + url + } + workflowRun { + event + workflow { + name + } + } + } + } + } + } + } + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetChecksWithoutSuite($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + url + latestReviews (first: 10) { + nodes { + authorAssociation + authorCanPushToRepository + state + author { + login + } + } + } + reviewsRequestingChanges: reviews (last: 5, states: [CHANGES_REQUESTED]) { + nodes { + authorAssociation + authorCanPushToRepository + state + author { + login + } + } + } + baseRef { + refUpdateRule { + requiredApprovingReviewCount + requiredStatusCheckContexts + requiresCodeOwnerReviews + viewerCanPush + } + } + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + state + contexts(first: 100) { + nodes { + __typename + ... on StatusContext { + id + state + targetUrl + description + context + avatarUrl + isRequired(pullRequestNumber: $number) + } + ... on CheckRun { + id + conclusion + title + detailsUrl + name + resourcePath + isRequired(pullRequestNumber: $number) + } + } + } + } + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query MergeQueueForBranch($owner: String!, $name: String!, $branch: String!) { + repository(owner: $owner, name: $name) { + mergeQueue(branch: $branch) { + configuration { + mergeMethod + } + } + } +} + +query GetFileContent($owner: String!, $name: String!, $expression: String!) { + repository(owner: $owner, name: $name) { + object(expression: $expression) { + ... on Blob { + text + } + } + } + rateLimit { + ...RateLimit + } +} + +mutation ResolveReviewThread($input: ResolveReviewThreadInput!) { + resolveReviewThread(input: $input) { + thread { + ...ReviewThread + } + } +} + +mutation LegacyResolveReviewThread($input: ResolveReviewThreadInput!) { + resolveReviewThread(input: $input) { + thread { + ...LegacyReviewThread + } + } +} + +mutation UnresolveReviewThread($input: UnresolveReviewThreadInput!) { + unresolveReviewThread(input: $input) { + thread { + ...ReviewThread + } + } +} + +mutation LegacyUnresolveReviewThread($input: UnresolveReviewThreadInput!) { + unresolveReviewThread(input: $input) { + thread { + ...LegacyReviewThread + } + } +} + +mutation EnablePullRequestAutoMerge($input: EnablePullRequestAutoMergeInput!) { + enablePullRequestAutoMerge(input: $input) { + pullRequest { + id + } + } +} + +mutation DisablePullRequestAutoMerge($input: DisablePullRequestAutoMergeInput!) { + disablePullRequestAutoMerge(input: $input) { + pullRequest { + id + } + } +} + +mutation MarkFileAsViewed($input: MarkFileAsViewedInput!) { + markFileAsViewed(input: $input) { + pullRequest { + id + } + } +} + +mutation UnmarkFileAsViewed($input: UnmarkFileAsViewedInput!) { + unmarkFileAsViewed(input: $input) { + pullRequest { + id + } + } +} + +mutation MergePullRequest($input: MergePullRequestInput!, $last: Int = 150) { + mergePullRequest(input: $input) { + pullRequest { + id + timelineItems(last: $last) { + nodes { + __typename + ...Merged + ...Comment + ...Review + ...Commit + ...AssignedEvent + ...UnassignedEvent + ...HeadRefDeleted + ...CrossReferencedEvent + ...ClosedEvent + ...ReopenedEvent + ...BaseRefChangedEvent + } + } + } + } +} diff --git a/src/github/quickPicks.ts b/src/github/quickPicks.ts new file mode 100644 index 0000000000..1da63ea1a1 --- /dev/null +++ b/src/github/quickPicks.ts @@ -0,0 +1,557 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import { Buffer } from 'buffer'; +import * as vscode from 'vscode'; +import { FolderRepositoryManager } from './folderRepositoryManager'; +import { GitHubRepository, TeamReviewerRefreshKind } from './githubRepository'; +import { IAccount, ILabel, IMilestone, IProject, isISuggestedReviewer, isITeam, ISuggestedReviewer, ITeam, reviewerId, ReviewState } from './interface'; +import { IssueModel } from './issueModel'; +import { DisplayLabel } from './views'; +import { RemoteInfo } from '../../common/types'; +import { Ref } from '../api/api'; +import { COPILOT_ACCOUNTS } from '../common/comment'; +import { COPILOT_REVIEWER, COPILOT_REVIEWER_ACCOUNT, 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 { RECENTLY_USED_BRANCHES, RecentlyUsedBranchesState } from '../extensionState'; + +export async function chooseItem( + itemsToChooseFrom: T[], + propertyGetter: (itemValue: T) => { label: string; description?: string; }, + options?: vscode.QuickPickOptions, +): Promise { + if (itemsToChooseFrom.length === 0) { + return undefined; + } + if (itemsToChooseFrom.length === 1) { + return itemsToChooseFrom[0]; + } + interface Item extends vscode.QuickPickItem { + itemValue: T; + } + const items: Item[] = itemsToChooseFrom.map(currentItem => { + return { + ...propertyGetter(currentItem), + itemValue: currentItem, + }; + }); + return (await vscode.window.showQuickPick(items, options))?.itemValue; +} + +async function getItems(context: vscode.ExtensionContext, skipList: Set, users: T[], picked: boolean, tooManyAssignable: boolean = false): Promise<(vscode.QuickPickItem & { user?: T })[]> { + const alreadyAssignedItems: (vscode.QuickPickItem & { user?: T })[] = []; + // Address skip list before first await + const filteredUsers: T[] = []; + for (const user of users) { + const id = reviewerId(user); + if (!skipList.has(id)) { + filteredUsers.push(user); + skipList.add(id); + } + } + + const avatars = await DataUri.avatarCirclesAsImageDataUris(context, filteredUsers, 16, 16, tooManyAssignable); + for (let i = 0; i < filteredUsers.length; i++) { + const user = filteredUsers[i]; + + let detail: string | undefined; + if (isISuggestedReviewer(user)) { + detail = user.isAuthor && user.isCommenter + ? vscode.l10n.t('Recently edited and reviewed changes to these files') + : user.isAuthor + ? vscode.l10n.t('Recently edited these files') + : user.isCommenter + ? vscode.l10n.t('Recently reviewed changes to these files') + : vscode.l10n.t('Suggested reviewer'); + } + + alreadyAssignedItems.push({ + label: isITeam(user) ? `${user.org}/${user.slug}` : COPILOT_ACCOUNTS[user.login] ? COPILOT_ACCOUNTS[user.login].name : user.login, + description: user.name, + user, + picked, + detail, + iconPath: avatars[i] ?? userThemeIcon(user) + }); + } + return alreadyAssignedItems; +} + +export async function getAssigneesQuickPickItems(folderRepositoryManager: FolderRepositoryManager, gitHubRepository: GitHubRepository | undefined, remoteName: string, alreadyAssigned: IAccount[], item?: IssueModel, assignYourself?: boolean): + Promise<(vscode.QuickPickItem & { user?: IAccount })[]> { + + const [allAssignableUsers, participantsAndViewer] = await Promise.all([ + folderRepositoryManager.getAssignableUsers(), + item ? folderRepositoryManager.getPullRequestParticipants(item.githubRepository, item.number) : undefined + ]); + const viewer = participantsAndViewer?.viewer; + const participants = participantsAndViewer?.participants ?? []; + + let assignableUsers = allAssignableUsers[remoteName]; + + assignableUsers = assignableUsers ?? []; + // used to track logins that shouldn't be added to pick list + // e.g. author, existing and already added reviewers + const skipList: Set = new Set(); + + const assigneePromises: Promise<(vscode.QuickPickItem & { user?: IAccount })[]>[] = []; + + // Start with all currently assigned so they show at the top + if (alreadyAssigned.length) { + assigneePromises.push(getItems(folderRepositoryManager.context, skipList, alreadyAssigned ?? [], true)); + } + + // Check if the viewer is allowed to be assigned to the PR + if (viewer && !skipList.has(viewer.login) && (assignableUsers.findIndex((assignableUser: IAccount) => assignableUser.login === viewer.login) !== -1)) { + assigneePromises.push(getItems(folderRepositoryManager.context, skipList, [viewer], false)); + } + + // Suggested reviewers + if (participants.length) { + assigneePromises.push(getItems(folderRepositoryManager.context, skipList, participants, false)); + } + + if (assigneePromises.length !== 0) { + assigneePromises.unshift(Promise.resolve([{ + kind: vscode.QuickPickItemKind.Separator, + label: vscode.l10n.t('Suggestions') + }])); + } + + if (assignableUsers.length) { + const tooManyAssignable = assignableUsers.length > 80; + assigneePromises.push(getItems(folderRepositoryManager.context, skipList, assignableUsers, false, tooManyAssignable)); + } + + const assignees = (await Promise.all(assigneePromises)).flat(); + + if (assignees.length === 0) { + assignees.push({ + label: vscode.l10n.t('No assignees available for this repository') + }); + } + + if (assignYourself) { + const currentUser = viewer ?? await folderRepositoryManager.getCurrentUser(gitHubRepository); + if (assignees.length !== 0) { + assignees.unshift({ kind: vscode.QuickPickItemKind.Separator, label: vscode.l10n.t('Users') }); + } + assignees.unshift({ label: vscode.l10n.t('Assign yourself'), user: currentUser }); + } + + return assignees; +} + +function userThemeIcon(user: IAccount | ITeam) { + return (isITeam(user) ? new vscode.ThemeIcon('organization') : new vscode.ThemeIcon('account')); +} + +async function getReviewersQuickPickItems(folderRepositoryManager: FolderRepositoryManager, remoteName: string, isInOrganization: boolean, author: IAccount, existingReviewers: ReviewState[], + suggestedReviewers: ISuggestedReviewer[] | undefined, refreshKind: TeamReviewerRefreshKind, +): Promise<(vscode.QuickPickItem & { user?: IAccount | ITeam })[]> { + if (!suggestedReviewers) { + return []; + } + + const allAssignableUsers = await folderRepositoryManager.getAssignableUsers(); + const allTeamReviewers = isInOrganization ? await folderRepositoryManager.getTeamReviewers(refreshKind) : []; + const teamReviewers: ITeam[] = allTeamReviewers[remoteName] ?? []; + const assignableUsers: (IAccount | ITeam)[] = [...teamReviewers]; + + // Remove the swe agent as it can't do reviews, but add the reviewer instead + const originalAssignableUsers = allAssignableUsers[remoteName] ?? []; + let hasCopilotSweAgent: boolean = false; + const assignableUsersForRemote = originalAssignableUsers.filter(user => { + if (user.login === COPILOT_SWE_AGENT) { + hasCopilotSweAgent = true; + return false; + } + return true; + }); + + if (assignableUsersForRemote) { + assignableUsers.push(...assignableUsersForRemote); + } + + // used to track logins that shouldn't be added to pick list + // e.g. author, existing and already added reviewers + const skipList: Set = new Set([ + author.login + ]); + + const reviewersPromises: Promise<(vscode.QuickPickItem & { reviewer?: IAccount | ITeam })[]>[] = []; + + // Start with all existing reviewers so they show at the top + if (existingReviewers.length) { + reviewersPromises.push(getItems(folderRepositoryManager.context, skipList, existingReviewers.map(reviewer => reviewer.reviewer), true)); + } + + // If we removed the coding agent, add the Copilot reviewer instead + if (hasCopilotSweAgent && !existingReviewers.find(user => (user.reviewer as IAccount).login === COPILOT_REVIEWER)) { + assignableUsers.push(COPILOT_REVIEWER_ACCOUNT); + } + + // Suggested reviewers + reviewersPromises.push(getItems(folderRepositoryManager.context, skipList, suggestedReviewers, false)); + + const tooManyAssignable = assignableUsers.length > 60; + reviewersPromises.push(getItems(folderRepositoryManager.context, skipList, assignableUsers, false, tooManyAssignable)); + + const reviewers = (await Promise.all(reviewersPromises)).flat(); + + if (reviewers.length === 0) { + reviewers.push({ + label: vscode.l10n.t('No reviewers available for this repository') + }); + } + + return reviewers; +} + +export async function reviewersQuickPick(folderRepositoryManager: FolderRepositoryManager, remoteName: string, isInOrganization: boolean, teamsCount: number, author: IAccount, existingReviewers: ReviewState[], + suggestedReviewers: ISuggestedReviewer[] | undefined): Promise> { + const quickPick = vscode.window.createQuickPick(); + // The quick-max is used to show the "update reviewers" button. If the number of teams is less than the quick-max, then they'll be automatically updated when the quick pick is opened. + const quickMaxTeamReviewers = 100; + const defaultPlaceholder = vscode.l10n.t('Add reviewers'); + quickPick.busy = true; + quickPick.canSelectMany = true; + quickPick.matchOnDescription = true; + quickPick.placeholder = defaultPlaceholder; + if (isInOrganization) { + quickPick.buttons = [{ iconPath: new vscode.ThemeIcon('organization'), tooltip: vscode.l10n.t('Show or refresh team reviewers') }]; + } + quickPick.show(); + const updateItems = async (refreshKind: TeamReviewerRefreshKind) => { + const slowWarning = setTimeout(() => { + quickPick.placeholder = vscode.l10n.t('Getting team reviewers can take several minutes. Results will be cached.'); + }, 3000); + const start = performance.now(); + quickPick.items = await getReviewersQuickPickItems(folderRepositoryManager, remoteName, isInOrganization, author, existingReviewers, suggestedReviewers, refreshKind); + Logger.appendLine(`Setting quick pick reviewers took ${performance.now() - start}ms`, 'QuickPicks'); + clearTimeout(slowWarning); + quickPick.selectedItems = quickPick.items.filter(item => item.picked); + quickPick.placeholder = defaultPlaceholder; + }; + + await updateItems((teamsCount !== 0 && teamsCount <= quickMaxTeamReviewers) ? TeamReviewerRefreshKind.Try : TeamReviewerRefreshKind.None); + quickPick.onDidTriggerButton(() => { + quickPick.busy = true; + quickPick.ignoreFocusOut = true; + updateItems(TeamReviewerRefreshKind.Force).then(() => { + quickPick.ignoreFocusOut = false; + quickPick.busy = false; + }); + }); + return quickPick; +} + +type ProjectQuickPickItem = vscode.QuickPickItem & { id: string; project: IProject }; + +function isProjectQuickPickItem(x: vscode.QuickPickItem | ProjectQuickPickItem): x is ProjectQuickPickItem { + return !!(x as ProjectQuickPickItem).id && !!(x as ProjectQuickPickItem).project; +} + +export async function getProjectFromQuickPick(folderRepoManager: FolderRepositoryManager, githubRepository: GitHubRepository, currentProjects: IProject[] | undefined, callback: (projects: IProject[]) => Promise): Promise { + try { + let selectedItems: vscode.QuickPickItem[] = []; + async function getProjectOptions(): Promise<(ProjectQuickPickItem | vscode.QuickPickItem)[]> { + const projects = await folderRepoManager.getAllProjects(githubRepository); + if (!projects || !projects.length) { + return [ + { + label: vscode.l10n.t('No projects created for this repository.'), + }, + ]; + } + + const projectItems: (ProjectQuickPickItem | vscode.QuickPickItem)[] = projects.map(result => { + const item = { + iconPath: new vscode.ThemeIcon('github-project'), + label: result.title, + id: result.id, + project: result + }; + if (currentProjects && currentProjects.find(project => project.id === result.id)) { + selectedItems.push(item); + } + return item; + }); + return projectItems; + } + + const quickPick = vscode.window.createQuickPick(); + quickPick.busy = true; + quickPick.canSelectMany = true; + quickPick.title = vscode.l10n.t('Set projects'); + quickPick.ignoreFocusOut = true; + quickPick.show(); + quickPick.items = await getProjectOptions(); + quickPick.ignoreFocusOut = false; + if (quickPick.items.length === 1) { + quickPick.canSelectMany = false; + } + quickPick.selectedItems = selectedItems; + quickPick.busy = false; + + // Kick off a cache refresh + folderRepoManager.getOrgProjects(true); + quickPick.onDidAccept(async () => { + quickPick.hide(); + const projectsToAdd = quickPick.selectedItems.map(item => isProjectQuickPickItem(item) ? item.project : undefined).filter(project => project !== undefined) as IProject[]; + if (projectsToAdd) { + await callback(projectsToAdd); + } + }); + } catch (e) { + vscode.window.showErrorMessage(`Failed to add project: ${formatError(e)}`); + } +} + +type MilestoneQuickPickItem = vscode.QuickPickItem & { id: string; milestone: IMilestone }; + +function isMilestoneQuickPickItem(x: vscode.QuickPickItem | MilestoneQuickPickItem): x is MilestoneQuickPickItem { + return !!(x as MilestoneQuickPickItem).id && !!(x as MilestoneQuickPickItem).milestone; +} + +export async function getMilestoneFromQuickPick(folderRepositoryManager: FolderRepositoryManager, githubRepository: GitHubRepository, currentMilestone: IMilestone | undefined, callback: (milestone: IMilestone | undefined) => Promise): Promise { + try { + const removeMilestoneItem: vscode.QuickPickItem = { + label: vscode.l10n.t('Remove Milestone') + }; + let selectedItem: vscode.QuickPickItem | undefined; + async function getMilestoneOptions(): Promise<(MilestoneQuickPickItem | vscode.QuickPickItem)[]> { + const milestones = (await githubRepository.getMilestones())?.sort((a, b) => { + // Milestones with a date should be first, and sorted by due date + if (a.dueOn && b.dueOn) { + return new Date(a.dueOn).getTime() - new Date(b.dueOn).getTime(); + } else if (a.dueOn) { + return -1; + } else if (b.dueOn) { + return 1; + } else { + return a.title.localeCompare(b.title); + } + }); + if (!milestones || !milestones.length) { + return [ + { + label: vscode.l10n.t('No milestones created for this repository.'), + }, + ]; + } + + const milestonesItems: (MilestoneQuickPickItem | vscode.QuickPickItem)[] = milestones.map(result => { + const item = { + iconPath: new vscode.ThemeIcon('milestone'), + label: result.title, + id: result.id, + milestone: result + }; + if (currentMilestone && currentMilestone.id === result.id) { + selectedItem = item; + } + return item; + }); + if (currentMilestone) { + milestonesItems.unshift({ label: 'Milestones', kind: vscode.QuickPickItemKind.Separator }); + milestonesItems.unshift(removeMilestoneItem); + } + return milestonesItems; + } + + const quickPick = vscode.window.createQuickPick(); + quickPick.busy = true; + quickPick.canSelectMany = false; + quickPick.title = vscode.l10n.t('Set milestone'); + quickPick.buttons = [{ + iconPath: new vscode.ThemeIcon('add'), + tooltip: 'Create', + }]; + quickPick.onDidTriggerButton((_) => { + quickPick.hide(); + + const inputBox = vscode.window.createInputBox(); + inputBox.title = vscode.l10n.t('Create new milestone'); + inputBox.placeholder = vscode.l10n.t('New milestone title'); + if (quickPick.value !== '') { + inputBox.value = quickPick.value; + } + inputBox.show(); + inputBox.onDidAccept(async () => { + inputBox.hide(); + if (inputBox.value === '') { + return; + } + if (inputBox.value.length > 255) { + vscode.window.showErrorMessage(vscode.l10n.t(`Failed to create milestone: The title can contain a maximum of 255 characters`)); + return; + } + // Check if milestone already exists (only check open ones) + for (const existingMilestone of quickPick.items) { + if (existingMilestone.label === inputBox.value) { + vscode.window.showErrorMessage(vscode.l10n.t('Failed to create milestone: The milestone \'{0}\' already exists', inputBox.value)); + return; + } + } + try { + const milestone = await folderRepositoryManager.createMilestone(githubRepository, inputBox.value); + if (milestone !== undefined) { + await callback(milestone); + } + } catch (e) { + vscode.window.showErrorMessage(`Failed to create milestone: ${formatError(e)}`); + } + }); + }); + + quickPick.show(); + quickPick.items = await getMilestoneOptions(); + quickPick.activeItems = selectedItem ? [selectedItem] : (currentMilestone ? [quickPick.items[1]] : [quickPick.items[0]]); + quickPick.busy = false; + + quickPick.onDidAccept(async () => { + quickPick.hide(); + const milestoneToAdd = quickPick.selectedItems[0]; + if (milestoneToAdd && isMilestoneQuickPickItem(milestoneToAdd)) { + await callback(milestoneToAdd.milestone); + } else if (milestoneToAdd && milestoneToAdd === removeMilestoneItem) { + await callback(undefined); + } + }); + } catch (e) { + vscode.window.showErrorMessage(`Failed to add milestone: ${formatError(e)}`); + } +} + +export async function getLabelOptions( + folderRepoManager: FolderRepositoryManager, + labels: ILabel[], + baseOwner: string, + repositoryName: string +): Promise<{ newLabels: DisplayLabel[], labelPicks: (vscode.QuickPickItem & { name: string })[] }> { + await ensureEmojis(folderRepoManager.context); + const newLabels = (await folderRepoManager.getLabels(undefined, { owner: baseOwner, repo: repositoryName })).map(label => ({ ...label, displayName: emojify(label.name) })); + + const labelPicks = newLabels.map(label => { + return { + label: label.displayName, + name: label.name, + description: label.description ?? undefined, + picked: labels.some(existingLabel => existingLabel.name === label.name), + iconPath: DataUri.asImageDataURI(Buffer.from(` + + `, 'utf8')) + }; + }).sort((a, b) => { + // Sort so that already picked labels are at the top + if (a.picked === b.picked) { + return a.label.localeCompare(b.label); + } + return a.picked ? -1 : 1; + }); + return { newLabels, labelPicks }; +} + +export async function pickEmail(githubRepository: GitHubRepository, current: string): Promise { + async function getEmails(): Promise<(vscode.QuickPickItem)[]> { + const emails = await githubRepository.getAuthenticatedUserEmails(); + return emails.map(email => { + return { + label: email, + picked: email.toLowerCase() === current.toLowerCase() + }; + }); + } + + const result = await vscode.window.showQuickPick(getEmails(), { canPickMany: false, title: vscode.l10n.t('Choose an email') }); + return result ? result.label : undefined; +} + +function getRecentlyUsedBranches(folderRepoManager: FolderRepositoryManager, owner: string, repositoryName: string): string[] { + const repoKey = `${owner}/${repositoryName}`; + const state = folderRepoManager.context.workspaceState.get(RECENTLY_USED_BRANCHES, { branches: {} }); + return state.branches[repoKey] || []; +} + +export async function branchPicks(githubRepository: GitHubRepository, folderRepoManager: FolderRepositoryManager, changeRepoMessage: string | undefined, isBase: boolean, prefix: string | undefined): Promise<(vscode.QuickPickItem & { remote?: RemoteInfo, branch?: string })[]> { + let branches: (string | Ref)[]; + if (isBase) { + // For the base, we only want to show branches from GitHub. + branches = await githubRepository.listBranches(githubRepository.remote.owner, githubRepository.remote.repositoryName, prefix); + } else { + // For the compare, we only want to show local branches. + branches = (await folderRepoManager.repository.getBranches({ remote: false })).filter(branch => branch.name); + } + + + const branchNames = branches.map(branch => typeof branch === 'string' ? branch : branch.name!); + + // Get recently used branches for base branches only + let recentBranches: string[] = []; + let otherBranches: string[] = branchNames; + if (isBase) { + const recentlyUsed = getRecentlyUsedBranches(folderRepoManager, githubRepository.remote.owner, githubRepository.remote.repositoryName); + // Include all recently used branches, even if they're not in the current branch list + // This allows showing branches that weren't fetched due to timeout + recentBranches = recentlyUsed; + // Remove recently used branches from the main list (if they exist there) + otherBranches = branchNames.filter(name => !recentBranches.includes(name)); + } + + const branchPicks: (vscode.QuickPickItem & { remote?: RemoteInfo, branch?: string })[] = []; + + // Add recently used branches section + if (recentBranches.length > 0) { + branchPicks.push({ + kind: vscode.QuickPickItemKind.Separator, + label: vscode.l10n.t('Recently Used') + }); + recentBranches.forEach(branchName => { + branchPicks.push({ + iconPath: new vscode.ThemeIcon('git-branch'), + label: branchName, + remote: { + owner: githubRepository.remote.owner, + repositoryName: githubRepository.remote.repositoryName + }, + branch: branchName + }); + }); + } + + // Add all other branches section + if (otherBranches.length > 0) { + branchPicks.push({ + kind: vscode.QuickPickItemKind.Separator, + label: recentBranches.length > 0 ? vscode.l10n.t('All Branches') : `${githubRepository.remote.owner}/${githubRepository.remote.repositoryName}` + }); + otherBranches.forEach(branchName => { + branchPicks.push({ + iconPath: new vscode.ThemeIcon('git-branch'), + label: branchName, + remote: { + owner: githubRepository.remote.owner, + repositoryName: githubRepository.remote.repositoryName + }, + branch: branchName + }); + }); + } + + if (changeRepoMessage) { + branchPicks.unshift({ + iconPath: new vscode.ThemeIcon('repo'), + label: changeRepoMessage + }); + } + return branchPicks; +} \ No newline at end of file diff --git a/src/github/repositoriesManager.ts b/src/github/repositoriesManager.ts index 3099ddc951..e2fce7a325 100644 --- a/src/github/repositoriesManager.ts +++ b/src/github/repositoriesManager.ts @@ -3,18 +3,21 @@ * 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'; +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'; +import { Disposable, disposeAll } from '../common/lifecycle'; +import Logger from '../common/logger'; import { ITelemetry } from '../common/telemetry'; import { EventType } from '../common/timelineEvent'; -import { compareIgnoreCase, dispose } from '../common/utils'; -import { CredentialStore } from './credentials'; -import { FolderRepositoryManager, ReposManagerState, ReposManagerStateContext } from './folderRepositoryManager'; -import { IssueModel } from './issueModel'; -import { findDotComAndEnterpriseRemotes, hasEnterpriseUri, setEnterpriseUri } from './utils'; +import { fromPRUri, fromRepoUri, Schemes } from '../common/uri'; +import { compareIgnoreCase, isDescendant } from '../common/utils'; export interface ItemsResponseResult { items: T[]; @@ -28,36 +31,39 @@ export interface PullRequestDefaults { base: string; } -export const NO_MILESTONE: string = 'No Milestone'; - -export class RepositoriesManager implements vscode.Disposable { +export class RepositoriesManager extends Disposable { static ID = 'RepositoriesManager'; + private _folderManagers: FolderRepositoryManager[] = []; private _subs: Map; private _onDidChangeState = new vscode.EventEmitter(); readonly onDidChangeState: vscode.Event = this._onDidChangeState.event; - private _onDidChangeFolderRepositories = new vscode.EventEmitter(); + private _onDidChangeFolderRepositories = new vscode.EventEmitter<{ added?: FolderRepositoryManager }>(); readonly onDidChangeFolderRepositories = this._onDidChangeFolderRepositories.event; private _onDidLoadAnyRepositories = new vscode.EventEmitter(); readonly onDidLoadAnyRepositories = this._onDidLoadAnyRepositories.event; + private _onDidChangeAnyPullRequests = new vscode.EventEmitter(); + readonly onDidChangeAnyPullRequests = this._onDidChangeAnyPullRequests.event; + + private _onDidAddPullRequest = new vscode.EventEmitter(); + readonly onDidAddPullRequest = this._onDidAddPullRequest.event; + + private _onDidAddAnyGitHubRepository = new vscode.EventEmitter(); + readonly onDidChangeAnyGitHubRepository = this._onDidAddAnyGitHubRepository.event; + private _state: ReposManagerState = ReposManagerState.Initializing; constructor( - private _folderManagers: FolderRepositoryManager[], private _credentialStore: CredentialStore, private _telemetry: ITelemetry, ) { + super(); this._subs = new Map(); vscode.commands.executeCommand('setContext', ReposManagerStateContext, this._state); - - this.updateActiveReviewCount(); - for (const folderManager of this._folderManagers) { - this.registerFolderListeners(folderManager); - } } private updateActiveReviewCount() { @@ -76,11 +82,15 @@ export class RepositoriesManager implements vscode.Disposable { private registerFolderListeners(folderManager: FolderRepositoryManager) { const disposables = [ - folderManager.onDidLoadRepositories(state => { - this.state = state; + folderManager.onDidLoadRepositories(() => { + this.updateState(); this._onDidLoadAnyRepositories.fire(); }), - folderManager.onDidChangeActivePullRequest(() => this.updateActiveReviewCount()) + folderManager.onDidChangeActivePullRequest(() => this.updateActiveReviewCount()), + folderManager.onDidDispose(() => this.removeRepo(folderManager.repository)), + folderManager.onDidChangeAnyPullRequests(e => this._onDidChangeAnyPullRequests.fire(e)), + folderManager.onDidAddPullRequest(e => this._onDidAddPullRequest.fire(e)), + folderManager.onDidChangeGithubRepositories(() => this._onDidAddAnyGitHubRepository.fire(folderManager)), ]; this._subs.set(folderManager, disposables); } @@ -92,7 +102,7 @@ export class RepositoriesManager implements vscode.Disposable { const workspaceFolders = vscode.workspace.workspaceFolders; if (workspaceFolders) { const index = workspaceFolders.findIndex( - folder => folder.uri.toString() === folderManager.repository.rootUri.toString(), + folder => isDescendant(folder.uri.fsPath, folderManager.repository.rootUri.fsPath) || isDescendant(folderManager.repository.rootUri.fsPath, folder.uri.fsPath), ); if (index > -1) { const arrayEnd = this._folderManagers.slice(index, this._folderManagers.length); @@ -100,13 +110,13 @@ export class RepositoriesManager implements vscode.Disposable { this._folderManagers.push(folderManager); this._folderManagers.push(...arrayEnd); this.updateActiveReviewCount(); - this._onDidChangeFolderRepositories.fire(); + this._onDidChangeFolderRepositories.fire({ added: folderManager }); return; } } this._folderManagers.push(folderManager); this.updateActiveReviewCount(); - this._onDidChangeFolderRepositories.fire(); + this._onDidChangeFolderRepositories.fire({ added: folderManager }); } removeRepo(repo: Repository) { @@ -115,12 +125,12 @@ export class RepositoriesManager implements vscode.Disposable { ); if (existingFolderManagerIndex > -1) { const folderManager = this._folderManagers[existingFolderManagerIndex]; - dispose(this._subs.get(folderManager)!); + disposeAll(this._subs.get(folderManager)!); this._subs.delete(folderManager); this._folderManagers.splice(existingFolderManagerIndex); folderManager.dispose(); this.updateActiveReviewCount(); - this._onDidChangeFolderRepositories.fire(); + this._onDidChangeFolderRepositories.fire({}); } } @@ -128,22 +138,7 @@ export class RepositoriesManager implements vscode.Disposable { if (issueModel === undefined) { return undefined; } - const issueRemoteUrl = issueModel.remote.url.substring( - 0, - issueModel.remote.url.length - path.extname(issueModel.remote.url).length, - ); - for (const folderManager of this._folderManagers) { - if ( - folderManager.gitHubRepositories - .map(repo => - repo.remote.url.substring(0, repo.remote.url.length - path.extname(repo.remote.url).length), - ) - .includes(issueRemoteUrl) - ) { - return folderManager; - } - } - return undefined; + return this.getManagerForRepository(issueModel.remote.owner, issueModel.remote.repositoryName); } getManagerForFile(uri: vscode.Uri): FolderRepositoryManager | undefined { @@ -151,27 +146,75 @@ export class RepositoriesManager implements vscode.Disposable { return this._folderManagers[0]; } - for (const folderManager of this._folderManagers) { + const repoInfo = ((uri.scheme === Schemes.Repo) ? fromRepoUri(uri) : undefined); + const prInfo = ((uri.scheme === Schemes.Pr) ? fromPRUri(uri) : undefined); + + // Prioritize longest path first to handle nested workspaces + const folderManagers = this._folderManagers + .slice() + .sort((a, b) => b.repository.rootUri.path.length - a.repository.rootUri.path.length); + + for (const folderManager of folderManagers) { const managerPath = folderManager.repository.rootUri.path; - const testUriRelativePath = uri.path.substring( - managerPath.length > 1 ? managerPath.length + 1 : managerPath.length, - ); - if (compareIgnoreCase(vscode.Uri.joinPath(folderManager.repository.rootUri, testUriRelativePath).path, uri.path) === 0) { + + if (repoInfo && folderManager.findExistingGitHubRepository({ owner: repoInfo.owner, repositoryName: repoInfo.repo })) { + return folderManager; + } else if (prInfo && folderManager.repository.state.remotes.find(remote => remote.name === prInfo.remoteName)) { return folderManager; + } else { + const testUriRelativePath = uri.path.substring( + managerPath.length > 1 ? managerPath.length + 1 : managerPath.length, + ); + if (compareIgnoreCase(vscode.Uri.joinPath(folderManager.repository.rootUri, testUriRelativePath).path, uri.path) === 0) { + return folderManager; + } } } return undefined; } + getManagerForRepository(owner: string, repo: string) { + const issueRemoteUrl = `${owner.toLowerCase()}/${repo.toLowerCase()}`; + for (const folderManager of this._folderManagers) { + if ( + folderManager.gitHubRepositories + .map(repo => + `${repo.remote.owner.toLowerCase()}/${repo.remote.repositoryName.toLowerCase()}` + ) + .includes(issueRemoteUrl) + ) { + return folderManager; + } + } + } + get state() { return this._state; } - set state(state: ReposManagerState) { - const stateChange = state !== this._state; - this._state = state; + private updateState(state?: ReposManagerState) { + let maxState = ReposManagerState.Initializing; + if (state) { + maxState = state; + } else { + // Get the most advanced state from all folder managers + const stateValue = (testState: ReposManagerState) => { + switch (testState) { + case ReposManagerState.Initializing: return 0; + case ReposManagerState.NeedsAuthentication: return 1; + case ReposManagerState.RepositoriesLoaded: return 2; + } + }; + for (const folderManager of this._folderManagers) { + if (stateValue(folderManager.state) > stateValue(maxState)) { + maxState = folderManager.state; + } + } + } + const stateChange = maxState !== this._state; + this._state = maxState; if (stateChange) { - vscode.commands.executeCommand('setContext', ReposManagerStateContext, state); + vscode.commands.executeCommand('setContext', ReposManagerStateContext, maxState); this._onDidChangeState.fire(); } } @@ -182,20 +225,45 @@ export class RepositoriesManager implements vscode.Disposable { async clearCredentialCache(): Promise { await this._credentialStore.reset(); - this.state = ReposManagerState.Initializing; + this.updateState(ReposManagerState.NeedsAuthentication); } - async authenticate(enterprise: boolean = false): Promise { + async authenticate(enterprise?: boolean): Promise { + if (enterprise === false) { + return !!this._credentialStore.login(AuthProvider.github); + } const { dotComRemotes, enterpriseRemotes, unknownRemotes } = await findDotComAndEnterpriseRemotes(this.folderManagers); const yes = vscode.l10n.t('Yes'); if (enterprise) { - const remoteToUse = enterpriseRemotes.length ? enterpriseRemotes[0] : (unknownRemotes.length ? unknownRemotes[0] : undefined); + let remoteToUse = getEnterpriseUri()?.toString() ?? (enterpriseRemotes.length ? enterpriseRemotes[0].normalizedHost : (unknownRemotes.length ? unknownRemotes[0].normalizedHost : undefined)); + if (enterpriseRemotes.length === 0 && unknownRemotes.length === 0) { + Logger.appendLine(`Enterprise login selected, but no possible enterprise remotes discovered (${dotComRemotes.length} .com)`, RepositoriesManager.ID); + } if (remoteToUse) { - const promptResult = await vscode.window.showInformationMessage(vscode.l10n.t('Would you like to set up GitHub Pull Requests and Issues to authenticate with the enterprise server {0}?', remoteToUse.normalizedHost), - { modal: true }, yes, vscode.l10n.t('No, manually set {0}', 'github-enterprise.uri')); + const no = vscode.l10n.t('No, manually set {0}', 'github-enterprise.uri'); + const promptResult = await vscode.window.showInformationMessage(vscode.l10n.t('Would you like to set up GitHub Pull Requests and Issues to authenticate with the enterprise server {0}?', remoteToUse), + { modal: true }, yes, no); if (promptResult === yes) { - await setEnterpriseUri(remoteToUse.normalizedHost); + await setEnterpriseUri(remoteToUse); + } else if (promptResult === no) { + remoteToUse = undefined; + } else { + return false; + } + } + if (!remoteToUse) { + const setEnterpriseUriPrompt = await vscode.window.showInputBox({ + placeHolder: vscode.l10n.t('Set a GitHub Enterprise server URL'), ignoreFocusOut: true, validateInput: (value) => { + const pattern = /^(?:$|(https?):\/\/(?!github\.com).*)/; + if (!pattern.test(value)) { + return vscode.l10n.t('Please enter a valid GitHub Enterprise server URL. A "github.com" URL is not valid for GitHub Enterprise.'); + } + return undefined; + } + }); + if (setEnterpriseUriPrompt) { + await setEnterpriseUri(setEnterpriseUriPrompt); } else { return false; } @@ -215,7 +283,7 @@ export class RepositoriesManager implements vscode.Disposable { let githubEnterprise; const hasNonDotComRemote = (enterpriseRemotes.length > 0) || (unknownRemotes.length > 0); if ((hasEnterpriseUri() || (dotComRemotes.length === 0)) && hasNonDotComRemote) { - githubEnterprise = await this._credentialStore.login(AuthProvider['github-enterprise']); + githubEnterprise = await this._credentialStore.login(AuthProvider.githubEnterprise); } let github; if (!githubEnterprise && (!hasEnterpriseUri() || enterpriseRemotes.length === 0)) { @@ -224,8 +292,8 @@ export class RepositoriesManager implements vscode.Disposable { return !!github || !!githubEnterprise; } - dispose() { - this._subs.forEach(sub => dispose(sub)); + override dispose() { + this._subs.forEach(sub => disposeAll(sub)); } } diff --git a/src/github/revertPRViewProvider.ts b/src/github/revertPRViewProvider.ts new file mode 100644 index 0000000000..c50ad7eaa1 --- /dev/null +++ b/src/github/revertPRViewProvider.ts @@ -0,0 +1,114 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CreateParamsNew, CreatePullRequestNew } from '../../common/views'; +import { openDescription } from '../commands'; +import { BaseCreatePullRequestViewProvider, BasePullRequestDataModel } from './createPRViewProvider'; +import { + FolderRepositoryManager, + PullRequestDefaults, +} 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( + telemetry: ITelemetry, + model: BasePullRequestDataModel, + extensionUri: vscode.Uri, + folderRepositoryManager: FolderRepositoryManager, + pullRequestDefaults: PullRequestDefaults, + private readonly pullRequest: PullRequestModel + ) { + super(telemetry, model, extensionUri, folderRepositoryManager, pullRequestDefaults, folderRepositoryManager.repository.state.HEAD?.name ?? pullRequest.base.name); + } + + protected async getTitleAndDescription(): Promise<{ title: string; description: string; }> { + return { + title: vscode.l10n.t('Revert "{0}"', this.pullRequest.title), + description: vscode.l10n.t('Reverts {0}', `${this.pullRequest.remote.owner}/${this.pullRequest.remote.repositoryName}#${this.pullRequest.number}`) + }; + } + + protected async detectBaseMetadata(): Promise { + return { + owner: this.pullRequest.remote.owner, + repositoryName: this.pullRequest.remote.repositoryName, + branch: this.pullRequest.base.name + }; + } + + protected override getTitleAndDescriptionProvider(_name?: string) { + return undefined; + } + + protected override async getCreateParams(): Promise { + const params = await super.getCreateParams(); + params.canModifyBranches = false; + params.actionDetail = vscode.l10n.t('Reverting'); + params.associatedExistingPullRequest = this.pullRequest.number; + return params; + } + + private openAssociatedPullRequest() { + return openDescription(this.telemetry, this.pullRequest, undefined, this._folderRepositoryManager, false, true); + } + + protected override async _onDidReceiveMessage(message: IRequestMessage) { + const result = await super._onDidReceiveMessage(message); + if (result !== this.MESSAGE_UNHANDLED) { + return; + } + + switch (message.command) { + case 'pr.openAssociatedPullRequest': + return this.openAssociatedPullRequest(); + default: + // Log error + vscode.window.showErrorMessage('Unsupported webview message'); + } + } + + protected async create(message: IRequestMessage): Promise { + let revertPr: PullRequestModel | undefined; + RevertPullRequestViewProvider.withProgress(async () => { + commands.setContext(contexts.CREATING, true); + try { + revertPr = await this._folderRepositoryManager.revert(this.pullRequest, message.args.title, message.args.body, message.args.draft); + if (revertPr) { + await this.postCreate(message, revertPr); + await openDescription(this.telemetry, revertPr, undefined, this._folderRepositoryManager, true); + } + + } catch (e) { + if (!revertPr) { + let errorMessage: string = e.message; + if (errorMessage.startsWith('GraphQL error: ')) { + errorMessage = errorMessage.substring('GraphQL error: '.length); + } + this._throwError(message, errorMessage); + } else { + if ((e as Error).message === 'GraphQL error: ["Pull request Pull request is in unstable status"]') { + // This error can happen if the PR isn't fully created by the time we try to set properties on it. Try again. + await this.postCreate(message, revertPr); + } + // All of these errors occur after the PR is created, so the error is not critical. + vscode.window.showErrorMessage(vscode.l10n.t('There was an error creating the pull request: {0}', (e as Error).message)); + } + } finally { + commands.setContext(contexts.CREATING, false); + if (revertPr) { + this._onDone.fire(revertPr); + } else { + await this._replyMessage(message, {}); + } + } + }); + } +} \ No newline at end of file diff --git a/src/github/utils.ts b/src/github/utils.ts index 36af5b522c..1d9abc778f 100644 --- a/src/github/utils.ts +++ b/src/github/utils.ts @@ -7,38 +7,54 @@ import * as crypto from 'crypto'; import * as OctokitTypes from '@octokit/types'; import * as vscode from 'vscode'; -import { Repository } from '../api/api'; -import { GitApiImpl } from '../api/api1'; -import { AuthProvider, GitHubServerType } from '../common/authentication'; -import { IComment, IReviewThread, Reaction } from '../common/comment'; -import { DiffHunk, parseDiffHunk } from '../common/diffHunk'; -import { GitHubRef } from '../common/githubRef'; -import Logger from '../common/logger'; -import { Remote } from '../common/remote'; -import { Resource } from '../common/resources'; -import { OVERRIDE_DEFAULT_BRANCH } from '../common/settingKeys'; -import * as Common from '../common/timelineEvent'; -import { uniqBy } from '../common/utils'; import { OctokitCommon } from './common'; -import { FolderRepositoryManager, PullRequestDefaults, SETTINGS_NAMESPACE } from './folderRepositoryManager'; +import { FolderRepositoryManager, PullRequestDefaults } from './folderRepositoryManager'; import { GitHubRepository, ViewerPermission } from './githubRepository'; import * as GraphQL from './graphql'; import { + AccountType, IAccount, + IActor, IGitHubRef, + IIssueComment, ILabel, IMilestone, + IProjectItem, Issue, ISuggestedReviewer, + ITeam, MergeMethod, + MergeQueueEntry, + MergeQueueState, + Notification, + NotificationSubjectType, PullRequest, PullRequestMergeability, + Reaction, + reviewerId, + reviewerLabel, ReviewState, + toAccountType, User, } from './interface'; import { IssueModel } from './issueModel'; import { GHPRComment, GHPRCommentThread } from './prComment'; import { PullRequestModel } from './pullRequestModel'; +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_REVIEWER, 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)/; @@ -84,29 +100,49 @@ export function threadRange(startLine: number, endLine: number, endCharacter?: n return new vscode.Range(startLine, 0, endLine, endCharacter); } +export async function setReplyAuthor(thread: vscode.CommentThread | vscode.CommentThread2, currentUser: IAccount, context: vscode.ExtensionContext) { + if (currentUser.avatarUrl) { + const thread2 = thread as vscode.CommentThread2; + thread2.canReply = { name: currentUser.name ?? currentUser.login, iconPath: vscode.Uri.parse(currentUser.avatarUrl) }; + const uri = await DataUri.avatarCirclesAsImageDataUris(context, [currentUser], 28, 28); + thread2.canReply = { name: currentUser.name ?? currentUser.login, iconPath: uri[0] }; + } else { + thread.canReply = true; + } +} + export function createVSCodeCommentThreadForReviewThread( + context: vscode.ExtensionContext, uri: vscode.Uri, - range: vscode.Range, + range: vscode.Range | undefined, thread: IReviewThread, commentController: vscode.CommentController, - currentUser: string, - githubRepository?: GitHubRepository + currentUser: IAccount, + githubRepositories?: GitHubRepository[] ): GHPRCommentThread { const vscodeThread = commentController.createCommentThread(uri, range, []); (vscodeThread as GHPRCommentThread).gitHubThreadId = thread.id; - vscodeThread.comments = thread.comments.map(comment => new GHPRComment(comment, vscodeThread as GHPRCommentThread, githubRepository)); - vscodeThread.state = isResolvedToResolvedState(thread.isResolved); + vscodeThread.comments = thread.comments.map(comment => new GHPRComment(context, comment, vscodeThread as GHPRCommentThread, githubRepositories)); + const resolved = isResolvedToResolvedState(thread.isResolved); + let applicability = vscode.CommentThreadApplicability.Current; if (thread.viewerCanResolve && !thread.isResolved) { vscodeThread.contextValue = 'canResolve'; } else if (thread.viewerCanUnresolve && thread.isResolved) { vscodeThread.contextValue = 'canUnresolve'; } + if (thread.isOutdated) { + vscodeThread.contextValue += 'outdated'; + applicability = vscode.CommentThreadApplicability.Outdated; + } + vscodeThread.state = { resolved, applicability }; updateCommentThreadLabel(vscodeThread as GHPRCommentThread); - vscodeThread.collapsibleState = getCommentCollapsibleState(thread, undefined, currentUser); + vscodeThread.collapsibleState = getCommentCollapsibleState(thread, undefined, currentUser.login); + + setReplyAuthor(vscodeThread, currentUser, context); return vscodeThread as GHPRCommentThread; } @@ -117,14 +153,22 @@ function isResolvedToResolvedState(isResolved: boolean) { export const COMMENT_EXPAND_STATE_SETTING = 'commentExpandState'; export const COMMENT_EXPAND_STATE_COLLAPSE_VALUE = 'collapseAll'; +export const COMMENT_EXPAND_STATE_COLLAPSE_PREEXISTING_VALUE = 'collapsePreexisting'; export const COMMENT_EXPAND_STATE_EXPAND_VALUE = 'expandUnresolved'; -export function getCommentCollapsibleState(thread: IReviewThread, expand?: boolean, currentUser?: string) { - if (thread.isResolved - || (currentUser && thread.comments[thread.comments.length - 1].user?.login === currentUser)) { +export function getCommentCollapsibleState(thread: IReviewThread, expand?: boolean, currentUser?: string, isNewlyAdded?: boolean) { + const config = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE)?.get(COMMENT_EXPAND_STATE_SETTING); + const isFromCurrent = (currentUser && (thread.comments[thread.comments.length - 1].user?.login === currentUser)); + const isJustSuggestion = thread.comments.length === 1 && thread.comments[0].body.startsWith('```suggestion') && thread.comments[0].body.endsWith('```'); + + // When collapsePreexisting is set, keep newly added comments expanded + if (config === COMMENT_EXPAND_STATE_COLLAPSE_PREEXISTING_VALUE && isNewlyAdded && !isJustSuggestion) { + return vscode.CommentThreadCollapsibleState.Expanded; + } + + if (thread.isResolved || (!thread.isOutdated && isFromCurrent && !isJustSuggestion)) { return vscode.CommentThreadCollapsibleState.Collapsed; } if (expand === undefined) { - const config = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE)?.get(COMMENT_EXPAND_STATE_SETTING); expand = config === COMMENT_EXPAND_STATE_EXPAND_VALUE; } return expand @@ -132,46 +176,69 @@ export function getCommentCollapsibleState(thread: IReviewThread, expand?: boole } -export function updateThreadWithRange(vscodeThread: GHPRCommentThread, reviewThread: IReviewThread, githubRepository: GitHubRepository, expand?: boolean) { +export function updateThreadWithRange(context: vscode.ExtensionContext, vscodeThread: GHPRCommentThread, reviewThread: IReviewThread, githubRepositories?: GitHubRepository[], expand?: boolean, isNewlyAdded?: boolean) { + if (!vscodeThread.range) { + return; + } const editors = vscode.window.visibleTextEditors; for (let editor of editors) { if (editor.document.uri.toString() === vscodeThread.uri.toString()) { const endLine = editor.document.lineAt(vscodeThread.range.end.line); const range = new vscode.Range(vscodeThread.range.start.line, 0, vscodeThread.range.end.line, endLine.text.length); - updateThread(vscodeThread, reviewThread, githubRepository, expand, range); + updateThread(context, vscodeThread, reviewThread, githubRepositories, expand, range, undefined, isNewlyAdded); break; } } } -export function updateThread(vscodeThread: GHPRCommentThread, reviewThread: IReviewThread, githubRepository: GitHubRepository, expand?: boolean, range?: vscode.Range) { +export function updateThread(context: vscode.ExtensionContext, vscodeThread: GHPRCommentThread, reviewThread: IReviewThread, githubRepositories?: GitHubRepository[], expand?: boolean, range?: vscode.Range, currentUser?: string, isNewlyAdded?: boolean) { if (reviewThread.viewerCanResolve && !reviewThread.isResolved) { vscodeThread.contextValue = 'canResolve'; } else if (reviewThread.viewerCanUnresolve && reviewThread.isResolved) { vscodeThread.contextValue = 'canUnresolve'; } + if (reviewThread.isOutdated) { + vscodeThread.contextValue += 'outdated'; + } + const newResolvedState = isResolvedToResolvedState(reviewThread.isResolved); - if (vscodeThread.state !== newResolvedState) { - vscodeThread.state = newResolvedState; + const newApplicabilityState = reviewThread.isOutdated ? vscode.CommentThreadApplicability.Outdated : vscode.CommentThreadApplicability.Current; + if ((vscodeThread.state?.resolved !== newResolvedState) || (vscodeThread.state?.applicability !== newApplicabilityState)) { + vscodeThread.state = { + resolved: newResolvedState, + applicability: newApplicabilityState + }; } - vscodeThread.collapsibleState = getCommentCollapsibleState(reviewThread, expand); + vscodeThread.collapsibleState = getCommentCollapsibleState(reviewThread, expand, currentUser, isNewlyAdded); if (range) { vscodeThread.range = range; } - vscodeThread.comments = reviewThread.comments.map(c => new GHPRComment(c, vscodeThread, githubRepository)); + if ((vscodeThread.comments.length === reviewThread.comments.length) && vscodeThread.comments.every((vscodeComment, index) => vscodeComment.commentId === `${reviewThread.comments[index].id}`)) { + // The comments all still exist. Update them instead of creating new ones. This allows the UI to be more stable. + let index = 0; + for (const comment of vscodeThread.comments) { + if (comment instanceof GHPRComment) { + comment.update(reviewThread.comments[index]); + } + index++; + } + } else { + vscodeThread.comments = reviewThread.comments.map(c => new GHPRComment(context, c, vscodeThread, githubRepositories)); + } + updateCommentThreadLabel(vscodeThread); } export function updateCommentThreadLabel(thread: GHPRCommentThread) { - if (thread.state === vscode.CommentThreadState.Resolved) { + if (thread.state?.resolved === vscode.CommentThreadState.Resolved) { thread.label = vscode.l10n.t('Marked as resolved'); return; } if (thread.comments.length) { - const participantsList = uniqBy(thread.comments as vscode.Comment[], comment => comment.author.name) - .map(comment => `@${comment.author.name}`) + const participantsList = uniqBy(thread.comments, comment => comment.originalAuthor.name) + .map(comment => `@${comment.originalAuthor.name}`) .join(', '); thread.label = vscode.l10n.t('Participants: {0}', participantsList); } else { @@ -179,28 +246,34 @@ export function updateCommentThreadLabel(thread: GHPRCommentThread) { } } -export function generateCommentReactions(reactions: Reaction[] | undefined) { - return getReactionGroup().map(reaction => { +export function updateCommentReactions(comment: vscode.Comment, reactions: Reaction[] | undefined) { + let reactionsHaveUpdates = false; + const previousReactions = comment.reactions; + const newReactions = getReactionGroup().map((reaction, index) => { if (!reactions) { return { label: reaction.label, authorHasReacted: false, count: 0, iconPath: reaction.icon || '' }; } const matchedReaction = reactions.find(re => re.label === reaction.label); - + let newReaction: vscode.CommentReaction; if (matchedReaction) { - return { + newReaction = { label: matchedReaction.label, authorHasReacted: matchedReaction.viewerHasReacted, count: matchedReaction.count, iconPath: reaction.icon || '', + reactors: matchedReaction.reactors.map(reactor => ({ name: reactor })) }; } else { - return { label: reaction.label, authorHasReacted: false, count: 0, iconPath: reaction.icon || '' }; + newReaction = { label: reaction.label, authorHasReacted: false, count: 0, iconPath: reaction.icon || '' }; + } + if (!reactionsHaveUpdates && (!previousReactions || (previousReactions[index].authorHasReacted !== newReaction.authorHasReacted) || (previousReactions[index].count !== newReaction.count))) { + reactionsHaveUpdates = true; } + return newReaction; }); -} -export function updateCommentReactions(comment: vscode.Comment, reactions: Reaction[] | undefined) { - comment.reactions = generateCommentReactions(reactions); + comment.reactions = newReactions; + return reactionsHaveUpdates; } export function updateCommentReviewState(thread: GHPRCommentThread, newDraftMode: boolean) { @@ -219,30 +292,43 @@ export function updateCommentReviewState(thread: GHPRCommentThread, newDraftMode }); } +export function isEnterprise(provider: AuthProvider): boolean { + return provider === AuthProvider.githubEnterprise; +} + export function convertRESTUserToAccount( user: OctokitCommon.PullsListResponseItemUser, - githubRepository: GitHubRepository, + githubRepository?: GitHubRepository, ): IAccount { - return { - login: user.login, - url: user.html_url, - avatarUrl: getAvatarWithEnterpriseFallback(user.avatar_url, user.gravatar_id ?? undefined, githubRepository.remote.authProviderId), - }; + return parseAccount(user, githubRepository); } -export function convertRESTHeadToIGitHubRef(head: OctokitCommon.PullsListResponseItemHead) { +export function convertRESTHeadToIGitHubRef(head: OctokitCommon.PullsListResponseItemHead): IGitHubRef { return { label: head.label, ref: head.ref, sha: head.sha, repo: { cloneUrl: head.repo.clone_url, + isInOrganization: head.repo.owner.type === 'Organization', owner: head.repo.owner!.login, name: head.repo.name }, }; } +async function transformHtmlUrlsToExtensionUrls(body: string, githubRepository: GitHubRepository): Promise { + const issueRegex = new RegExp( + `href="https?:\/\/${escapeRegExp(githubRepository.remote.gitProtocol.url.authority)}\\/${escapeRegExp(githubRepository.remote.owner)}\\/${escapeRegExp(githubRepository.remote.repositoryName)}\\/(issues|pull)\\/([0-9]+)"`, 'g'); + return stringReplaceAsync(body, issueRegex, async (match: string, issuesOrPull: string, number: string) => { + if (issuesOrPull === 'issues') { + return `href="${(await toOpenIssueWebviewUri({ owner: githubRepository.remote.owner, repo: githubRepository.remote.repositoryName, issueNumber: Number(number) })).toString()}""`; + } else { + return `href="${(await toOpenPullRequestWebviewUri({ owner: githubRepository.remote.owner, repo: githubRepository.remote.repositoryName, pullRequestNumber: Number(number) })).toString()}"`; + } + }); +} + export function convertRESTPullRequestToRawPullRequest( pullRequest: | OctokitCommon.PullsGetResponseData @@ -283,23 +369,33 @@ export function convertRESTPullRequestToRawPullRequest( : undefined, createdAt: created_at, updatedAt: updated_at, + viewerCanUpdate: false, head: head.repo ? convertRESTHeadToIGitHubRef(head as OctokitCommon.PullsListResponseItemHead) : undefined, base: convertRESTHeadToIGitHubRef(base), - mergeable: (pullRequest as OctokitCommon.PullsGetResponseData).mergeable - ? PullRequestMergeability.Mergeable - : PullRequestMergeability.NotMergeable, labels: labels.map(l => ({ name: '', color: '', ...l })), isDraft: draft, suggestedReviewers: [], // suggested reviewers only available through GraphQL API + projectItems: [], // projects only available through GraphQL API + commits: [], // commits only available through GraphQL API + reactionCount: 0, // reaction count only available through GraphQL API + reactions: [], // reactions only available through GraphQL API + commentCount: 0 // comment count only available through GraphQL API }; + // mergeable is not included in the list response, will need to fetch later + if ((pullRequest as OctokitCommon.PullsGetResponseData).mergeable !== undefined) { + item.mergeable = (pullRequest as OctokitCommon.PullsGetResponseData).mergeable + ? PullRequestMergeability.Mergeable + : PullRequestMergeability.NotMergeable; + } + return item; } export function convertRESTIssueToRawPullRequest( pullRequest: OctokitCommon.IssuesCreateResponseData, githubRepository: GitHubRepository, -): PullRequest { +): Issue { const { number, body, @@ -313,9 +409,10 @@ export function convertRESTIssueToRawPullRequest( labels, node_id, id, + comments } = pullRequest; - const item: PullRequest = { + const item: Issue = { id, graphNodeId: node_id, number, @@ -331,9 +428,12 @@ export function convertRESTIssueToRawPullRequest( createdAt: created_at, updatedAt: updated_at, labels: labels.map(l => - typeof l === 'string' ? { name: l, color: '' } : { name: l.name ?? '', color: l.color ?? '' }, + typeof l === 'string' ? { name: l, color: '' } : { name: l.name ?? '', color: l.color ?? '', description: l.description ?? undefined }, ), - suggestedReviewers: [], // suggested reviewers only available through GraphQL API + projectItems: [], // projects only available through GraphQL API + reactionCount: 0, // reaction count only available through GraphQL API + reactions: [], // reactions only available through GraphQL API + commentCount: comments }; return item; @@ -346,7 +446,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, @@ -354,6 +454,7 @@ export function convertRESTReviewEvent( authorAssociation: review.user!.type, state: review.state as 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED' | 'PENDING', id: review.id, + reactions: undefined // reactions only available through GraphQL API }; } @@ -371,33 +472,6 @@ export function parseCommentDiffHunk(comment: IComment): DiffHunk[] { return diffHunks; } -export function convertPullRequestsGetCommentsResponseItemToComment( - comment: OctokitCommon.PullsCreateReviewCommentResponseData, - githubRepository: GitHubRepository, -): IComment { - const ret: IComment = { - url: comment.url, - id: comment.id, - pullRequestReviewId: comment.pull_request_review_id ?? undefined, - diffHunk: comment.diff_hunk, - path: comment.path, - position: comment.position, - commitId: comment.commit_id, - originalPosition: comment.original_position, - originalCommitId: comment.original_commit_id, - user: convertRESTUserToAccount(comment.user!, githubRepository), - body: comment.body, - createdAt: comment.created_at, - htmlUrl: comment.html_url, - inReplyToId: comment.in_reply_to_id, - graphNodeId: comment.node_id, - }; - - const diffHunks = parseCommentDiffHunk(ret); - ret.diffHunks = diffHunks; - return ret; -} - export function convertGraphQLEventType(text: string) { switch (text) { case 'PullRequestCommit': @@ -408,6 +482,8 @@ export function convertGraphQLEventType(text: string) { return Common.EventType.Milestoned; case 'AssignedEvent': return Common.EventType.Assigned; + case 'UnassignedEvent': + return Common.EventType.Unassigned; case 'HeadRefDeletedEvent': return Common.EventType.HeadRefDeleted; case 'IssueComment': @@ -416,7 +492,14 @@ export function convertGraphQLEventType(text: string) { return Common.EventType.Reviewed; case 'MergedEvent': return Common.EventType.Merged; - + case 'CrossReferencedEvent': + return Common.EventType.CrossReferenced; + case 'ClosedEvent': + return Common.EventType.Closed; + case 'ReopenedEvent': + return Common.EventType.Reopened; + case 'BaseRefChangedEvent': + return Common.EventType.BaseRefChanged; default: return Common.EventType.Other; } @@ -426,7 +509,7 @@ export function parseGraphQLReviewThread(thread: GraphQL.ReviewThread, githubRep return { id: thread.id, prReviewDatabaseId: thread.comments.edges && thread.comments.edges.length ? - thread.comments.edges[0].node.pullRequestReview.databaseId : + thread.comments.edges[0].node.pullRequestReview?.databaseId : undefined, isResolved: thread.isResolved, viewerCanResolve: thread.viewerCanResolve, @@ -438,15 +521,18 @@ export function parseGraphQLReviewThread(thread: GraphQL.ReviewThread, githubRep originalEndLine: thread.originalLine, diffSide: thread.diffSide, isOutdated: thread.isOutdated, - comments: thread.comments.nodes.map(comment => parseGraphQLComment(comment, thread.isResolved, githubRepository)), + comments: thread.comments.nodes.map(comment => parseGraphQLComment(comment, thread.isResolved, thread.isOutdated, githubRepository)), + subjectType: thread.subjectType ?? SubjectType.LINE }; } -export function parseGraphQLComment(comment: GraphQL.ReviewComment, isResolved: boolean, githubRepository: GitHubRepository): IComment { +export function parseGraphQLComment(comment: GraphQL.ReviewComment, isResolved: boolean, isOutdated: boolean, githubRepository: GitHubRepository): IComment { + const specialAuthor = COPILOT_ACCOUNTS[comment.author?.login ?? '']; const c: IComment = { id: comment.databaseId, url: comment.url, body: comment.body, + specialDisplayBodyPostfix: specialAuthor?.postComment, bodyHTML: comment.bodyHTML, path: comment.path, canEdit: comment.viewerCanDelete, @@ -457,7 +543,7 @@ export function parseGraphQLComment(comment: GraphQL.ReviewComment, isResolved: commitId: comment.commit.oid, originalPosition: comment.originalPosition, originalCommitId: comment.originalCommit && comment.originalCommit.oid, - user: comment.author ? parseAuthor(comment.author, githubRepository) : undefined, + user: comment.author ? parseAccount(comment.author, githubRepository) : undefined, createdAt: comment.createdAt, htmlUrl: comment.url, graphNodeId: comment.id, @@ -465,6 +551,7 @@ export function parseGraphQLComment(comment: GraphQL.ReviewComment, isResolved: inReplyToId: comment.replyTo && comment.replyTo.databaseId, reactions: parseGraphQLReaction(comment.reactionGroups), isResolved, + isOutdated }; const diffHunks = parseCommentDiffHunk(c); @@ -478,14 +565,16 @@ export function parseGraphQlIssueComment(comment: GraphQL.IssueComment, githubRe id: comment.databaseId, url: comment.url, body: comment.body, + specialDisplayBodyPostfix: COPILOT_ACCOUNTS[comment.author?.login ?? '']?.postComment, bodyHTML: comment.bodyHTML, canEdit: comment.viewerCanDelete, canDelete: comment.viewerCanDelete, - user: parseAuthor(comment.author, githubRepository), + user: parseAccount(comment.author, githubRepository), createdAt: comment.createdAt, htmlUrl: comment.url, graphNodeId: comment.id, diffHunk: '', + reactions: parseGraphQLReaction(comment.reactionGroups) }; } @@ -493,16 +582,17 @@ export function parseGraphQLReaction(reactionGroups: GraphQL.ReactionGroup[]): R const reactionContentEmojiMapping = getReactionGroup().reduce((prev, curr) => { prev[curr.title] = curr; return prev; - }, {} as { [key: string]: { title: string; label: string; icon?: vscode.Uri } }); + }, {} as { [key: string]: { title: string; label: string; icon?: string } }); const reactions = reactionGroups - .filter(group => group.users.totalCount > 0) + .filter(group => group.reactors.totalCount > 0) .map(group => { const reaction: Reaction = { label: reactionContentEmojiMapping[group.content].label, - count: group.users.totalCount, + count: group.reactors.totalCount, icon: reactionContentEmojiMapping[group.content].icon, viewerHasReacted: group.viewerHasReacted, + reactors: group.reactors.nodes.map(node => COPILOT_ACCOUNTS[node.login]?.name ?? node.login) }; return reaction; @@ -522,22 +612,117 @@ function parseRef(refName: string, oid: string, repository?: GraphQL.RefReposito sha: oid, repo: { cloneUrl: repository.url, + isInOrganization: repository.isInOrganization, owner: repository.owner.login, name: refName }, }; } -function parseAuthor( - author: { login: string; url: string; avatarUrl: string; email?: string } | null, - githubRepository: GitHubRepository, +export interface RestAccount { + login: string; + html_url: string; + avatar_url: string; + email?: string | null; + node_id: string; + name?: string | null; + type: string; +} + +export interface GraphQLAccount { + login: string; + url: string; + avatarUrl: string; + email?: string; + id: string; + name?: string; + __typename: string; +} + +export function parseAccount( + author: GraphQLAccount | RestAccount | null, + githubRepository?: GitHubRepository, ): IAccount { + if (author) { + let avatarUrl: string; + let id: string; + let url: string; + let accountType: string; + if ((author as RestAccount).html_url) { + const asRestAccount = author as RestAccount; + avatarUrl = asRestAccount.avatar_url; + id = asRestAccount.node_id; + url = asRestAccount.html_url; + accountType = asRestAccount.type; + } else { + const asGraphQLAccount = author as GraphQLAccount; + avatarUrl = asGraphQLAccount.avatarUrl; + id = asGraphQLAccount.id; + url = asGraphQLAccount.url; + accountType = asGraphQLAccount.__typename; + } + + // 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: finalAvatarUrl, + email: author.email ?? undefined, + id, + name: author.name ?? COPILOT_ACCOUNTS[author.login]?.name ?? undefined, + specialDisplayName: COPILOT_ACCOUNTS[author.login] ? (author.name ?? COPILOT_ACCOUNTS[author.login].name) : undefined, + accountType: toAccountType(accountType), + }; + } else { + return { + login: '', + url: '', + id: '', + accountType: AccountType.User + }; + } +} + +function parseTeam(team: GraphQL.Team, githubRepository: GitHubRepository): ITeam { + return { + name: team.name, + url: team.url, + avatarUrl: getAvatarWithEnterpriseFallback(team.avatarUrl, undefined, githubRepository.remote.isEnterprise), + id: team.id, + org: githubRepository.remote.owner, + slug: team.slug + }; +} + +export function parseGraphQLReviewers(data: GraphQL.GetReviewRequestsResponse, repository: GitHubRepository): (IAccount | ITeam)[] { + if (!data.repository) { + return []; + } + const reviewers: (IAccount | ITeam)[] = []; + for (const reviewer of data.repository.pullRequest.reviewRequests.nodes) { + if (GraphQL.isTeam(reviewer.requestedReviewer)) { + const team: ITeam = parseTeam(reviewer.requestedReviewer, repository); + reviewers.push(team); + } else if (GraphQL.isAccount(reviewer.requestedReviewer) || GraphQL.isBot(reviewer.requestedReviewer)) { + const account: IAccount = parseAccount(reviewer.requestedReviewer, repository); + reviewers.push(account); + } + } + return reviewers; +} + +function parseActor( + author: { login: string; url: string; avatarUrl: string; } | null, + githubRepository: GitHubRepository, +): IActor { if (author) { return { login: author.login, url: author.url, - avatarUrl: getAvatarWithEnterpriseFallback(author.avatarUrl, undefined, githubRepository.remote.authProviderId), - email: author.email + avatarUrl: getAvatarWithEnterpriseFallback(author.avatarUrl, undefined, githubRepository.remote.isEnterprise), }; } else { return { @@ -547,8 +732,20 @@ function parseAuthor( } } +export function parseProjectItems(projects: { id: string; project: { id: string; title: string; } }[] | undefined): IProjectItem[] | undefined { + if (!projects) { + return undefined; + } + return projects.map(project => { + return { + id: project.id, + project: project.project + }; + }); +} + export function parseMilestone( - milestone: { title: string; dueOn?: string; createdAt: string; id: string } | undefined, + milestone: { title: string; dueOn?: string; createdAt: string; id: string, number: number } | undefined, ): IMilestone | undefined { if (!milestone) { return undefined; @@ -558,10 +755,41 @@ export function parseMilestone( dueOn: milestone.dueOn, createdAt: milestone.createdAt, id: milestone.id, + number: milestone.number }; } -function parseMergeMethod(mergeMethod: 'MERGE' | 'SQUASH' | 'REBASE' | undefined): MergeMethod | undefined { +export function parseMergeQueueEntry(mergeQueueEntry: GraphQL.MergeQueueEntry | null | undefined): MergeQueueEntry | undefined | null { + if (!mergeQueueEntry) { + return null; + } + let state: MergeQueueState; + switch (mergeQueueEntry.state) { + case 'AWAITING_CHECKS': { + state = MergeQueueState.AwaitingChecks; + break; + } + case 'LOCKED': { + state = MergeQueueState.Locked; + break; + } + case 'QUEUED': { + state = MergeQueueState.Queued; + break; + } + case 'MERGEABLE': { + state = MergeQueueState.Mergeable; + break; + } + case 'UNMERGEABLE': { + state = MergeQueueState.Unmergeable; + break; + } + } + return { position: mergeQueueEntry.position, state, url: mergeQueueEntry.mergeQueue.url }; +} + +export function parseMergeMethod(mergeMethod: GraphQL.MergeMethod | undefined): MergeMethod | undefined { switch (mergeMethod) { case 'MERGE': return 'merge'; case 'REBASE': return 'rebase'; @@ -569,10 +797,11 @@ function parseMergeMethod(mergeMethod: 'MERGE' | 'SQUASH' | 'REBASE' | undefined } } -export function parseMergeability(mergeability: 'UNKNOWN' | 'MERGEABLE' | 'CONFLICTING', - mergeStateStatus: 'BEHIND' | 'BLOCKED' | 'CLEAN' | 'DIRTY' | 'HAS_HOOKS' | 'UNKNOWN' | 'UNSTABLE'): PullRequestMergeability { +export function parseMergeability(mergeability: 'UNKNOWN' | 'MERGEABLE' | 'CONFLICTING' | undefined, + mergeStateStatus: 'BEHIND' | 'BLOCKED' | 'CLEAN' | 'DIRTY' | 'HAS_HOOKS' | 'UNKNOWN' | 'UNSTABLE' | undefined): PullRequestMergeability { let parsed: PullRequestMergeability; switch (mergeability) { + case undefined: case 'UNKNOWN': parsed = PullRequestMergeability.Unknown; break; @@ -583,24 +812,28 @@ export function parseMergeability(mergeability: 'UNKNOWN' | 'MERGEABLE' | 'CONFL parsed = PullRequestMergeability.Conflict; break; } - if ((parsed !== PullRequestMergeability.Conflict) && (mergeStateStatus === 'BLOCKED')) { - parsed = PullRequestMergeability.NotMergeable; + if (parsed !== PullRequestMergeability.Conflict) { + if (mergeStateStatus === 'BLOCKED') { + parsed = PullRequestMergeability.NotMergeable; + } else if (mergeStateStatus === 'BEHIND') { + parsed = PullRequestMergeability.Behind; + } } return parsed; } -export function parseGraphQLPullRequest( +export async function parseGraphQLPullRequest( graphQLPullRequest: GraphQL.PullRequest, githubRepository: GitHubRepository, -): PullRequest { - return { +): Promise { + const pr: PullRequest = { id: graphQLPullRequest.databaseId, graphNodeId: graphQLPullRequest.id, url: graphQLPullRequest.url, number: graphQLPullRequest.number, state: graphQLPullRequest.state, body: graphQLPullRequest.body, - bodyHTML: graphQLPullRequest.bodyHTML, + bodyHTML: await transformHtmlUrlsToExtensionUrls(graphQLPullRequest.bodyHTML, githubRepository), title: graphQLPullRequest.title, titleHTML: graphQLPullRequest.titleHTML, createdAt: graphQLPullRequest.createdAt, @@ -609,19 +842,89 @@ export function parseGraphQLPullRequest( head: parseRef(graphQLPullRequest.headRef?.name ?? graphQLPullRequest.headRefName, graphQLPullRequest.headRefOid, graphQLPullRequest.headRepository), isRemoteBaseDeleted: !graphQLPullRequest.baseRef, base: parseRef(graphQLPullRequest.baseRef?.name ?? graphQLPullRequest.baseRefName, graphQLPullRequest.baseRefOid, graphQLPullRequest.baseRepository), - user: parseAuthor(graphQLPullRequest.author, githubRepository), + user: parseAccount(graphQLPullRequest.author, githubRepository), merged: graphQLPullRequest.merged, mergeable: parseMergeability(graphQLPullRequest.mergeable, graphQLPullRequest.mergeStateStatus), + mergeQueueEntry: parseMergeQueueEntry(graphQLPullRequest.mergeQueueEntry), + hasComments: graphQLPullRequest.reviewThreads.totalCount > 0, autoMerge: !!graphQLPullRequest.autoMergeRequest, autoMergeMethod: parseMergeMethod(graphQLPullRequest.autoMergeRequest?.mergeMethod), allowAutoMerge: graphQLPullRequest.viewerCanEnableAutoMerge || graphQLPullRequest.viewerCanDisableAutoMerge, + viewerCanUpdate: graphQLPullRequest.viewerCanUpdate, labels: graphQLPullRequest.labels.nodes, isDraft: graphQLPullRequest.isDraft, suggestedReviewers: parseSuggestedReviewers(graphQLPullRequest.suggestedReviewers), comments: parseComments(graphQLPullRequest.comments?.nodes, githubRepository), + projectItems: parseProjectItems(graphQLPullRequest.projectItems?.nodes), milestone: parseMilestone(graphQLPullRequest.milestone), - assignees: graphQLPullRequest.assignees?.nodes.map(assignee => parseAuthor(assignee, githubRepository)), + assignees: graphQLPullRequest.assignees?.nodes.map(assignee => parseAccount(assignee, githubRepository)), + commits: parseCommits(graphQLPullRequest.commits.nodes), + reactionCount: graphQLPullRequest.reactions.totalCount, + reactions: parseGraphQLReaction(graphQLPullRequest.reactionGroups), + commentCount: graphQLPullRequest.comments.totalCount, + additions: graphQLPullRequest.additions, + deletions: graphQLPullRequest.deletions, }; + pr.mergeCommitMeta = parseCommitMeta(graphQLPullRequest.baseRepository.mergeCommitTitle, graphQLPullRequest.baseRepository.mergeCommitMessage, pr); + pr.squashCommitMeta = parseCommitMeta(graphQLPullRequest.baseRepository.squashMergeCommitTitle, graphQLPullRequest.baseRepository.squashMergeCommitMessage, pr); + return pr; +} + +function parseCommitMeta(titleSource: GraphQL.DefaultCommitTitle | undefined, descriptionSource: GraphQL.DefaultCommitMessage | undefined, pullRequest: PullRequest): { title: string, description: string } | undefined { + if (titleSource === undefined || descriptionSource === undefined) { + return undefined; + } + + let title = ''; + let description = ''; + const prNumberPostfix = `(#${pullRequest.number})`; + + switch (titleSource) { + case GraphQL.DefaultCommitTitle.prTitle: { + title = `${pullRequest.title} ${prNumberPostfix}`; + break; + } + case GraphQL.DefaultCommitTitle.mergeMessage: { + title = `Merge pull request #${pullRequest.number} from ${pullRequest.head?.label ?? ''}`; + break; + } + case GraphQL.DefaultCommitTitle.commitOrPrTitle: { + if (pullRequest.commits.length === 1) { + title = `${pullRequest.commits[0].message.split('\n')[0]} ${prNumberPostfix}`; + } else { + title = `${pullRequest.title} ${prNumberPostfix}`; + } + break; + } + } + switch (descriptionSource) { + case GraphQL.DefaultCommitMessage.prBody: { + description = pullRequest.body; + break; + } + case GraphQL.DefaultCommitMessage.commitMessages: { + if ((pullRequest.commits.length === 1) && (titleSource === GraphQL.DefaultCommitTitle.commitOrPrTitle)) { + const split = pullRequest.commits[0].message.split('\n'); + description = split.length > 1 ? split.slice(1).join('\n').trim() : ''; + } else { + description = pullRequest.commits.map(commit => `* ${commit.message}`).join('\n\n'); + } + break; + } + case GraphQL.DefaultCommitMessage.prTitle: { + description = pullRequest.title; + break; + } + } + return { title, description }; +} + +function parseCommits(commits: { commit: { message: string; }; }[]): { message: string; }[] { + return commits.map(commit => { + return { + message: commit.commit.message + }; + }); } function parseComments(comments: GraphQL.AbbreviatedIssueComment[] | undefined, githubRepository: GitHubRepository) { @@ -632,37 +935,58 @@ function parseComments(comments: GraphQL.AbbreviatedIssueComment[] | undefined, author: IAccount; body: string; databaseId: number; + reactionCount: number; + createdAt: string; }[] = []; for (const comment of comments) { parsedComments.push({ - author: parseAuthor(comment.author, githubRepository), + author: parseAccount(comment.author, githubRepository), body: comment.body, databaseId: comment.databaseId, + reactionCount: comment.reactions.totalCount, + createdAt: comment.createdAt }); } return parsedComments; } -export function parseGraphQLIssue(issue: GraphQL.PullRequest, githubRepository: GitHubRepository): Issue { +export async function parseGraphQLIssue(issue: GraphQL.Issue, githubRepository: GitHubRepository): Promise { return { id: issue.databaseId, graphNodeId: issue.id, url: issue.url, number: issue.number, state: issue.state, + stateReason: issue.stateReason, body: issue.body, - bodyHTML: issue.bodyHTML, + bodyHTML: await transformHtmlUrlsToExtensionUrls(issue.bodyHTML, githubRepository), title: issue.title, titleHTML: issue.titleHTML, createdAt: issue.createdAt, updatedAt: issue.updatedAt, - assignees: issue.assignees?.nodes.map(assignee => parseAuthor(assignee, githubRepository)), - user: parseAuthor(issue.author, githubRepository), + assignees: issue.assignees?.nodes.map(assignee => parseAccount(assignee, githubRepository)), + user: parseAccount(issue.author, githubRepository), labels: issue.labels.nodes, - repositoryName: issue.repository?.name, - repositoryOwner: issue.repository?.owner.login, - repositoryUrl: issue.repository?.url, + milestone: parseMilestone(issue.milestone), + repositoryName: issue.repository?.name ?? githubRepository.remote.repositoryName, + repositoryOwner: issue.repository?.owner.login ?? githubRepository.remote.owner, + repositoryUrl: issue.repository?.url ?? githubRepository.remote.url, + projectItems: parseProjectItems(issue.projectItems?.nodes), + comments: issue.comments.nodes?.map(comment => parseIssueComment(comment, githubRepository)), + reactionCount: issue.reactions.totalCount, + reactions: parseGraphQLReaction(issue.reactionGroups), + commentCount: issue.comments.totalCount + }; +} + +function parseIssueComment(comment: GraphQL.AbbreviatedIssueComment, githubRepository: GitHubRepository): IIssueComment { + return { + author: parseAccount(comment.author, githubRepository), + body: comment.body, + databaseId: comment.databaseId, + reactionCount: comment.reactions.totalCount, + createdAt: comment.createdAt, }; } @@ -673,13 +997,11 @@ function parseSuggestedReviewers( return []; } const ret: ISuggestedReviewer[] = suggestedReviewers.map(suggestedReviewer => { + const account = parseAccount(suggestedReviewer.reviewer, undefined); return { - login: suggestedReviewer.reviewer.login, - avatarUrl: suggestedReviewer.reviewer.avatarUrl, - name: suggestedReviewer.reviewer.name, - url: suggestedReviewer.reviewer.url, + ...account, isAuthor: suggestedReviewer.isAuthor, - isCommenter: suggestedReviewer.isCommenter, + isCommenter: suggestedReviewer.isCommenter }; }); @@ -693,6 +1015,15 @@ export function loginComparator(a: IAccount, b: IAccount) { // sensitivity: 'accent' allows case insensitive comparison return a.login.localeCompare(b.login, 'en', { sensitivity: 'accent' }); } +/** + * Used for case insensitive sort by team name + */ +export function teamComparator(a: ITeam, b: ITeam) { + const aKey = a.name ?? a.slug ?? a.id; + const bKey = b.name ?? b.slug ?? b.id; + // sensitivity: 'accent' allows case insensitive comparison + return aKey.localeCompare(bKey, 'en', { sensitivity: 'accent' }); +} export function parseGraphQLReviewEvent( review: GraphQL.SubmittedReview, @@ -700,19 +1031,105 @@ export function parseGraphQLReviewEvent( ): Common.ReviewEvent { return { event: Common.EventType.Reviewed, - comments: review.comments.nodes.map(comment => parseGraphQLComment(comment, false, githubRepository)).filter(c => !c.inReplyToId), + comments: review.comments.nodes.map(comment => parseGraphQLComment(comment, false, false, githubRepository)).filter(c => !c.inReplyToId), submittedAt: review.submittedAt, body: review.body, bodyHTML: review.bodyHTML, htmlUrl: review.url, - user: parseAuthor(review.author, githubRepository), + user: parseAccount(review.author, githubRepository), authorAssociation: review.authorAssociation, state: review.state, id: review.databaseId, + reactions: parseGraphQLReaction(review.reactionGroups), + }; +} + +export function parseSelectRestTimelineEvents( + issueModel: IssueModel, + events: OctokitCommon.ListEventsForTimelineResponse[] +): Common.TimelineEvent[] { + const parsedEvents: Common.TimelineEvent[] = []; + + const prSessionLink: Common.SessionPullInfo = { + id: issueModel.id, + host: issueModel.githubRepository.remote.gitProtocol.host, + owner: issueModel.githubRepository.remote.owner, + repo: issueModel.githubRepository.remote.repositoryName, + pullNumber: issueModel.number, }; + + let sessionIndex = 0; + for (const event of events) { + const eventNode = event as { created_at?: string; node_id?: string; actor: RestAccount, performed_via_github_app?: { slug: string } | null }; + if (eventNode.created_at && eventNode.node_id) { + if (event.event === 'copilot_work_started' && eventNode.performed_via_github_app?.slug === COPILOT_SWE_AGENT) { + parsedEvents.push({ + id: eventNode.node_id, + event: Common.EventType.CopilotStarted, + createdAt: eventNode.created_at, + onBehalfOf: parseAccount(eventNode.actor), + sessionLink: { + ...prSessionLink, + sessionIndex + } + }); + } else if (event.event === 'copilot_work_finished' && eventNode.performed_via_github_app?.slug === COPILOT_SWE_AGENT) { + parsedEvents.push({ + id: eventNode.node_id, + event: Common.EventType.CopilotFinished, + createdAt: eventNode.created_at, + onBehalfOf: parseAccount(eventNode.actor) + }); + sessionIndex++; + } else if (event.event === 'copilot_work_finished_failure') { + sessionIndex++; + parsedEvents.push({ + id: eventNode.node_id, + event: Common.EventType.CopilotFinishedError, + createdAt: eventNode.created_at, + onBehalfOf: parseAccount(eventNode.actor), + sessionLink: { + ...prSessionLink, + sessionIndex + } + }); + } else if (event.event === 'copilot_work_started' && eventNode.performed_via_github_app?.slug === COPILOT_REVIEWER) { + parsedEvents.push({ + id: eventNode.node_id, + event: Common.EventType.CopilotReviewStarted, + createdAt: eventNode.created_at, + }); + } + } + } + + return parsedEvents; } -export function parseGraphQLTimelineEvents( +export function eventTime(event: Common.TimelineEvent): Date | undefined { + switch (event.event) { + case Common.EventType.Committed: + return new Date(event.committedDate); + case Common.EventType.Commented: + case Common.EventType.Assigned: + case Common.EventType.HeadRefDeleted: + case Common.EventType.Merged: + case Common.EventType.CrossReferenced: + case Common.EventType.Closed: + case Common.EventType.Reopened: + case Common.EventType.CopilotStarted: + case Common.EventType.CopilotFinished: + case Common.EventType.CopilotFinishedError: + case Common.EventType.CopilotReviewStarted: + return new Date(event.createdAt); + case Common.EventType.Reviewed: + return new Date(event.submittedAt); + default: + return undefined; + } +} + +export async function parseCombinedTimelineEvents( events: ( | GraphQL.MergedEvent | GraphQL.Review @@ -720,65 +1137,98 @@ export function parseGraphQLTimelineEvents( | GraphQL.Commit | GraphQL.AssignedEvent | GraphQL.HeadRefDeletedEvent + | GraphQL.CrossReferencedEvent + | GraphQL.BaseRefChangedEvent + | null )[], + restEvents: Common.TimelineEvent[], githubRepository: GitHubRepository, -): Common.TimelineEvent[] { +): Promise { const normalizedEvents: Common.TimelineEvent[] = []; - events.forEach(event => { + let restEventIndex = -1; + let restEventTime: number | undefined; + const incrementRestEvent = () => { + restEventIndex++; + restEventTime = restEvents.length > restEventIndex ? eventTime(restEvents[restEventIndex])?.getTime() : undefined; + }; + incrementRestEvent(); + const addTimelineEvent = (event: Common.TimelineEvent) => { + if (!restEventTime) { + normalizedEvents.push(event); + return; + } + const newEventTime = eventTime(event)?.getTime(); + if (newEventTime) { + while (restEventTime && newEventTime > restEventTime) { + normalizedEvents.push(restEvents[restEventIndex]); + incrementRestEvent(); + } + } + normalizedEvents.push(event); + }; + + // 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) { case Common.EventType.Commented: const commentEvent = event as GraphQL.IssueComment; - normalizedEvents.push({ + addTimelineEvent({ htmlUrl: commentEvent.url, body: commentEvent.body, bodyHTML: commentEvent.bodyHTML, - user: parseAuthor(commentEvent.author, githubRepository), + user: parseAccount(commentEvent.author, githubRepository), event: type, canEdit: commentEvent.viewerCanUpdate, canDelete: commentEvent.viewerCanDelete, id: commentEvent.databaseId, graphNodeId: commentEvent.id, createdAt: commentEvent.createdAt, + reactions: parseGraphQLReaction(commentEvent.reactionGroups), }); - return; + break; case Common.EventType.Reviewed: const reviewEvent = event as GraphQL.Review; - normalizedEvents.push({ + addTimelineEvent({ event: type, comments: [], submittedAt: reviewEvent.submittedAt, body: reviewEvent.body, bodyHTML: reviewEvent.bodyHTML, htmlUrl: reviewEvent.url, - user: parseAuthor(reviewEvent.author, githubRepository), + user: parseAccount(reviewEvent.author, githubRepository), authorAssociation: reviewEvent.authorAssociation, state: reviewEvent.state, id: reviewEvent.databaseId, + reactions: parseGraphQLReaction(reviewEvent.reactionGroups), }); - return; + break; case Common.EventType.Committed: const commitEv = event as GraphQL.Commit; - normalizedEvents.push({ + addTimelineEvent({ id: commitEv.id, event: type, sha: commitEv.commit.oid, author: commitEv.commit.author.user - ? parseAuthor(commitEv.commit.author.user, githubRepository) + ? parseAccount(commitEv.commit.author.user, githubRepository) : { login: commitEv.commit.committer.name }, htmlUrl: commitEv.url, message: commitEv.commit.message, - authoredDate: new Date(commitEv.commit.authoredDate), + committedDate: new Date(commitEv.commit.committedDate), + status: commitEv.commit.statusCheckRollup?.state } as Common.CommitEvent); // TODO remove cast - return; + break; case Common.EventType.Merged: const mergeEv = event as GraphQL.MergedEvent; - normalizedEvents.push({ + addTimelineEvent({ id: mergeEv.id, event: type, - user: parseAuthor(mergeEv.actor, githubRepository), + user: parseActor(mergeEv.actor, githubRepository), createdAt: mergeEv.createdAt, mergeRef: mergeEv.mergeRef.name, sha: mergeEv.commit.oid, @@ -786,33 +1236,108 @@ export function parseGraphQLTimelineEvents( url: mergeEv.url, graphNodeId: mergeEv.id, }); - return; + break; case Common.EventType.Assigned: const assignEv = event as GraphQL.AssignedEvent; - normalizedEvents.push({ + addTimelineEvent({ id: assignEv.id, event: type, - user: parseAuthor(assignEv.user, githubRepository), - actor: assignEv.actor, + assignees: [parseAccount(assignEv.user, githubRepository)], + actor: parseAccount(assignEv.actor), + createdAt: assignEv.createdAt, + }); + break; + case Common.EventType.Unassigned: + const unassignEv = event as GraphQL.UnassignedEvent; + + normalizedEvents.push({ + id: unassignEv.id, + event: type, + unassignees: [parseAccount(unassignEv.user, githubRepository)], + actor: parseAccount(unassignEv.actor), + createdAt: unassignEv.createdAt, }); - return; + break; case Common.EventType.HeadRefDeleted: const deletedEv = event as GraphQL.HeadRefDeletedEvent; - normalizedEvents.push({ + addTimelineEvent({ id: deletedEv.id, event: type, - actor: parseAuthor(deletedEv.actor, githubRepository), + actor: parseAccount(deletedEv.actor, githubRepository), createdAt: deletedEv.createdAt, headRef: deletedEv.headRefName, }); - return; + break; + case Common.EventType.CrossReferenced: + const crossRefEv = event as GraphQL.CrossReferencedEvent; + if (!crossRefEv.source) { + break; + } + const isIssue = crossRefEv.source.__typename === 'Issue'; + const extensionUrl = isIssue + ? await toOpenIssueWebviewUri({ owner: crossRefEv.source.repository.owner.login, repo: crossRefEv.source.repository.name, issueNumber: crossRefEv.source.number }) + : await toOpenPullRequestWebviewUri({ owner: crossRefEv.source.repository.owner.login, repo: crossRefEv.source.repository.name, pullRequestNumber: crossRefEv.source.number }); + addTimelineEvent({ + id: crossRefEv.id, + event: type, + actor: parseAccount(crossRefEv.actor, githubRepository), + createdAt: crossRefEv.createdAt, + source: { + url: crossRefEv.source.url, + extensionUrl: extensionUrl.toString(), + number: crossRefEv.source.number, + title: crossRefEv.source.title, + isIssue, + owner: crossRefEv.source.repository.owner.login, + repo: crossRefEv.source.repository.name, + }, + willCloseTarget: crossRefEv.willCloseTarget + }); + break; + case Common.EventType.Closed: + const closedEv = event as GraphQL.ClosedEvent; + + addTimelineEvent({ + id: closedEv.id, + event: type, + actor: parseAccount(closedEv.actor, githubRepository), + createdAt: closedEv.createdAt, + }); + break; + case Common.EventType.Reopened: + const reopenedEv = event as GraphQL.ReopenedEvent; + + addTimelineEvent({ + id: reopenedEv.id, + event: type, + actor: parseAccount(reopenedEv.actor, githubRepository), + createdAt: reopenedEv.createdAt, + }); + break; + case Common.EventType.BaseRefChanged: + const baseRefChangedEv = event as GraphQL.BaseRefChangedEvent; + + addTimelineEvent({ + id: baseRefChangedEv.id, + event: type, + actor: parseAccount(baseRefChangedEv.actor, githubRepository), + createdAt: baseRefChangedEv.createdAt, + currentRefName: baseRefChangedEv.currentRefName, + previousRefName: baseRefChangedEv.previousRefName, + }); + break; default: break; } - }); + } + // Add any remaining rest events + while (restEventTime) { + normalizedEvents.push(restEvents[restEventIndex]); + incrementRestEvent(); + } return normalizedEvents; } @@ -820,12 +1345,14 @@ export function parseGraphQLUser(user: GraphQL.UserResponse, githubRepository: G return { login: user.user.login, name: user.user.name, - avatarUrl: getAvatarWithEnterpriseFallback(user.user.avatarUrl ?? '', undefined, githubRepository.remote.authProviderId), + avatarUrl: getAvatarWithEnterpriseFallback(user.user.avatarUrl ?? '', undefined, githubRepository.remote.isEnterprise), url: user.user.url, bio: user.user.bio, company: user.user.company, location: user.user.location, commitContributions: parseGraphQLCommitContributions(user.user.contributionsCollection), + id: user.user.id, + accountType: toAccountType(user.user.__typename) }; } @@ -844,62 +1371,54 @@ function parseGraphQLCommitContributions( return items; } -export function getReactionGroup(): { title: string; label: string; icon?: vscode.Uri }[] { +export function getReactionGroup(): { title: string; label: string; icon?: string }[] { const ret = [ { 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: '👀' }, ]; return ret; } -export async function restPaginate(request: R, variables: Parameters[0]): Promise { +export async function restPaginate(request: R, variables: Parameters[0], per_page: number = 100): Promise { let page = 1; let results: T[] = []; let hasNextPage = false; @@ -907,8 +1426,9 @@ export async function restPaginate(r do { const result = await request( { + // eslint-disable-next-line rulesdir/no-cast-to-any ...(variables as any), - per_page: 100, + per_page, page } ); @@ -944,7 +1464,7 @@ export function getRelatedUsersFromTimelineEvents( }); } - if (event.event === Common.EventType.Commented) { + if ((event.event === Common.EventType.Commented) && event.user) { ret.push({ login: event.user.login, name: event.user.name ?? event.user.login, @@ -958,7 +1478,7 @@ export function getRelatedUsersFromTimelineEvents( export function parseGraphQLViewerPermission( viewerPermissionResponse: GraphQL.ViewerPermissionResponse, ): ViewerPermission { - if (viewerPermissionResponse && viewerPermissionResponse.repository.viewerPermission) { + if (viewerPermissionResponse && viewerPermissionResponse.repository?.viewerPermission) { if ( (Object.values(ViewerPermission) as string[]).includes(viewerPermissionResponse.repository.viewerPermission) ) { @@ -975,11 +1495,16 @@ export function isFileInRepo(repository: Repository, file: vscode.Uri): boolean } export function getRepositoryForFile(gitAPI: GitApiImpl, file: vscode.Uri): Repository | undefined { - for (const repository of gitAPI.repositories) { + const foundRepos: Repository[] = []; + for (const repository of gitAPI.repositories.reverse()) { if (isFileInRepo(repository, file)) { - return repository; + foundRepos.push(repository); } } + if (foundRepos.length > 0) { + foundRepos.sort((a, b) => b.rootUri.path.length - a.rootUri.path.length); + return foundRepos[0]; + } return undefined; } @@ -993,7 +1518,7 @@ export function getRepositoryForFile(gitAPI: GitApiImpl, file: vscode.Uri): Repo * @param author The author of the pull request */ export function parseReviewers( - requestedReviewers: IAccount[], + requestedReviewers: (IAccount | ITeam)[], timelineEvents: Common.TimelineEvent[], author: IAccount, ): ReviewState[] { @@ -1005,24 +1530,25 @@ export function parseReviewers( seen.set(author.login, true); for (let i = reviewEvents.length - 1; i >= 0; i--) { - const reviewer = reviewEvents[i].user; - if (!seen.get(reviewer.login)) { + const reviewEvent = reviewEvents[i]; + const reviewer = reviewEvent.user; + if (reviewEvent.state && !seen.get(reviewer.login)) { seen.set(reviewer.login, true); reviewers.push({ reviewer: reviewer, - state: reviewEvents[i].state, + state: reviewEvent.state, }); } } requestedReviewers.forEach(request => { - if (!seen.get(request.login)) { + if (!seen.get(reviewerId(request))) { reviewers.push({ reviewer: request, state: 'REQUESTED', }); } else { - const reviewer = reviewers.find(r => r.reviewer.login === request.login); + const reviewer = reviewers.find(r => reviewerId(r.reviewer) === reviewerId(request)); reviewer!.state = 'REQUESTED'; } }); @@ -1037,12 +1563,42 @@ export function parseReviewers( return -1; } - return a.reviewer.login.toLowerCase() < b.reviewer.login.toLowerCase() ? -1 : 1; + return reviewerLabel(a.reviewer).toLowerCase() < reviewerLabel(b.reviewer).toLowerCase() ? -1 : 1; }); return reviewers; } +export function parseNotification(notification: OctokitCommon.Notification): Notification | undefined { + if (!notification.subject.url) { + return undefined; + } + const owner = notification.repository.owner.login; + const name = notification.repository.name; + const itemID = notification.subject.url.split('/').pop(); + + return { + owner, + name, + key: getNotificationKey(owner, name, itemID!), + id: notification.id, + itemID: itemID!, + subject: { + title: notification.subject.title, + type: notification.subject.type as NotificationSubjectType, + url: notification.subject.url + }, + lastReadAt: notification.last_read_at ? new Date(notification.last_read_at) : undefined, + reason: notification.reason, + unread: notification.unread, + updatedAt: new Date(notification.updated_at), + }; +} + +export function getNotificationKey(owner: string, name: string, itemID: string): string { + return `${owner}/${name}#${itemID}`; +} + export function insertNewCommitsSinceReview( timelineEvents: Common.TimelineEvent[], latestReviewCommitOid: string | undefined, @@ -1082,9 +1638,9 @@ export function insertNewCommitsSinceReview( } } -export function getPRFetchQuery(repo: string, user: string, query: string): string { +export function getPRFetchQuery(user: string, query: string): string { const filter = query.replace(/\$\{user\}/g, user); - return `is:pull-request ${filter} type:pr repo:${repo}`; + return `is:pull-request ${filter} type:pr`; } export function isInCodespaces(): boolean { @@ -1092,11 +1648,11 @@ export function isInCodespaces(): boolean { } export async function setEnterpriseUri(host: string) { - return vscode.workspace.getConfiguration('github-enterprise').update('uri', host, vscode.ConfigurationTarget.Workspace); + return vscode.workspace.getConfiguration(GITHUB_ENTERPRISE).update(URI, host, vscode.ConfigurationTarget.Workspace); } export function getEnterpriseUri(): vscode.Uri | undefined { - const config: string = vscode.workspace.getConfiguration('github-enterprise').get('uri', ''); + const config: string = vscode.workspace.getConfiguration(GITHUB_ENTERPRISE).get(URI, ''); if (config) { let uri = vscode.Uri.parse(config, true); if (uri.scheme === 'http') { @@ -1114,9 +1670,22 @@ export function generateGravatarUrl(gravatarId: string | undefined, size: number return !!gravatarId ? `https://www.gravatar.com/avatar/${gravatarId}?s=${size}&d=retro` : undefined; } -export function getAvatarWithEnterpriseFallback(avatarUrl: string, email: string | undefined, authProviderId: AuthProvider): string | undefined { - return authProviderId === AuthProvider.github ? avatarUrl : (email ? generateGravatarUrl( - crypto.createHash('md5').update(email?.trim()?.toLowerCase()).digest('hex')) : undefined); +export function getAvatarWithEnterpriseFallback(avatarUrl: string, email: string | undefined, isEnterpriseRemote: boolean): string | 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) { @@ -1128,40 +1697,76 @@ export function getIssuesUrl(repo: GitHubRepository) { } export function sanitizeIssueTitle(title: string): string { - const regex = /[~^:;'".,~#?%*[\]@\\{}()]|\/\//g; + const regex = /[~^:;'".,~#?%*&[\]@\\{}()/]|\/\//g; return title.replace(regex, '').trim().substring(0, 150).replace(/\s+/g, '-'); } -const VARIABLE_PATTERN = /\$\{(.*?)\}/g; -export async function variableSubstitution( +const SINCE_VALUE_PATTERN = /-([0-9]+)([d])/; +function computeSinceValue(sinceValue: string | undefined): string { + const match = sinceValue ? SINCE_VALUE_PATTERN.exec(sinceValue) : undefined; + const date = new Date(); + if (match && match.length === 3 && match[2] === 'd') { + const dateOffset = parseInt(match[1]) * (24 * 60 * 60 * 1000); + date.setTime(date.getTime() - dateOffset); + } + const month = `${date.getMonth() + 1}`; + const day = `${date.getDate()}`; + return `${date.getFullYear()}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`; +} + +const COPILOT_PATTERN = /\:(Copilot|copilot)(\s|$)/g; + +const VARIABLE_PATTERN = /\$\{([^-]*?)(-.*?)?\}/g; +export function variableSubstitution( value: string, issueModel?: IssueModel, defaults?: PullRequestDefaults, user?: string, -): Promise { - return value.replace(VARIABLE_PATTERN, (match: string, variable: string) => { +): string { + const withVariables = value.replace(VARIABLE_PATTERN, (match: string, variable: string, extra: string) => { + let result: string; switch (variable) { case 'user': - return user ? user : match; + result = user ? user : match; + break; case 'issueNumber': - return issueModel ? `${issueModel.number}` : match; + result = issueModel ? `${issueModel.number}` : match; + break; case 'issueNumberLabel': - return issueModel ? `${getIssueNumberLabel(issueModel, defaults)}` : match; + result = issueModel ? `${getIssueNumberLabel(issueModel, defaults)}` : match; + break; case 'issueTitle': - return issueModel ? issueModel.title : match; + result = issueModel ? issueModel.title : match; + break; case 'repository': - return defaults ? defaults.repo : match; + result = defaults ? defaults.repo : match; + break; case 'owner': - return defaults ? defaults.owner : match; + result = defaults ? defaults.owner : match; + break; case 'sanitizedIssueTitle': - return issueModel ? sanitizeIssueTitle(issueModel.title) : match; // check what characters are permitted + result = issueModel ? sanitizeIssueTitle(issueModel.title) : match; // check what characters are permitted + break; case 'sanitizedLowercaseIssueTitle': - return issueModel ? sanitizeIssueTitle(issueModel.title).toLowerCase() : match; + result = issueModel ? sanitizeIssueTitle(issueModel.title).toLowerCase() : match; + break; + case 'today': + result = computeSinceValue(extra); + break; default: - return match; + result = match; + break; } + Logger.debug(`${match} -> ${result}`, 'VariableSubstitution'); + return result; + }); + + // not a variable, but still a substitution that needs to be done + const withCopilot = withVariables.replace(COPILOT_PATTERN, () => { + return `:${COPILOT_SWE_AGENT}[bot] `; }); + return withCopilot; } export function getIssueNumberLabel(issue: IssueModel, repo?: PullRequestDefaults) { @@ -1186,7 +1791,7 @@ export function getIssueNumberLabelFromParsed(parsed: ParsedIssue) { } export function getOverrideBranch(): string | undefined { - const overrideSetting = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get(OVERRIDE_DEFAULT_BRANCH); + const overrideSetting = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(OVERRIDE_DEFAULT_BRANCH); if (overrideSetting) { Logger.debug('Using override setting for default branch', GitHubRepository.ID); return overrideSetting; @@ -1211,7 +1816,47 @@ export async function findDotComAndEnterpriseRemotes(folderManagers: FolderRepos return { dotComRemotes, enterpriseRemotes, unknownRemotes }; } -export function vscodeDevPrLink(pullRequest: PullRequestModel) { +export function vscodeDevPrLink(pullRequest: IssueModel) { const itemUri = vscode.Uri.parse(pullRequest.html_url); - return `https://vscode.dev/github${itemUri.path}`; + return `https://${vscode.env.appName.toLowerCase().includes('insider') ? 'insiders.' : ''}vscode.dev/github${itemUri.path}`; +} + +export function codespacesPrLink(pullRequest: PullRequestModel): string { + const repoFullName = `${pullRequest.head!.owner}/${pullRequest.remote.repositoryName}`; + const branch = pullRequest.head!.ref; + return `https://github.com/codespaces/new/${encodeURIComponent(repoFullName)}/tree/${encodeURIComponent(branch)}`; +} + +export function makeLabel(label: ILabel): string { + const isDarkTheme = vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark; + const labelColor = gitHubLabelColor(label.color, isDarkTheme, true); + const labelName = emojify(label.name.trim()); + return `  ${labelName}  `; +} + + +export enum UnsatisfiedChecks { + None = 0, + ReviewRequired = 1 << 0, + ChangesRequested = 1 << 1, + CIFailed = 1 << 2, + CIPending = 1 << 3 +} + +export async function extractRepoFromQuery(folderManager: FolderRepositoryManager, query: string | undefined): Promise { + if (!query) { + return undefined; + } + + const defaults = await folderManager.getPullRequestDefaults(); + // Use a fake user since we only care about pulling out the repo and repo owner + const substituted = variableSubstitution(query, undefined, defaults, 'fakeUser'); + + const repoRegex = /(?:^|\s)repo:(?:"?(?[A-Za-z0-9_.-]+)\/(?[A-Za-z0-9_.-]+)"?)/i; + const repoMatch = repoRegex.exec(substituted); + if (repoMatch && repoMatch.groups) { + return { owner: repoMatch.groups.owner, repositoryName: repoMatch.groups.repo }; + } + + return undefined; } \ No newline at end of file diff --git a/src/github/views.ts b/src/github/views.ts new file mode 100644 index 0000000000..beae02bebf --- /dev/null +++ b/src/github/views.ts @@ -0,0 +1,206 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + GithubItemStateEnum, + IAccount, + ILabel, + IMilestone, + IProjectItem, + MergeMethod, + MergeMethodsAvailability, + MergeQueueState, + PullRequestChecks, + PullRequestMergeability, + PullRequestReviewRequirement, + Reaction, + ReviewState, + StateReason, +} from './interface'; +import { IComment } from '../common/comment'; +import { CommentEvent, ReviewEvent, SessionLinkInfo, TimelineEvent } from '../common/timelineEvent'; + +export enum ReviewType { + Comment = 'comment', + Approve = 'approve', + RequestChanges = 'requestChanges', +} + +export interface DisplayLabel extends ILabel { + displayName: string; +} + +export interface Issue { + owner: string; + repo: string; + number: number; + title: string; + titleHTML: string; + url: string; + createdAt: string; + body: string; + bodyHTML?: string; + author: IAccount; + state: GithubItemStateEnum; // TODO: don't allow merged + stateReason?: StateReason; + events: TimelineEvent[]; + labels: DisplayLabel[]; + assignees: IAccount[]; + projectItems: IProjectItem[] | undefined; + milestone: IMilestone | undefined; + /** + * User can edit PR title and description (author or user with push access) + */ + canEdit: boolean; + /** + * Users with push access to repo have rights to merge/close PRs, + * edit title/description, assign reviewers/labels etc. + */ + hasWritePermission: boolean; + pendingCommentText?: string; + pendingCommentDrafts?: { [key: string]: string }; + isIssue: boolean; + isAuthor: boolean; + continueOnGitHub: boolean; + isDarkTheme: boolean; + isEnterprise: boolean; + canAssignCopilot: boolean; + canRequestCopilotReview: boolean; + reactions: Reaction[]; + busy?: boolean; +} + +export interface PullRequest extends Issue { + isCopilotOnMyBehalf: boolean; + isCurrentlyCheckedOut: boolean; + isRemoteBaseDeleted?: boolean; + base: string; + isRemoteHeadDeleted?: boolean; + isLocalHeadDeleted?: boolean; + head: string; + commitsCount: number; + projectItems: IProjectItem[] | undefined; + repositoryDefaultBranch: string; + doneCheckoutBranch: string; + emailForCommit?: string; + pendingReviewType?: ReviewType; + status: PullRequestChecks | null; + reviewRequirement: PullRequestReviewRequirement | null; + canUpdateBranch: boolean; + mergeable: PullRequestMergeability; + defaultMergeMethod: MergeMethod; + mergeMethodsAvailability: MergeMethodsAvailability; + autoMerge?: boolean; + allowAutoMerge: boolean; + autoMergeMethod?: MergeMethod; + mergeQueueMethod: MergeMethod | undefined; + mergeQueueEntry?: { + url: string; + position: number; + state: MergeQueueState; + }; + mergeCommitMeta?: { title: string, description: string }; + squashCommitMeta?: { title: string, description: string }; + reviewers: ReviewState[]; + isDraft?: boolean; + currentUserReviewState?: string; + hasReviewDraft: boolean; + lastReviewType?: ReviewType; + revertable?: boolean; + busy?: boolean; + loadingCommit?: string; + generateDescriptionTitle?: string; +} + +export interface ProjectItemsReply { + projectItems: IProjectItem[] | undefined; +} + +export interface ChangeAssigneesReply { + assignees: IAccount[]; + events: TimelineEvent[]; +} + +export interface ChangeReviewersReply { + reviewers: ReviewState[]; +} + +export interface SubmitReviewReply { + events?: TimelineEvent[]; + reviewedEvent: ReviewEvent | CommentEvent; + reviewers?: ReviewState[]; +} + +export interface ReadyForReviewReply { + isDraft: boolean; + reviewEvent?: ReviewEvent; + reviewers?: ReviewState[]; + autoMerge?: boolean; +} + +export interface ConvertToDraftReply { + isDraft: boolean; +} + +export interface MergeArguments { + title: string | undefined; + description: string | undefined; + method: MergeMethod; + email?: string; +} + +export interface MergeResult { + state: GithubItemStateEnum; + revertable: boolean; + events?: TimelineEvent[]; +} + +export interface DeleteReviewResult { + deletedReviewId: number; + deletedReviewComments: IComment[]; +} + +export enum PreReviewState { + None = 0, + Available, + ReviewedWithComments, + ReviewedWithoutComments +} + +export interface ChangeTemplateReply { + description: string; +} + +export interface CancelCodingAgentReply { + events: TimelineEvent[]; +} + +export interface OverviewContext { + 'preventDefaultContextMenuItems': true; + owner: string; + repo: string; + number: number; + [key: string]: boolean | string | number; +} + +export interface CodingAgentContext extends SessionLinkInfo { + 'preventDefaultContextMenuItems': true; + [key: string]: boolean | string | number | undefined; +} + +export interface ChangeBaseReply { + base: string; + events: TimelineEvent[]; +} + +/** + * Represents an unresolved PR or issue identity - just enough info to show the overview + * panel before the full model is loaded. + */ +export interface UnresolvedIdentity { + owner: string; + repo: string; + number: number; +} \ No newline at end of file diff --git a/src/integrations/gitlens/gitlens.d.ts b/src/integrations/gitlens/gitlens.d.ts index f927e13712..531a68d1c0 100644 --- a/src/integrations/gitlens/gitlens.d.ts +++ b/src/integrations/gitlens/gitlens.d.ts @@ -23,7 +23,7 @@ export interface CreatePullRequestActionContext { readonly name: string; readonly provider?: RemoteProvider; readonly url?: string; - } + } | undefined; } diff --git a/src/integrations/gitlens/gitlensImpl.ts b/src/integrations/gitlens/gitlensImpl.ts index 5cb4eda27a..a589aa1252 100644 --- a/src/integrations/gitlens/gitlensImpl.ts +++ b/src/integrations/gitlens/gitlensImpl.ts @@ -3,25 +3,24 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { commands, Disposable, extensions } from 'vscode'; +import * as vscode from 'vscode'; import { CreatePullRequestActionContext, GitLensApi } from './gitlens'; +import { Disposable } from '../../common/lifecycle'; -export class GitLensIntegration implements Disposable { - private _extensionsDisposable: Disposable; - private _subscriptions: Disposable[] = []; +export class GitLensIntegration extends Disposable { + private _extensionsDisposable: vscode.Disposable; constructor() { - this._extensionsDisposable = extensions.onDidChange(this.onExtensionsChanged, this); + super(); + this._extensionsDisposable = this._register(vscode.extensions.onDidChange(this.onExtensionsChanged, this)); this.onExtensionsChanged(); } - dispose() { - this._extensionsDisposable.dispose(); - Disposable.from(...this._subscriptions).dispose(); - } - - private register(api: GitLensApi) { - this._subscriptions.push( + private register(api: GitLensApi | undefined) { + if (!api) { + return; + } + this._register( api.registerActionRunner('createPullRequest', { partnerId: 'ghpr', name: 'GitHub Pull Requests and Issues', @@ -32,7 +31,7 @@ export class GitLensIntegration implements Disposable { return; } - commands.executeCommand('pr.create', { + vscode.commands.executeCommand('pr.create', { repoPath: context.repoPath, compareBranch: context.branch.name, }); @@ -43,8 +42,8 @@ export class GitLensIntegration implements Disposable { private async onExtensionsChanged() { const extension = - extensions.getExtension>('eamodio.gitlens') ?? - extensions.getExtension>('eamodio.gitlens-insiders'); + vscode.extensions.getExtension>('eamodio.gitlens') ?? + vscode.extensions.getExtension>('eamodio.gitlens-insiders'); if (extension) { this._extensionsDisposable.dispose(); diff --git a/src/issues/currentIssue.ts b/src/issues/currentIssue.ts index 203593fb1a..2839991f30 100644 --- a/src/issues/currentIssue.ts +++ b/src/issues/currentIssue.ts @@ -4,27 +4,30 @@ *--------------------------------------------------------------------------------------------*/ 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'; import { Remote } from '../common/remote'; +import { + ASSIGN_WHEN_WORKING, + ISSUE_BRANCH_TITLE, + ISSUES_SETTINGS_NAMESPACE, + USE_BRANCH_FOR_ISSUES, + WORKING_ISSUE_FORMAT_SCM, +} from '../common/settingKeys'; +import { escapeRegExp } from '../common/utils'; import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; import { GithubItemStateEnum } from '../github/interface'; import { IssueModel } from '../github/issueModel'; import { variableSubstitution } from '../github/utils'; -import { IssueState, StateManager } from './stateManager'; -import { - BRANCH_CONFIGURATION, - BRANCH_NAME_CONFIGURATION, - ISSUES_CONFIGURATION, - SCM_MESSAGE_CONFIGURATION -} from './util'; -export class CurrentIssue { - private repoChangeDisposable: vscode.Disposable | undefined; +export class CurrentIssue extends Disposable { private _branchName: string | undefined; private user: string | undefined; private repo: Repository | undefined; private _repoDefaults: PullRequestDefaults | undefined; - private _onDidChangeCurrentIssueState: vscode.EventEmitter = new vscode.EventEmitter(); + private _onDidChangeCurrentIssueState: vscode.EventEmitter = this._register(new vscode.EventEmitter()); public readonly onDidChangeCurrentIssueState: vscode.Event = this._onDidChangeCurrentIssueState.event; constructor( private issueModel: IssueModel, @@ -33,6 +36,7 @@ export class CurrentIssue { remote?: Remote, private shouldPromptForBranch?: boolean, ) { + super(); this.setRepo(remote ?? this.issueModel.githubRepository.remote); } @@ -74,7 +78,7 @@ export class CurrentIssue { this._onDidChangeCurrentIssueState.fire(); const login = (await this.manager.getCurrentUser(this.issueModel.githubRepository)).login; if ( - vscode.workspace.getConfiguration('githubIssues').get('assignWhenWorking') && + vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(ASSIGN_WHEN_WORKING) && !this.issueModel.assignees?.find(value => value.login === login) ) { // Check that we have a repo open for this issue and only try to assign in that case. @@ -83,7 +87,7 @@ export class CurrentIssue { )) { await this.manager.assignIssue(this.issueModel, login); } - await this.stateManager.refresh(); + await this.stateManager.refresh(this.manager); } return true; } @@ -94,16 +98,21 @@ export class CurrentIssue { return false; } - public dispose() { - this.repoChangeDisposable?.dispose(); - } - - public async stopWorking() { + public async stopWorking(checkoutDefaultBranch: boolean) { if (this.repo) { this.repo.inputBox.value = ''; } - if (this._repoDefaults) { - await this.manager.repository.checkout(this._repoDefaults.base); + if (this._repoDefaults && checkoutDefaultBranch) { + try { + await this.manager.repository.checkout(this._repoDefaults.base); + } catch (e) { + if (e.gitErrorCode === GitErrorCodes.DirtyWorkTree) { + vscode.window.showErrorMessage( + vscode.l10n.t('Your local changes would be overwritten by checkout, please commit your changes or stash them before you switch branches'), + ); + } + throw e; + } } this._onDidChangeCurrentIssueState.fire(); this.dispose(); @@ -142,14 +151,14 @@ export class CurrentIssue { private async getUser(): Promise { if (!this.user) { - this.user = await this.issueModel.githubRepository.getAuthenticatedUser(); + this.user = (await this.issueModel.githubRepository.getAuthenticatedUser()).login; } return this.user; } private async getBranchTitle(): Promise { return ( - vscode.workspace.getConfiguration(ISSUES_CONFIGURATION).get(BRANCH_NAME_CONFIGURATION) ?? + vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(ISSUE_BRANCH_TITLE) ?? this.getBasicBranchName(await this.getUser()) ); } @@ -169,7 +178,7 @@ export class CurrentIssue { if (result === editSetting) { vscode.commands.executeCommand( 'workbench.action.openSettings', - `${ISSUES_CONFIGURATION}.${BRANCH_NAME_CONFIGURATION}`, + `${ISSUES_SETTINGS_NAMESPACE}.${ISSUE_BRANCH_TITLE}`, ); } }); @@ -178,7 +187,7 @@ export class CurrentIssue { private async offerNewBranch(branch: Branch, branchNameConfig: string, branchNameMatch: RegExpMatchArray | null | undefined): Promise { // Check if this branch has a merged PR associated with it. // If so, offer to create a new branch. - const pr = await this.manager.getMatchingPullRequestMetadataFromGitHub(branch.upstream?.remote, branch.upstream?.name); + const pr = await this.manager.getMatchingPullRequestMetadataFromGitHub(branch, branch.upstream?.remote, branch.upstream?.name); if (pr && (pr.model.state !== GithubItemStateEnum.Open)) { const mergedMessage = vscode.l10n.t('The pull request for {0} has been merged. Do you want to create a new branch?', branch.name ?? 'unknown branch'); const closedMessage = vscode.l10n.t('The pull request for {0} has been closed. Do you want to create a new branch?', branch.name ?? 'unknown branch'); @@ -198,19 +207,19 @@ export class CurrentIssue { private async createIssueBranch(silent: boolean): Promise { const createBranchConfig = this.shouldPromptForBranch ? 'prompt' - : vscode.workspace.getConfiguration(ISSUES_CONFIGURATION).get(BRANCH_CONFIGURATION); + : vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(USE_BRANCH_FOR_ISSUES); if (createBranchConfig === 'off') { return true; } const state: IssueState = this.stateManager.getSavedIssueState(this.issueModel.number); this._branchName = this.shouldPromptForBranch ? undefined : state.branch; - const branchNameConfig = await variableSubstitution( + const branchNameConfig = variableSubstitution( await this.getBranchTitle(), this.issue, undefined, await this.getUser(), ); - const branchNameMatch = this._branchName?.match(new RegExp('^(' + branchNameConfig + ')(_)?(\\d*)')); + const branchNameMatch = this._branchName?.match(new RegExp('^(' + escapeRegExp(branchNameConfig) + ')(_)?(\\d*)')); if ((createBranchConfig === 'on')) { const branch = await this.getBranch(this._branchName!); if (!branch) { @@ -248,7 +257,7 @@ export class CurrentIssue { } public async getCommitMessage(): Promise { - const configuration = vscode.workspace.getConfiguration(ISSUES_CONFIGURATION).get(SCM_MESSAGE_CONFIGURATION); + const configuration = vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(WORKING_ISSUE_FORMAT_SCM); if (typeof configuration === 'string') { return variableSubstitution(configuration, this.issueModel, this._repoDefaults); } diff --git a/src/issues/issueCompletionProvider.ts b/src/issues/issueCompletionProvider.ts index 9cdabc54d7..63bd4a527d 100644 --- a/src/issues/issueCompletionProvider.ts +++ b/src/issues/issueCompletionProvider.ts @@ -4,20 +4,24 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { + IGNORE_COMPLETION_TRIGGER, + ISSUE_COMPLETION_FORMAT_SCM, + ISSUES_SETTINGS_NAMESPACE, +} 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 { MilestoneModel } from '../github/milestoneModel'; +import { issueMarkdown } from '../github/markdownUtils'; import { RepositoriesManager } from '../github/repositoriesManager'; import { getIssueNumberLabel, variableSubstitution } from '../github/utils'; -import { extractIssueOriginFromQuery, NEW_ISSUE_SCHEME } from './issueFile'; -import { StateManager } from './stateManager'; -import { - getRootUriFromScmInputUri, - isComment, - issueMarkdown, - ISSUES_CONFIGURATION, -} from './util'; class IssueCompletionItem extends vscode.CompletionItem { constructor(public readonly issue: IssueModel) { @@ -25,6 +29,20 @@ class IssueCompletionItem extends vscode.CompletionItem { } } +class ConfigureIssueQueriesCompletionItem extends vscode.CompletionItem { + constructor() { + super(vscode.l10n.t('Configure issue queries...'), vscode.CompletionItemKind.Text); + this.detail = vscode.l10n.t('No issues found. Set up queries to see relevant issues.'); + this.insertText = ''; + this.command = { + command: 'workbench.action.openSettings', + title: vscode.l10n.t('Open Settings'), + arguments: [`@ext:${EXTENSION_ID} githubIssues.queries`] + }; + this.sortText = '~'; // Sort to bottom of list + } +} + export class IssueCompletionProvider implements vscode.CompletionItemProvider { constructor( private stateManager: StateManager, @@ -75,14 +93,16 @@ export class IssueCompletionProvider implements vscode.CompletionItemProvider { if ( context.triggerKind === vscode.CompletionTriggerKind.TriggerCharacter && vscode.workspace - .getConfiguration(ISSUES_CONFIGURATION) - .get('ignoreCompletionTrigger', []) + .getConfiguration(ISSUES_SETTINGS_NAMESPACE) + .get(IGNORE_COMPLETION_TRIGGER, []) .find(value => value === document.languageId) ) { 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 []; } @@ -93,8 +113,27 @@ export class IssueCompletionProvider implements vscode.CompletionItemProvider { } } - const completionItems: Map = new Map(); - const now = new Date(); + // Check for owner/repo preceding the # + let filterOwnerAndRepo: { owner: string; repo: string } | undefined; + if (wordAtPos === '#' && wordRange) { + if (wordRange.start.character >= 3) { + const ownerRepoRange = new vscode.Range( + wordRange.start.with(undefined, 0), + wordRange.start + ); + const ownerRepo = document.getText(ownerRepoRange); + const ownerRepoMatch = ownerRepo.match(/([^\s]+)\/([^\s]+)/); + if (ownerRepoMatch) { + filterOwnerAndRepo = { + owner: ownerRepoMatch[1], + repo: ownerRepoMatch[2], + }; + } + } + } + + const completionItems: IssueCompletionItem[] = []; + const seenIssues: Set = new Set(); let repo: PullRequestDefaults | undefined; let uri: vscode.Uri | undefined; if (document.languageId === 'scminput') { @@ -109,8 +148,8 @@ export class IssueCompletionProvider implements vscode.CompletionItemProvider { } } } else { - uri = document.uri.scheme === NEW_ISSUE_SCHEME - ? extractIssueOriginFromQuery(document.uri) ?? document.uri + uri = document.uri.scheme === Schemes.NewIssue + ? fromNewIssueUri(document.uri)?.originUri ?? document.uri : document.uri; } if (!uri) { @@ -125,65 +164,44 @@ export class IssueCompletionProvider implements vscode.CompletionItemProvider { // leave repo undefined } const issueData = this.stateManager.getIssueCollection(folderManager?.repository.rootUri ?? uri); + let sortNumber = 0; - // Count up total number of issues. The number of queries is expected to be small. - let totalIssues = 0; + // Process queries in order to maintain query priority for (const issueQuery of issueData) { - const issuesOrMilestones: IssueModel[] | MilestoneModel[] = (await issueQuery[1]) ?? []; - if (issuesOrMilestones[0] instanceof IssueModel) { - totalIssues += issuesOrMilestones.length; - } else { - for (const milestone of issuesOrMilestones) { - totalIssues += (milestone as MilestoneModel).issues.length; - } - } - } - - for (const issueQuery of issueData) { - const issuesOrMilestones: IssueModel[] | MilestoneModel[] = (await issueQuery[1]) ?? []; - if (issuesOrMilestones.length === 0) { + const issuesOrMilestones: IssueQueryResult = await issueQuery[1]; + if ((issuesOrMilestones.issues ?? []).length === 0) { continue; } - if (issuesOrMilestones[0] instanceof IssueModel) { - let index = 0; - for (const issue of issuesOrMilestones) { - completionItems.set( - getIssueNumberLabel(issue as IssueModel), - await this.completionItemFromIssue(repo, issue as IssueModel, now, range, document, index++, totalIssues), - ); + for (const issue of (issuesOrMilestones.issues ?? [])) { + if (filterOwnerAndRepo && ((issue as IssueModel).remote.owner !== filterOwnerAndRepo.owner || (issue as IssueModel).remote.repositoryName !== filterOwnerAndRepo.repo)) { + continue; } - } else { - for (let index = 0; index < issuesOrMilestones.length; index++) { - const value: MilestoneModel = issuesOrMilestones[index] as MilestoneModel; - for (const issue of value.issues) { - completionItems.set( - getIssueNumberLabel(issue), - await this.completionItemFromIssue( - repo, - issue, - now, - range, - document, - index, - totalIssues, - value.milestone, - ), - ); - } + const issueKey = getIssueNumberLabel(issue as IssueModel); + // Only add the issue if we haven't seen it before (first query wins) + if (!seenIssues.has(issueKey)) { + seenIssues.add(issueKey); + const completionItem = await this.completionItemFromIssue(repo, issue as IssueModel, range, document); + // Ensure that the sort order respects the query order + completionItem.sortText = sortNumber.toString().padStart(8, '0'); + sortNumber++; + completionItems.push(completionItem); } } } - return [...completionItems.values()]; + + // If no issues were found, show a configuration prompt + if (completionItems.length === 0) { + return [new ConfigureIssueQueriesCompletionItem()]; + } + + return completionItems; } private async completionItemFromIssue( repo: PullRequestDefaults | undefined, issue: IssueModel, - now: Date, range: vscode.Range, document: vscode.TextDocument, - index: number, - totalCount: number, milestone?: IMilestone, ): Promise { const item: IssueCompletionItem = new IssueCompletionItem(issue); @@ -191,10 +209,10 @@ export class IssueCompletionProvider implements vscode.CompletionItemProvider { item.insertText = `[${getIssueNumberLabel(issue, repo)}](${issue.html_url})`; } else { const configuration = vscode.workspace - .getConfiguration(ISSUES_CONFIGURATION) - .get('issueCompletionFormatScm'); + .getConfiguration(ISSUES_SETTINGS_NAMESPACE) + .get(ISSUE_COMPLETION_FORMAT_SCM); if (document.uri.path.match(/git\/scm\d\/input/) && typeof configuration === 'string') { - item.insertText = await variableSubstitution(configuration, issue, repo); + item.insertText = variableSubstitution(configuration, issue, repo); } else { item.insertText = `${getIssueNumberLabel(issue, repo)}`; } @@ -202,7 +220,6 @@ export class IssueCompletionProvider implements vscode.CompletionItemProvider { item.documentation = issue.body; item.range = range; item.detail = milestone ? milestone.title : issue.milestone?.title; - item.sortText = `${index}`.padStart(`${totalCount}`.length, '0'); item.filterText = `${item.detail} # ${issue.number} ${issue.title} ${item.documentation}`; return item; } diff --git a/src/issues/issueFeatureRegistrar.ts b/src/issues/issueFeatureRegistrar.ts index 4b912d61a6..321c7790e5 100644 --- a/src/issues/issueFeatureRegistrar.ts +++ b/src/issues/issueFeatureRegistrar.ts @@ -1,1206 +1,1704 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { GitApiImpl } from '../api/api1'; -import { ITelemetry } from '../common/telemetry'; -import { OctokitCommon } from '../github/common'; -import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; -import { IssueModel } from '../github/issueModel'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { getRepositoryForFile, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput } from '../github/utils'; -import { ReviewManager } from '../view/reviewManager'; -import { CurrentIssue } from './currentIssue'; -import { IssueCompletionProvider } from './issueCompletionProvider'; -import { - ASSIGNEES, - extractIssueOriginFromQuery, - IssueFileSystemProvider, - LabelCompletionProvider, - LABELS, - NEW_ISSUE_FILE, - NEW_ISSUE_SCHEME, - NewIssueCache, -} from './issueFile'; -import { IssueHoverProvider } from './issueHoverProvider'; -import { openCodeLink } from './issueLinkLookup'; -import { IssuesTreeData, IssueUriTreeItem } from './issuesView'; -import { IssueTodoProvider } from './issueTodoProvider'; -import { StateManager } from './stateManager'; -import { UserCompletionProvider } from './userCompletionProvider'; -import { UserHoverProvider } from './userHoverProvider'; -import { - createGitHubLink, - createGithubPermalink, - getIssue, - ISSUES_CONFIGURATION, - NewIssue, - PermalinkInfo, - pushAndCreatePR, - QUERIES_CONFIGURATION, - USER_EXPRESSION, -} from './util'; - -const ISSUE_COMPLETIONS_CONFIGURATION = 'issueCompletions.enabled'; -const USER_COMPLETIONS_CONFIGURATION = 'userCompletions.enabled'; - -const CREATING_ISSUE_FROM_FILE_CONTEXT = 'issues.creatingFromFile'; - -export class IssueFeatureRegistrar implements vscode.Disposable { - private _stateManager: StateManager; - private _newIssueCache: NewIssueCache; - - private createIssueInfo: - | { - document: vscode.TextDocument; - newIssue: NewIssue | undefined; - lineNumber: number | undefined; - insertIndex: number | undefined; - } - | undefined; - - constructor( - private gitAPI: GitApiImpl, - private manager: RepositoriesManager, - private reviewManagers: ReviewManager[], - private context: vscode.ExtensionContext, - private telemetry: ITelemetry, - ) { - this._stateManager = new StateManager(gitAPI, this.manager, this.context); - this._newIssueCache = new NewIssueCache(context); - } - - async initialize() { - this.context.subscriptions.push( - vscode.workspace.registerFileSystemProvider(NEW_ISSUE_SCHEME, new IssueFileSystemProvider(this._newIssueCache)), - ); - this.context.subscriptions.push( - vscode.languages.registerCompletionItemProvider( - { scheme: NEW_ISSUE_SCHEME }, - new LabelCompletionProvider(this.manager), - ' ', - ',', - ), - ); - this.context.subscriptions.push( - vscode.window.createTreeView('issues:github', { - showCollapseAll: true, - treeDataProvider: new IssuesTreeData(this._stateManager, this.manager, this.context), - }), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.createIssueFromSelection', - (newIssue?: NewIssue, issueBody?: string) => { - /* __GDPR__ - "issue.createIssueFromSelection" : {} - */ - this.telemetry.sendTelemetryEvent('issue.createIssueFromSelection'); - return this.createTodoIssue(newIssue, issueBody); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.createIssueFromClipboard', - () => { - /* __GDPR__ - "issue.createIssueFromClipboard" : {} - */ - this.telemetry.sendTelemetryEvent('issue.createIssueFromClipboard'); - return this.createTodoIssueClipboard(); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.copyGithubPermalink', - (fileUri: any) => { - /* __GDPR__ - "issue.copyGithubPermalink" : {} - */ - this.telemetry.sendTelemetryEvent('issue.copyGithubPermalink'); - return this.copyPermalink(this.manager, fileUri instanceof vscode.Uri ? fileUri : undefined); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.copyGithubHeadLink', - (fileUri: any) => { - /* __GDPR__ - "issue.copyGithubHeadLink" : {} - */ - this.telemetry.sendTelemetryEvent('issue.copyGithubHeadLink'); - return this.copyHeadLink(fileUri); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.copyMarkdownGithubPermalink', - () => { - /* __GDPR__ - "issue.copyMarkdownGithubPermalink" : {} - */ - this.telemetry.sendTelemetryEvent('issue.copyMarkdownGithubPermalink'); - return this.copyMarkdownPermalink(this.manager); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.openGithubPermalink', - () => { - /* __GDPR__ - "issue.openGithubPermalink" : {} - */ - this.telemetry.sendTelemetryEvent('issue.openGithubPermalink'); - return this.openPermalink(this.manager); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand('issue.openIssue', (issueModel: any) => { - /* __GDPR__ - "issue.openIssue" : {} - */ - this.telemetry.sendTelemetryEvent('issue.openIssue'); - return this.openIssue(issueModel); - }), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.startWorking', - (issue: any) => { - /* __GDPR__ - "issue.startWorking" : {} - */ - this.telemetry.sendTelemetryEvent('issue.startWorking'); - return this.startWorking(issue); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.startWorkingBranchDescriptiveTitle', - (issue: any) => { - /* __GDPR__ - "issue.startWorking" : {} - */ - this.telemetry.sendTelemetryEvent('issue.startWorking'); - return this.startWorking(issue); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.continueWorking', - (issue: any) => { - /* __GDPR__ - "issue.continueWorking" : {} - */ - this.telemetry.sendTelemetryEvent('issue.continueWorking'); - return this.startWorking(issue); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.startWorkingBranchPrompt', - (issueModel: any) => { - /* __GDPR__ - "issue.startWorkingBranchPrompt" : {} - */ - this.telemetry.sendTelemetryEvent('issue.startWorkingBranchPrompt'); - return this.startWorkingBranchPrompt(issueModel); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.stopWorking', - (issueModel: any) => { - /* __GDPR__ - "issue.stopWorking" : {} - */ - this.telemetry.sendTelemetryEvent('issue.stopWorking'); - return this.stopWorking(issueModel); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.stopWorkingBranchDescriptiveTitle', - (issueModel: any) => { - /* __GDPR__ - "issue.stopWorking" : {} - */ - this.telemetry.sendTelemetryEvent('issue.stopWorking'); - return this.stopWorking(issueModel); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.statusBar', - () => { - /* __GDPR__ - "issue.statusBar" : {} - */ - this.telemetry.sendTelemetryEvent('issue.statusBar'); - return this.statusBar(); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand('issue.copyIssueNumber', (issueModel: any) => { - /* __GDPR__ - "issue.copyIssueNumber" : {} - */ - this.telemetry.sendTelemetryEvent('issue.copyIssueNumber'); - return this.copyIssueNumber(issueModel); - }), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand('issue.copyIssueUrl', (issueModel: any) => { - /* __GDPR__ - "issue.copyIssueUrl" : {} - */ - this.telemetry.sendTelemetryEvent('issue.copyIssueUrl'); - return this.copyIssueUrl(issueModel); - }), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.refresh', - () => { - /* __GDPR__ - "issue.refresh" : {} - */ - this.telemetry.sendTelemetryEvent('issue.refresh'); - return this.refreshView(); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.suggestRefresh', - () => { - /* __GDPR__ - "issue.suggestRefresh" : {} - */ - this.telemetry.sendTelemetryEvent('issue.suggestRefresh'); - return this.suggestRefresh(); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.getCurrent', - () => { - /* __GDPR__ - "issue.getCurrent" : {} - */ - this.telemetry.sendTelemetryEvent('issue.getCurrent'); - return this.getCurrent(); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.editQuery', - (query: IssueUriTreeItem) => { - /* __GDPR__ - "issue.editQuery" : {} - */ - this.telemetry.sendTelemetryEvent('issue.editQuery'); - return this.editQuery(query); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.createIssue', - () => { - /* __GDPR__ - "issue.createIssue" : {} - */ - this.telemetry.sendTelemetryEvent('issue.createIssue'); - return this.createIssue(); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand( - 'issue.createIssueFromFile', - async () => { - /* __GDPR__ - "issue.createIssueFromFile" : {} - */ - this.telemetry.sendTelemetryEvent('issue.createIssueFromFile'); - await vscode.commands.executeCommand('setContext', CREATING_ISSUE_FROM_FILE_CONTEXT, true); - await this.createIssueFromFile(); - await vscode.commands.executeCommand('setContext', CREATING_ISSUE_FROM_FILE_CONTEXT, false); - }, - this, - ), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand('issue.issueCompletion', () => { - /* __GDPR__ - "issue.issueCompletion" : {} - */ - this.telemetry.sendTelemetryEvent('issue.issueCompletion'); - }), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand('issue.userCompletion', () => { - /* __GDPR__ - "issue.userCompletion" : {} - */ - this.telemetry.sendTelemetryEvent('issue.userCompletion'); - }), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand('issue.signinAndRefreshList', async () => { - return this.manager.authenticate(); - }), - ); - this.context.subscriptions.push( - vscode.commands.registerCommand('issue.goToLinkedCode', async (issueModel: any) => { - return openCodeLink(issueModel, this.manager); - }), - ); - this._stateManager.tryInitializeAndWait().then(() => { - this.registerCompletionProviders(); - - this.context.subscriptions.push( - vscode.languages.registerHoverProvider( - '*', - new IssueHoverProvider(this.manager, this._stateManager, this.context, this.telemetry), - ), - ); - this.context.subscriptions.push( - vscode.languages.registerHoverProvider('*', new UserHoverProvider(this.manager, this.telemetry)), - ); - this.context.subscriptions.push( - vscode.languages.registerCodeActionsProvider('*', new IssueTodoProvider(this.context), { providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] }), - ); - }); - } - - dispose() { } - - private documentFilters: Array = [ - { language: 'php' }, - { language: 'powershell' }, - { language: 'jade' }, - { language: 'python' }, - { language: 'r' }, - { language: 'razor' }, - { language: 'ruby' }, - { language: 'rust' }, - { language: 'scminput' }, - { language: 'scss' }, - { language: 'search-result' }, - { language: 'shaderlab' }, - { language: 'shellscript' }, - { language: 'sql' }, - { language: 'swift' }, - { language: 'typescript' }, - { language: 'vb' }, - { language: 'xml' }, - { language: 'yaml' }, - { language: 'markdown' }, - { language: 'bat' }, - { language: 'clojure' }, - { language: 'coffeescript' }, - { language: 'jsonc' }, - { language: 'c' }, - { language: 'cpp' }, - { language: 'csharp' }, - { language: 'css' }, - { language: 'dockerfile' }, - { language: 'fsharp' }, - { language: 'git-commit' }, - { language: 'go' }, - { language: 'groovy' }, - { language: 'handlebars' }, - { language: 'hlsl' }, - { language: 'html' }, - { language: 'ini' }, - { language: 'java' }, - { language: 'javascriptreact' }, - { language: 'javascript' }, - { language: 'json' }, - { language: 'less' }, - { language: 'log' }, - { language: 'lua' }, - { language: 'makefile' }, - { language: 'ignore' }, - { language: 'properties' }, - { language: 'objective-c' }, - { language: 'perl' }, - { language: 'perl6' }, - { language: 'typescriptreact' }, - { language: 'yml' }, - '*', - ]; - private registerCompletionProviders() { - const providers: { - provider: typeof IssueCompletionProvider | typeof UserCompletionProvider; - trigger: string; - disposable: vscode.Disposable | undefined; - configuration: string; - }[] = [ - { - provider: IssueCompletionProvider, - trigger: '#', - disposable: undefined, - configuration: ISSUE_COMPLETIONS_CONFIGURATION, - }, - { - provider: UserCompletionProvider, - trigger: '@', - disposable: undefined, - configuration: USER_COMPLETIONS_CONFIGURATION, - }, - ]; - for (const element of providers) { - if (vscode.workspace.getConfiguration(ISSUES_CONFIGURATION).get(element.configuration, true)) { - this.context.subscriptions.push( - (element.disposable = vscode.languages.registerCompletionItemProvider( - this.documentFilters, - new element.provider(this._stateManager, this.manager, this.context), - element.trigger, - )), - ); - } - } - this.context.subscriptions.push( - vscode.workspace.onDidChangeConfiguration(change => { - for (const element of providers) { - if (change.affectsConfiguration(`${ISSUES_CONFIGURATION}.${element.configuration}`)) { - const newValue: boolean = vscode.workspace - .getConfiguration(ISSUES_CONFIGURATION) - .get(element.configuration, true); - if (!newValue && element.disposable) { - element.disposable.dispose(); - element.disposable = undefined; - } else if (newValue && !element.disposable) { - this.context.subscriptions.push( - (element.disposable = vscode.languages.registerCompletionItemProvider( - this.documentFilters, - new element.provider(this._stateManager, this.manager, this.context), - element.trigger, - )), - ); - } - break; - } - } - }), - ); - } - - async createIssue() { - let uri = vscode.window.activeTextEditor?.document.uri; - if (!uri) { - uri = (await this.chooseRepo(vscode.l10n.t('Select the repo to create the issue in.')))?.repository.rootUri; - } - if (uri) { - return this.makeNewIssueFile(uri); - } - } - - async createIssueFromFile() { - let text: string; - if ( - !vscode.window.activeTextEditor || - vscode.window.activeTextEditor.document.uri.scheme !== NEW_ISSUE_SCHEME - ) { - return; - } - text = vscode.window.activeTextEditor.document.getText(); - const indexOfEmptyLineWindows = text.indexOf('\r\n\r\n'); - const indexOfEmptyLineOther = text.indexOf('\n\n'); - let indexOfEmptyLine: number; - if (indexOfEmptyLineWindows < 0 && indexOfEmptyLineOther < 0) { - return; - } else { - if (indexOfEmptyLineWindows < 0) { - indexOfEmptyLine = indexOfEmptyLineOther; - } else if (indexOfEmptyLineOther < 0) { - indexOfEmptyLine = indexOfEmptyLineWindows; - } else { - indexOfEmptyLine = Math.min(indexOfEmptyLineWindows, indexOfEmptyLineOther); - } - } - const title = text.substring(0, indexOfEmptyLine); - let assignees: string[] | undefined; - text = text.substring(indexOfEmptyLine + 2).trim(); - if (text.startsWith(ASSIGNEES)) { - const lines = text.split(/\r\n|\n/, 1); - if (lines.length === 1) { - assignees = lines[0] - .substring(ASSIGNEES.length) - .split(',') - .map(value => { - value = value.trim(); - if (value.startsWith('@')) { - value = value.substring(1); - } - return value; - }); - text = text.substring(lines[0].length).trim(); - } - } - let labels: string[] | undefined; - if (text.startsWith(LABELS)) { - const lines = text.split(/\r\n|\n/, 1); - if (lines.length === 1) { - labels = lines[0] - .substring(LABELS.length) - .split(',') - .map(value => value.trim()) - .filter(label => label); - text = text.substring(lines[0].length).trim(); - } - } - const body = text ?? ''; - if (!title) { - return; - } - const createSucceeded = await this.doCreateIssue( - this.createIssueInfo?.document, - this.createIssueInfo?.newIssue, - title, - body, - assignees, - labels, - this.createIssueInfo?.lineNumber, - this.createIssueInfo?.insertIndex, - extractIssueOriginFromQuery(vscode.window.activeTextEditor.document.uri), - ); - this.createIssueInfo = undefined; - if (createSucceeded) { - await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); - this._newIssueCache.clear(); - } - } - - async editQuery(query: IssueUriTreeItem) { - const config = vscode.workspace.getConfiguration(ISSUES_CONFIGURATION, null); - const inspect = config.inspect<{ label: string; query: string }[]>(QUERIES_CONFIGURATION); - let command: string; - if (inspect?.workspaceValue) { - command = 'workbench.action.openWorkspaceSettingsFile'; - } else { - const value = config.get<{ label: string; query: string }[]>(QUERIES_CONFIGURATION); - if (inspect?.defaultValue && JSON.stringify(inspect?.defaultValue) === JSON.stringify(value)) { - config.update(QUERIES_CONFIGURATION, inspect.defaultValue, vscode.ConfigurationTarget.Global); - } - command = 'workbench.action.openSettingsJson'; - } - await vscode.commands.executeCommand(command); - const editor = vscode.window.activeTextEditor; - if (editor) { - const text = editor.document.getText(); - const search = text.search(query.labelAsString!); - if (search >= 0) { - const position = editor.document.positionAt(search); - editor.revealRange(new vscode.Range(position, position)); - editor.selection = new vscode.Selection(position, position); - } - } - } - - getCurrent() { - // This is used by the "api" command issues.getCurrent - const currentIssues = this._stateManager.currentIssues(); - if (currentIssues.length > 0) { - return { - owner: currentIssues[0].issue.remote.owner, - repo: currentIssues[0].issue.remote.repositoryName, - number: currentIssues[0].issue.number, - }; - } - return undefined; - } - - refreshView() { - this._stateManager.refreshCacheNeeded(); - } - - async suggestRefresh() { - await vscode.commands.executeCommand('hideSuggestWidget'); - await this._stateManager.refresh(); - return vscode.commands.executeCommand('editor.action.triggerSuggest'); - } - - openIssue(issueModel: any) { - if (issueModel instanceof IssueModel) { - return vscode.env.openExternal(vscode.Uri.parse(issueModel.html_url)); - } - return undefined; - } - - async doStartWorking( - matchingRepoManager: FolderRepositoryManager | undefined, - issueModel: IssueModel, - needsBranchPrompt?: boolean, - ) { - let repoManager = matchingRepoManager; - let githubRepository = issueModel.githubRepository; - let remote = issueModel.remote; - if (!repoManager) { - repoManager = await this.chooseRepo(vscode.l10n.t('Choose which repository you want to work on this isssue in.')); - if (!repoManager) { - return; - } - githubRepository = await repoManager.getOrigin(); - remote = githubRepository.remote; - } - - const remoteNameResult = await repoManager.findUpstreamForItem({ githubRepository, remote }); - if (remoteNameResult.needsFork) { - if ((await repoManager.tryOfferToFork(githubRepository)) === undefined) { - return; - } - } - - await this._stateManager.setCurrentIssue( - repoManager, - new CurrentIssue(issueModel, repoManager, this._stateManager, remoteNameResult.remote, needsBranchPrompt), - ); - } - - async startWorking(issue: any) { - if (issue instanceof IssueModel) { - return this.doStartWorking(this.manager.getManagerForIssueModel(issue), issue); - } else if (issue instanceof vscode.Uri) { - const match = issue.toString().match(ISSUE_OR_URL_EXPRESSION); - const parsed = parseIssueExpressionOutput(match); - const folderManager = this.manager.folderManagers.find(folderManager => - folderManager.gitHubRepositories.find(repo => repo.remote.owner === parsed?.owner && repo.remote.repositoryName === parsed.name)); - if (parsed && folderManager) { - const issueModel = await getIssue(this._stateManager, folderManager, issue.toString(), parsed); - if (issueModel) { - return this.doStartWorking(folderManager, issueModel); - } - } - } - } - - async startWorkingBranchPrompt(issueModel: any) { - if (!(issueModel instanceof IssueModel)) { - return; - } - this.doStartWorking(this.manager.getManagerForIssueModel(issueModel), issueModel, true); - } - - async stopWorking(issueModel: any) { - let folderManager = this.manager.getManagerForIssueModel(issueModel); - if (!folderManager) { - folderManager = await this.chooseRepo(vscode.l10n.t('Choose which repository you want to stop working on this issue in.')); - if (!folderManager) { - return; - } - } - if ( - issueModel instanceof IssueModel && - this._stateManager.currentIssue(folderManager.repository.rootUri)?.issue.number === issueModel.number - ) { - await this._stateManager.setCurrentIssue(folderManager, undefined); - } - } - - private async statusBarActions(currentIssue: CurrentIssue) { - const openIssueText: string = vscode.l10n.t('{0} Open #{1} {2}', '$(globe)', currentIssue.issue.number, currentIssue.issue.title); - const pullRequestText: string = vscode.l10n.t({ message: '{0} Create pull request for #{1} (pushes branch)', args: ['$(git-pull-request)', currentIssue.issue.number], comment: ['The first placeholder is an icon and shouldn\'t be localized', 'The second placeholder is the ID number of a GitHub Issue.'] }); - let defaults: PullRequestDefaults | undefined; - try { - defaults = await currentIssue.manager.getPullRequestDefaults(); - } catch (e) { - // leave defaults undefined - } - const stopWorkingText: string = vscode.l10n.t('{0} Stop working on #{}', '$(primitive-square)', currentIssue.issue.number); - const choices = - currentIssue.branchName && defaults - ? [openIssueText, pullRequestText, stopWorkingText] - : [openIssueText, pullRequestText, stopWorkingText]; - const response: string | undefined = await vscode.window.showQuickPick(choices, { - placeHolder: vscode.l10n.t('Current issue options'), - }); - switch (response) { - case openIssueText: - return this.openIssue(currentIssue.issue); - case pullRequestText: { - const reviewManager = ReviewManager.getReviewManagerForFolderManager( - this.reviewManagers, - currentIssue.manager, - ); - if (reviewManager) { - return pushAndCreatePR(currentIssue.manager, reviewManager, this._stateManager); - } - break; - } - case stopWorkingText: - return this._stateManager.setCurrentIssue(currentIssue.manager, undefined); - } - } - - async statusBar() { - const currentIssues = this._stateManager.currentIssues(); - if (currentIssues.length === 1) { - return this.statusBarActions(currentIssues[0]); - } else { - interface IssueChoice extends vscode.QuickPickItem { - currentIssue: CurrentIssue; - } - const choices: IssueChoice[] = currentIssues.map(currentIssue => { - return { - label: vscode.l10n.t('#{0} from {1}', currentIssue.issue.number, `${currentIssue.issue.githubRepository.remote.owner}/${currentIssue.issue.githubRepository.remote.repositoryName}`), - currentIssue, - }; - }); - const response: IssueChoice | undefined = await vscode.window.showQuickPick(choices); - if (response) { - return this.statusBarActions(response.currentIssue); - } - } - } - - private stringToUint8Array(input: string): Uint8Array { - const encoder = new TextEncoder(); - return encoder.encode(input); - } - - copyIssueNumber(issueModel: any) { - if (issueModel instanceof IssueModel) { - return vscode.env.clipboard.writeText(issueModel.number.toString()); - } - return undefined; - } - - copyIssueUrl(issueModel: any) { - if (issueModel instanceof IssueModel) { - return vscode.env.clipboard.writeText(issueModel.html_url); - } - return undefined; - } - - async createTodoIssueClipboard() { - return this.createTodoIssue(undefined, await vscode.env.clipboard.readText()); - } - - private async createTodoIssueBody(newIssue?: NewIssue, issueBody?: string): Promise { - if (issueBody || newIssue?.document.isUntitled) { - return issueBody; - } - - let contents = ''; - if (newIssue) { - const repository = getRepositoryForFile(this.gitAPI, newIssue.document.uri); - const changeAffectingFile = repository?.state.workingTreeChanges.find(value => value.uri.toString() === newIssue.document.uri.toString()); - if (changeAffectingFile) { - // The file we're creating the issue for has uncommitted changes. - // Add a quote of the line so that the issue body is still meaningful. - contents = `\`\`\`\n${newIssue.line}\n\`\`\`\n\n`; - } - } - contents += (await createGithubPermalink(this.manager, this.gitAPI, newIssue)).permalink; - return contents; - } - - async createTodoIssue(newIssue?: NewIssue, issueBody?: string) { - let document: vscode.TextDocument; - let titlePlaceholder: string | undefined; - let insertIndex: number | undefined; - let lineNumber: number | undefined; - let assignee: string[] | undefined; - let issueGenerationText: string | undefined; - if (!newIssue && vscode.window.activeTextEditor) { - document = vscode.window.activeTextEditor.document; - issueGenerationText = document.getText(vscode.window.activeTextEditor.selection); - } else if (newIssue) { - document = newIssue.document; - insertIndex = newIssue.insertIndex; - lineNumber = newIssue.lineNumber; - titlePlaceholder = newIssue.line.substring(insertIndex, newIssue.line.length).trim(); - issueGenerationText = document.getText( - newIssue.range.isEmpty ? document.lineAt(newIssue.range.start.line).range : newIssue.range, - ); - } else { - return undefined; - } - const matches = issueGenerationText.match(USER_EXPRESSION); - if (matches && matches.length === 2 && (await this._stateManager.getUserMap(document.uri)).has(matches[1])) { - assignee = [matches[1]]; - } - let title: string | undefined; - const body: string | undefined = await this.createTodoIssueBody(newIssue, issueBody); - - const quickInput = vscode.window.createInputBox(); - quickInput.value = titlePlaceholder ?? ''; - quickInput.prompt = - vscode.l10n.t('Set the issue title. Confirm to create the issue now or use the edit button to edit the issue title and description.'); - quickInput.title = vscode.l10n.t('Create Issue'); - quickInput.buttons = [ - { - iconPath: new vscode.ThemeIcon('edit'), - tooltip: vscode.l10n.t('Edit Description'), - }, - ]; - quickInput.onDidAccept(async () => { - title = quickInput.value; - if (title) { - quickInput.busy = true; - await this.doCreateIssue(document, newIssue, title, body, assignee, undefined, lineNumber, insertIndex); - quickInput.busy = false; - } - quickInput.hide(); - }); - quickInput.onDidTriggerButton(async () => { - title = quickInput.value; - quickInput.busy = true; - this.createIssueInfo = { document, newIssue, lineNumber, insertIndex }; - - this.makeNewIssueFile(document.uri, title, body, assignee); - quickInput.busy = false; - quickInput.hide(); - }); - quickInput.show(); - - return undefined; - } - - private async makeNewIssueFile( - originUri: vscode.Uri, - title?: string, - body?: string, - assignees?: string[] | undefined, - ) { - const query = `?{"origin":"${originUri.toString()}"}`; - const bodyPath = vscode.Uri.parse(`${NEW_ISSUE_SCHEME}:/${NEW_ISSUE_FILE}${query}`); - if ( - vscode.window.visibleTextEditors.filter( - visibleEditor => visibleEditor.document.uri.scheme === NEW_ISSUE_SCHEME, - ).length > 0 - ) { - return; - } - await vscode.workspace.fs.delete(bodyPath); - const assigneeLine = `${ASSIGNEES} ${assignees && assignees.length > 0 ? assignees.map(value => '@' + value).join(', ') + ' ' : '' - }`; - const labelLine = `${LABELS} `; - const cached = this._newIssueCache.get(); - const text = (cached && cached !== '') ? cached : `${title ?? vscode.l10n.t('Issue Title')}\n -${assigneeLine} -${labelLine}\n -${body ?? ''}\n -`; - await vscode.workspace.fs.writeFile(bodyPath, this.stringToUint8Array(text)); - const assigneesDecoration = vscode.window.createTextEditorDecorationType({ - after: { - contentText: vscode.l10n.t(' Comma-separated usernames, either @username or just username.'), - fontStyle: 'italic', - color: new vscode.ThemeColor('issues.newIssueDecoration'), - }, - }); - const labelsDecoration = vscode.window.createTextEditorDecorationType({ - after: { - contentText: vscode.l10n.t(' Comma-separated labels.'), - fontStyle: 'italic', - color: new vscode.ThemeColor('issues.newIssueDecoration'), - }, - }); - const editorChangeDisposable = vscode.window.onDidChangeActiveTextEditor(textEditor => { - if (textEditor?.document.uri.scheme === NEW_ISSUE_SCHEME) { - const assigneeFullLine = textEditor.document.lineAt(2); - if (assigneeFullLine.text.startsWith(ASSIGNEES)) { - textEditor.setDecorations(assigneesDecoration, [ - new vscode.Range( - new vscode.Position(2, 0), - new vscode.Position(2, assigneeFullLine.text.length), - ), - ]); - } - const labelFullLine = textEditor.document.lineAt(3); - if (labelFullLine.text.startsWith(LABELS)) { - textEditor.setDecorations(labelsDecoration, [ - new vscode.Range(new vscode.Position(3, 0), new vscode.Position(3, labelFullLine.text.length)), - ]); - } - } - }); - - const editor = await vscode.window.showTextDocument(bodyPath); - const closeDisposable = vscode.workspace.onDidCloseTextDocument(textDocument => { - if (textDocument === editor.document) { - editorChangeDisposable.dispose(); - closeDisposable.dispose(); - } - }); - } - - private async verifyLabels( - folderManager: FolderRepositoryManager, - createParams: OctokitCommon.IssuesCreateParams, - ): Promise { - if (!createParams.labels) { - return true; - } - const allLabels = (await folderManager.getLabels(undefined, createParams)).map(label => label.name); - const newLabels: string[] = []; - const filteredLabels: string[] = []; - createParams.labels?.forEach(paramLabel => { - let label = typeof paramLabel === 'string' ? paramLabel : paramLabel.name; - if (!label) { - return; - } - - if (allLabels.includes(label)) { - filteredLabels.push(label); - } else { - newLabels.push(label); - } - }); - - if (newLabels.length > 0) { - const yes = vscode.l10n.t('Yes'); - const no = vscode.l10n.t('No'); - const promptResult = await vscode.window.showInformationMessage( - vscode.l10n.t('The following labels don\'t exist in this repository: {0}. \nDo you want to create these labels?', newLabels.join( - ', ', - )), - { modal: true }, - yes, - no, - ); - switch (promptResult) { - case yes: - return true; - case no: { - createParams.labels = filteredLabels; - return true; - } - default: - return false; - } - } - return true; - } - - private async chooseRepo(prompt: string): Promise { - interface RepoChoice extends vscode.QuickPickItem { - repo: FolderRepositoryManager; - } - const choices: RepoChoice[] = []; - for (const folderManager of this.manager.folderManagers) { - try { - const defaults = await folderManager.getPullRequestDefaults(); - choices.push({ - label: `${defaults.owner}/${defaults.repo}`, - repo: folderManager, - }); - } catch (e) { - // ignore - } - } - if (choices.length === 0) { - return; - } else if (choices.length === 1) { - return choices[0].repo; - } - - const choice = await vscode.window.showQuickPick(choices, { placeHolder: prompt }); - return choice?.repo; - } - - private async doCreateIssue( - document: vscode.TextDocument | undefined, - newIssue: NewIssue | undefined, - title: string, - issueBody: string | undefined, - assignees: string[] | undefined, - labels: string[] | undefined, - lineNumber: number | undefined, - insertIndex: number | undefined, - originUri?: vscode.Uri, - ): Promise { - let origin: PullRequestDefaults | undefined; - let folderManager: FolderRepositoryManager | undefined; - if (document) { - folderManager = this.manager.getManagerForFile(document.uri); - } else if (originUri) { - folderManager = this.manager.getManagerForFile(originUri); - } - if (!folderManager) { - folderManager = await this.chooseRepo(vscode.l10n.t('Choose where to create the issue.')); - } - - if (!folderManager) { - return false; - } - try { - origin = await folderManager.getPullRequestDefaults(); - } catch (e) { - // There is no remote - vscode.window.showErrorMessage(vscode.l10n.t('There is no remote. Can\'t create an issue.')); - return false; - } - const body: string | undefined = - issueBody || newIssue?.document.isUntitled - ? issueBody - : (await createGithubPermalink(this.manager, this.gitAPI, newIssue)).permalink; - const createParams: OctokitCommon.IssuesCreateParams = { - owner: origin.owner, - repo: origin.repo, - title, - body, - assignees, - labels, - }; - if (!(await this.verifyLabels(folderManager, createParams))) { - return false; - } - const issue = await folderManager.createIssue(createParams); - if (issue) { - if (document !== undefined && insertIndex !== undefined && lineNumber !== undefined) { - const edit: vscode.WorkspaceEdit = new vscode.WorkspaceEdit(); - const insertText: string = - vscode.workspace.getConfiguration(ISSUES_CONFIGURATION).get('createInsertFormat', 'number') === - 'number' - ? `#${issue.number}` - : issue.html_url; - edit.insert(document.uri, new vscode.Position(lineNumber, insertIndex), ` ${insertText}`); - await vscode.workspace.applyEdit(edit); - } else { - const copyIssueUrl = vscode.l10n.t('Copy Issue Link'); - const openIssue = vscode.l10n.t({ message: 'Open Issue', comment: 'Open the issue description in the browser to see it\'s full contents.' }); - vscode.window.showInformationMessage(vscode.l10n.t('Issue created'), copyIssueUrl, openIssue).then(async result => { - switch (result) { - case copyIssueUrl: - await vscode.env.clipboard.writeText(issue.html_url); - break; - case openIssue: - await vscode.env.openExternal(vscode.Uri.parse(issue.html_url)); - break; - } - }); - } - this._stateManager.refreshCacheNeeded(); - return true; - } - return false; - } - - private async getPermalinkWithError(repositoriesManager: RepositoriesManager, fileUri?: vscode.Uri): Promise { - const link = await createGithubPermalink(repositoriesManager, this.gitAPI, undefined, fileUri); - if (link.error) { - vscode.window.showWarningMessage(vscode.l10n.t('Unable to create a GitHub permalink for the selection. {0}', link.error)); - } - return link; - } - - private async getHeadLinkWithError(fileUri?: vscode.Uri): Promise { - const link = await createGitHubLink(this.manager, fileUri); - if (link.error) { - vscode.window.showWarningMessage(vscode.l10n.t('Unable to create a GitHub link for the selection. {0}', link.error)); - } - return link; - } - - private async getContextualizedLink(file: vscode.Uri, link: string): Promise { - let uri: vscode.Uri; - try { - uri = await vscode.env.asExternalUri(file); - } catch (e) { - // asExternalUri can throw when in the browser and the embedder doesn't set a uri resolver. - return link; - } - const authority = (uri.scheme === 'https' && /^(insiders\.vscode|vscode|github)\./.test(uri.authority)) ? uri.authority : undefined; - if (!authority) { - return link; - } - const linkUri = vscode.Uri.parse(link); - const linkPath = /^(github)\./.test(uri.authority) ? linkUri.path : `/github${linkUri.path}`; - return linkUri.with({ authority, path: linkPath }).toString(); - } - - async copyPermalink(repositoriesManager: RepositoriesManager, fileUri?: vscode.Uri) { - const link = await this.getPermalinkWithError(repositoriesManager, fileUri); - if (link.permalink) { - return vscode.env.clipboard.writeText( - link.originalFile ? (await this.getContextualizedLink(link.originalFile, link.permalink)) : link.permalink); - } - } - - async copyHeadLink(fileUri?: vscode.Uri) { - const link = await this.getHeadLinkWithError(fileUri); - if (link.permalink) { - return vscode.env.clipboard.writeText( - link.originalFile ? (await this.getContextualizedLink(link.originalFile, link.permalink)) : link.permalink); - } - } - - private getMarkdownLinkText(): string | undefined { - if (!vscode.window.activeTextEditor) { - return undefined; - } - let editorSelection: vscode.Range | undefined = vscode.window.activeTextEditor.selection; - if (editorSelection.start.line !== editorSelection.end.line) { - editorSelection = new vscode.Range( - editorSelection.start, - new vscode.Position(editorSelection.start.line + 1, 0), - ); - } - const selection = vscode.window.activeTextEditor.document.getText(editorSelection); - if (selection) { - return selection; - } - editorSelection = vscode.window.activeTextEditor.document.getWordRangeAtPosition(editorSelection.start); - if (editorSelection) { - return vscode.window.activeTextEditor.document.getText(editorSelection); - } - return undefined; - } - - async copyMarkdownPermalink(repositoriesManager: RepositoriesManager) { - const link = await this.getPermalinkWithError(repositoriesManager); - const selection = this.getMarkdownLinkText(); - if (link.permalink && selection) { - return vscode.env.clipboard.writeText(`[${selection.trim()}](${link.permalink})`); - } - } - - async openPermalink(repositoriesManager: RepositoriesManager) { - const link = await this.getPermalinkWithError(repositoriesManager); - if (link.permalink) { - return vscode.env.openExternal(vscode.Uri.parse(link.permalink)); - } - return undefined; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { basename } from 'path'; +import * as yaml from 'js-yaml'; +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'; +import { commands } from '../common/executeCommands'; +import { Disposable } from '../common/lifecycle'; +import Logger from '../common/logger'; +import { + ALWAYS_PROMPT_FOR_NEW_ISSUE_REPO, + CREATE_INSERT_FORMAT, + ENABLED, + ISSUE_COMPLETIONS, + ISSUES_SETTINGS_NAMESPACE, + USER_COMPLETIONS, + WORKING_BASE_BRANCH, +} from '../common/settingKeys'; +import { editQuery } from '../common/settingsUtils'; +import { ITelemetry } from '../common/telemetry'; +import { fromRepoUri, RepoUriParams, Schemes, toNewIssueUri } from '../common/uri'; +import { EXTENSION_ID } from '../constants'; +import { + ASSIGNEES, + extractMetadataFromFile, + IssueFileSystemProvider, + LABELS, + MILESTONE, + NewIssueCache, + NewIssueFileCompletionProvider, + NewIssueFileOptions, + PROJECTS, +} from './issueFile'; +import { IssueHoverProvider } from './issueHoverProvider'; +import { openCodeLink } from './issueLinkLookup'; +import { IssuesTreeData, QueryNode, updateExpandedQueries } from './issuesView'; +import { IssueTodoProvider } from './issueTodoProvider'; +import { ShareProviderManager } from './shareProviders'; +import { StateManager } from './stateManager'; +import { UserCompletionProvider } from './userCompletionProvider'; +import { UserHoverProvider } from './userHoverProvider'; +import { + createGitHubLink, + createGithubPermalink, + createSinglePermalink, + getIssue, + IssueTemplate, + LinkContext, + NewIssue, + PERMALINK_COMPONENT, + PermalinkInfo, + pushAndCreatePR, + USER_EXPRESSION, + YamlIssueTemplate, +} from './util'; +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 { ReviewManager } from '../view/reviewManager'; +import { ReviewsManager } from '../view/reviewsManager'; +import { PRNode } from '../view/treeNodes/pullRequestNode'; + +const CREATING_ISSUE_FROM_FILE_CONTEXT = 'issues.creatingFromFile'; + +export class IssueFeatureRegistrar extends Disposable { + private static readonly ID = 'IssueFeatureRegistrar'; + private _newIssueCache: NewIssueCache; + + private createIssueInfo: + | { + document: vscode.TextDocument; + newIssue: NewIssue | undefined; + lineNumber: number | undefined; + insertIndex: number | undefined; + } + | undefined; + + constructor( + private gitAPI: GitApiImpl, + private manager: RepositoriesManager, + private reviewsManager: ReviewsManager, + private context: vscode.ExtensionContext, + private telemetry: ITelemetry, + private readonly _stateManager: StateManager, + private copilotRemoteAgentManager: CopilotRemoteAgentManager, + ) { + super(); + this._newIssueCache = new NewIssueCache(context); + } + + async initialize() { + this._register(vscode.workspace.registerFileSystemProvider(Schemes.NewIssue, new IssueFileSystemProvider(this._newIssueCache))); + this._register( + vscode.languages.registerCompletionItemProvider( + { scheme: Schemes.NewIssue }, + new NewIssueFileCompletionProvider(this.manager), + ' ', + ',', + ), + ); + const view = vscode.window.createTreeView('issues:github', { + showCollapseAll: true, + treeDataProvider: new IssuesTreeData(this._stateManager, this.manager, this.context), + }); + this._register(view); + this._register(view.onDidCollapseElement(e => updateExpandedQueries(this.context, e.element, false))); + this._register(view.onDidExpandElement(e => updateExpandedQueries(this.context, e.element, true))); + this._register( + vscode.commands.registerCommand( + 'issue.createIssueFromSelection', + (newIssue?: NewIssue, issueBody?: string) => { + /* __GDPR__ + "issue.createIssueFromSelection" : {} + */ + this.telemetry.sendTelemetryEvent('issue.createIssueFromSelection'); + return this.createTodoIssue(newIssue, issueBody); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'issue.createIssueFromClipboard', + () => { + /* __GDPR__ + "issue.createIssueFromClipboard" : {} + */ + this.telemetry.sendTelemetryEvent('issue.createIssueFromClipboard'); + return this.createTodoIssueClipboard(); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'issue.assignToCodingAgent', + (issueModel: any) => { + /* __GDPR__ + "issue.assignToCodingAgent" : {} + */ + this.telemetry.sendTelemetryEvent('issue.assignToCodingAgent'); + return this.assignToCodingAgent(issueModel); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'issue.copyGithubPermalink', + (context: LinkContext, additional: LinkContext[] | undefined) => { + /* __GDPR__ + "issue.copyGithubPermalink" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyGithubPermalink'); + return this.copyPermalink(this.manager, additional && additional.length > 0 ? additional : [context]); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'issue.copyGithubHeadLink', + (fileUri: vscode.Uri, additional: vscode.Uri[] | undefined) => { + /* __GDPR__ + "issue.copyGithubHeadLink" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyGithubHeadLink'); + return this.copyHeadLink(additional && additional.length > 0 ? additional : [fileUri]); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'issue.copyGithubPermalinkWithoutRange', + (context: LinkContext, additional: LinkContext[] | undefined) => { + /* __GDPR__ + "issue.copyGithubPermalinkWithoutRange" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyGithubPermalinkWithoutRange'); + return this.copyPermalink(this.manager, additional && additional.length > 0 ? additional : [context], false); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'issue.copyGithubHeadLinkWithoutRange', + (fileUri: vscode.Uri, additional: vscode.Uri[] | undefined) => { + /* __GDPR__ + "issue.copyGithubHeadLinkWithoutRange" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyGithubHeadLinkWithoutRange'); + return this.copyHeadLink(additional && additional.length > 0 ? additional : [fileUri], false); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'issue.copyGithubDevLinkWithoutRange', + (context: LinkContext, additional: LinkContext[] | undefined) => { + /* __GDPR__ + "issue.copyGithubDevLinkWithoutRange" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyGithubDevLinkWithoutRange'); + return this.copyPermalink(this.manager, additional && additional.length > 0 ? additional : [context], false, true, true); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'issue.copyGithubDevLink', + (context: LinkContext, additional: LinkContext[] | undefined) => { + /* __GDPR__ + "issue.copyGithubDevLink" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyGithubDevLink'); + return this.copyPermalink(this.manager, additional && additional.length > 0 ? additional : [context], true, true, true); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'issue.copyGithubDevLinkFile', + (context: LinkContext, additional: LinkContext[] | undefined) => { + /* __GDPR__ + "issue.copyGithubDevLinkFile" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyGithubDevLinkFile'); + return this.copyPermalink(this.manager, additional && additional.length > 0 ? additional : [context], false, true, true); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'issue.copyMarkdownGithubPermalink', + (context: LinkContext, additional: LinkContext[] | undefined) => { + /* __GDPR__ + "issue.copyMarkdownGithubPermalink" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyMarkdownGithubPermalink'); + return this.copyMarkdownPermalink(this.manager, additional && additional.length > 0 ? additional : [context]); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'issue.copyMarkdownGithubPermalinkWithoutRange', + (context: LinkContext, additional: LinkContext[] | undefined) => { + /* __GDPR__ + "issue.copyMarkdownGithubPermalinkWithoutRange" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyMarkdownGithubPermalinkWithoutRange'); + return this.copyMarkdownPermalink(this.manager, additional && additional.length > 0 ? additional : [context], false); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'issue.openGithubPermalink', + () => { + /* __GDPR__ + "issue.openGithubPermalink" : {} + */ + this.telemetry.sendTelemetryEvent('issue.openGithubPermalink'); + return this.openPermalink(this.manager); + }, + this, + ), + ); + this._register(new ShareProviderManager(this.manager, this.gitAPI)); + this._register( + vscode.commands.registerCommand('issue.openIssue', (issueModel: any) => { + /* __GDPR__ + "issue.openIssue" : {} + */ + this.telemetry.sendTelemetryEvent('issue.openIssue'); + return this.openIssue(issueModel); + }), + ); + this._register( + vscode.commands.registerCommand('issue.openIssueOnGitHub', async () => { + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showWarningMessage(vscode.l10n.t('No active editor. Open a file and place the cursor on an issue reference.')); + return; + } + + const document = editor.document; + const position = editor.selection.active; + + const wordRange = document.getWordRangeAtPosition(position, ISSUE_OR_URL_EXPRESSION); + if (!wordRange) { + vscode.window.showWarningMessage(vscode.l10n.t('No issue reference found at cursor position.')); + return; + } + + const word = document.getText(wordRange); + const match = word.match(ISSUE_OR_URL_EXPRESSION); + const parsed = parseIssueExpressionOutput(match); + + if (!parsed) { + vscode.window.showWarningMessage(vscode.l10n.t('Invalid issue reference.')); + return; + } + + const folderManager = this.manager.getManagerForFile(document.uri) ?? this.manager.folderManagers[0]; + if (!folderManager) { + vscode.window.showWarningMessage(vscode.l10n.t('No repository found for current file.')); + return; + } + + const issue = await getIssue(this._stateManager, folderManager, word, parsed); + if (!issue) { + vscode.window.showWarningMessage(vscode.l10n.t('Unable to resolve issue.')); + return; + } + + vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(issue.html_url)); + + /* __GDPR__ + "issue.openOnGitHub" : {} + */ + this.telemetry.sendTelemetryEvent('issue.openOnGitHub'); + }), + ); + this._register( + vscode.commands.registerCommand( + 'issue.startWorking', + (issue: any) => { + /* __GDPR__ + "issue.startWorking" : {} + */ + this.telemetry.sendTelemetryEvent('issue.startWorking'); + return this.startWorking(issue); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'issue.startWorkingBranchDescriptiveTitle', + (issue: any) => { + /* __GDPR__ + "issue.startWorking" : {} + */ + this.telemetry.sendTelemetryEvent('issue.startWorking'); + return this.startWorking(issue); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'issue.continueWorking', + (issue: any) => { + /* __GDPR__ + "issue.continueWorking" : {} + */ + this.telemetry.sendTelemetryEvent('issue.continueWorking'); + return this.startWorking(issue); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'issue.startWorkingBranchPrompt', + (issueModel: any) => { + /* __GDPR__ + "issue.startWorkingBranchPrompt" : {} + */ + this.telemetry.sendTelemetryEvent('issue.startWorkingBranchPrompt'); + return this.startWorkingBranchPrompt(issueModel); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'issue.stopWorking', + (issueModel: any) => { + /* __GDPR__ + "issue.stopWorking" : {} + */ + this.telemetry.sendTelemetryEvent('issue.stopWorking'); + return this.stopWorking(issueModel); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'issue.stopWorkingBranchDescriptiveTitle', + (issueModel: any) => { + /* __GDPR__ + "issue.stopWorking" : {} + */ + this.telemetry.sendTelemetryEvent('issue.stopWorking'); + return this.stopWorking(issueModel); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'issue.statusBar', + () => { + /* __GDPR__ + "issue.statusBar" : {} + */ + this.telemetry.sendTelemetryEvent('issue.statusBar'); + return this.statusBar(); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand('issue.copyIssueNumber', (issueModel: any) => { + /* __GDPR__ + "issue.copyIssueNumber" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyIssueNumber'); + return this.copyIssueNumber(issueModel); + }), + ); + this._register( + vscode.commands.registerCommand('issue.copyIssueUrl', (issueModel: any) => { + /* __GDPR__ + "issue.copyIssueUrl" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyIssueUrl'); + return this.copyIssueUrl(issueModel); + }), + ); + this._register( + vscode.commands.registerCommand( + 'issue.refresh', + () => { + /* __GDPR__ + "issue.refresh" : {} + */ + this.telemetry.sendTelemetryEvent('issue.refresh'); + return this.refreshView(); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'issue.suggestRefresh', + () => { + /* __GDPR__ + "issue.suggestRefresh" : {} + */ + this.telemetry.sendTelemetryEvent('issue.suggestRefresh'); + return this.suggestRefresh(); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'issue.getCurrent', + () => { + /* __GDPR__ + "issue.getCurrent" : {} + */ + this.telemetry.sendTelemetryEvent('issue.getCurrent'); + return this.getCurrent(); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'issue.editQuery', + (query: QueryNode) => { + /* __GDPR__ + "issue.editQuery" : {} + */ + this.telemetry.sendTelemetryEvent('issue.editQuery'); + return editQuery(ISSUES_SETTINGS_NAMESPACE, query.queryLabel); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'issue.createIssue', + () => { + /* __GDPR__ + "issue.createIssue" : {} + */ + this.telemetry.sendTelemetryEvent('issue.createIssue'); + return this.createIssue(); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'issue.createIssueFromFile', + async () => { + /* __GDPR__ + "issue.createIssueFromFile" : {} + */ + this.telemetry.sendTelemetryEvent('issue.createIssueFromFile'); + await vscode.commands.executeCommand('setContext', CREATING_ISSUE_FROM_FILE_CONTEXT, true); + await this.createIssueFromFile(); + await vscode.commands.executeCommand('setContext', CREATING_ISSUE_FROM_FILE_CONTEXT, false); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand('issue.issueCompletion', () => { + /* __GDPR__ + "issue.issueCompletion" : {} + */ + this.telemetry.sendTelemetryEvent('issue.issueCompletion'); + }), + ); + this._register( + vscode.commands.registerCommand('issue.userCompletion', () => { + /* __GDPR__ + "issue.userCompletion" : {} + */ + this.telemetry.sendTelemetryEvent('issue.userCompletion'); + }), + ); + this._register( + vscode.commands.registerCommand('issue.signinAndRefreshList', async () => { + return this.manager.authenticate(); + }), + ); + this._register( + vscode.commands.registerCommand('issue.goToLinkedCode', async (issueModel: any) => { + return openCodeLink(issueModel, this.manager); + }), + ); + this._register( + vscode.commands.registerCommand('issue.chatSummarizeIssue', (issue: any) => { + if (!(issue instanceof IssueModel || issue instanceof PRNode)) { + return; + } + /* __GDPR__ + "issue.chatSummarizeIssue" : {} + */ + this.telemetry.sendTelemetryEvent('issue.chatSummarizeIssue'); + if (issue instanceof IssueModel) { + commands.executeCommand(commands.NEW_CHAT, { inputValue: vscode.l10n.t('@githubpr Summarize issue {0}/{1}#{2}', issue.remote.owner, issue.remote.repositoryName, issue.number) }); + } else { + const pullRequestModel = issue.pullRequestModel; + const remote = pullRequestModel.githubRepository.remote; + commands.executeCommand(commands.NEW_CHAT, { inputValue: vscode.l10n.t('@githubpr Summarize pull request {0}/{1}#{2}', remote.owner, remote.repositoryName, pullRequestModel.number) }); + } + }), + ); + this._register( + vscode.commands.registerCommand('issue.chatSuggestFix', (issue: any) => { + if (!(issue instanceof IssueModel)) { + return; + } + /* __GDPR__ + "issue.chatSuggestFix" : {} + */ + this.telemetry.sendTelemetryEvent('issue.chatSuggestFix'); + commands.executeCommand(commands.NEW_CHAT, { inputValue: vscode.l10n.t('@githubpr Find a fix for issue {0}/{1}#{2}', issue.remote.owner, issue.remote.repositoryName, issue.number) }); + }), + ); + this._register(vscode.commands.registerCommand('issues.configureIssuesViewlet', async () => { + /* __GDPR__ + "issues.configureIssuesViewlet" : {} + */ + return vscode.commands.executeCommand( + 'workbench.action.openSettings', + `@ext:${EXTENSION_ID} issues`, + ); + })); + this._stateManager.tryInitializeAndWait().then(() => { + this.registerCompletionProviders(); + + this._register( + vscode.languages.registerHoverProvider( + '*', + new IssueHoverProvider(this.manager, this._stateManager, this.context, this.telemetry), + ), + ); + this._register( + vscode.languages.registerHoverProvider('*', new UserHoverProvider(this.manager, this.telemetry)), + ); + const todoProvider = new IssueTodoProvider(this.context, this.copilotRemoteAgentManager); + this._register( + vscode.languages.registerCodeActionsProvider('*', todoProvider, { providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] }), + ); + }); + } + + private documentFilters: Array = [ + { language: 'php' }, + { language: 'powershell' }, + { language: 'jade' }, + { language: 'python' }, + { language: 'r' }, + { language: 'razor' }, + { language: 'ruby' }, + { language: 'rust' }, + { language: 'scminput' }, + { language: 'scss' }, + { language: 'search-result' }, + { language: 'shaderlab' }, + { language: 'shellscript' }, + { language: 'sql' }, + { language: 'swift' }, + { language: 'typescript' }, + { language: 'vb' }, + { language: 'xml' }, + { language: 'yaml' }, + { language: 'markdown' }, + { language: 'bat' }, + { language: 'clojure' }, + { language: 'coffeescript' }, + { language: 'jsonc' }, + { language: 'c' }, + { language: 'cpp' }, + { language: 'csharp' }, + { language: 'css' }, + { language: 'dockerfile' }, + { language: 'fsharp' }, + { language: 'git-commit' }, + { language: 'go' }, + { language: 'groovy' }, + { language: 'handlebars' }, + { language: 'hlsl' }, + { language: 'html' }, + { language: 'ini' }, + { language: 'java' }, + { language: 'javascriptreact' }, + { language: 'javascript' }, + { language: 'json' }, + { language: 'less' }, + { language: 'log' }, + { language: 'lua' }, + { language: 'makefile' }, + { language: 'ignore' }, + { language: 'properties' }, + { language: 'objective-c' }, + { language: 'perl' }, + { language: 'perl6' }, + { language: 'typescriptreact' }, + { language: 'yml' }, + '*', + ]; + private registerCompletionProviders() { + const providers: { + provider: typeof IssueCompletionProvider | typeof UserCompletionProvider; + trigger: string; + disposable: vscode.Disposable | undefined; + configuration: string; + }[] = [ + { + provider: IssueCompletionProvider, + trigger: '#', + disposable: undefined, + configuration: `${ISSUE_COMPLETIONS}.${ENABLED}`, + }, + { + provider: UserCompletionProvider, + trigger: '@', + disposable: undefined, + configuration: `${USER_COMPLETIONS}.${ENABLED}`, + }, + ]; + for (const element of providers) { + if (vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(element.configuration, true)) { + this._register( + (element.disposable = vscode.languages.registerCompletionItemProvider( + this.documentFilters, + new element.provider(this._stateManager, this.manager, this.context), + element.trigger, + )), + ); + } + } + this._register( + vscode.workspace.onDidChangeConfiguration(change => { + for (const element of providers) { + if (change.affectsConfiguration(`${ISSUES_SETTINGS_NAMESPACE}.${element.configuration}`)) { + const newValue: boolean = vscode.workspace + .getConfiguration(ISSUES_SETTINGS_NAMESPACE) + .get(element.configuration, true); + if (!newValue && element.disposable) { + element.disposable.dispose(); + element.disposable = undefined; + } else if (newValue && !element.disposable) { + this._register( + (element.disposable = vscode.languages.registerCompletionItemProvider( + this.documentFilters, + new element.provider(this._stateManager, this.manager, this.context), + element.trigger, + )), + ); + } + break; + } + } + }), + ); + } + + async createIssue() { + let uri = vscode.window.activeTextEditor?.document.uri; + let folderManager: FolderRepositoryManager | undefined = uri ? this.manager.getManagerForFile(uri) : undefined; + + const alwaysPrompt = vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(ALWAYS_PROMPT_FOR_NEW_ISSUE_REPO); + if (!folderManager || alwaysPrompt) { + folderManager = await this.chooseRepo(vscode.l10n.t('Select the repo to create the issue in.')); + uri = folderManager?.repository.rootUri; + } + if (!folderManager || !uri) { + return; + } + + const template = await this.chooseTemplate(folderManager); + this._newIssueCache.clear(); + + const remoteName = folderManager.repository.state.HEAD?.upstream?.remote; + let remote = remoteName ? folderManager.repository.state.remotes.find(r => r.name === remoteName) : undefined; + + if (!remote) { + const potentialRemotes = folderManager.repository.state.remotes.filter(r => r.fetchUrl || r.pushUrl); + interface RemoteChoice extends vscode.QuickPickItem { + remote: Remote; + } + const choices: RemoteChoice[] = potentialRemotes.map(remote => ({ + label: `${remote.name}: ${remote.fetchUrl || remote.pushUrl}`, + remote, + })); + + const choice = await vscode.window.showQuickPick(choices, { placeHolder: vscode.l10n.t('Select a remote to file this issue to') }); + if (!choice) { + return; + } + remote = choice.remote; + } + + let options: NewIssueFileOptions = { remote }; + if (template) { + options = { + ...options, + title: template.title, + body: template.body, + labels: template.labels, + assignees: template.assignees, + }; + } + await this.makeNewIssueFile(uri, options); + } + + async createIssueFromFile() { + const metadata = await extractMetadataFromFile(this.manager); + if (!metadata || !vscode.window.activeTextEditor) { + return; + } + const createSucceeded = await this.doCreateIssue( + this.createIssueInfo?.document, + this.createIssueInfo?.newIssue, + metadata.title, + metadata.body, + metadata.assignees, + metadata.labels, + metadata.milestone, + metadata.projects, + this.createIssueInfo?.lineNumber, + this.createIssueInfo?.insertIndex, + metadata.originUri + ); + this.createIssueInfo = undefined; + if (createSucceeded && vscode.window.tabGroups.activeTabGroup.activeTab) { + await vscode.window.activeTextEditor.document.save(); + await vscode.window.tabGroups.close(vscode.window.tabGroups.activeTabGroup.activeTab); + this._newIssueCache.clear(); + } + } + + getCurrent() { + // This is used by the "api" command issues.getCurrent + const currentIssues = this._stateManager.currentIssues(); + if (currentIssues.length > 0) { + return { + owner: currentIssues[0].issue.remote.owner, + repo: currentIssues[0].issue.remote.repositoryName, + number: currentIssues[0].issue.number, + }; + } + return undefined; + } + + refreshView() { + this._stateManager.refreshCacheNeeded(); + } + + async suggestRefresh() { + await vscode.commands.executeCommand('hideSuggestWidget'); + await this._stateManager.refresh(); + return vscode.commands.executeCommand('editor.action.triggerSuggest'); + } + + openIssue(issueModel: any) { + if (issueModel instanceof IssueModel) { + return vscode.env.openExternal(vscode.Uri.parse(issueModel.html_url)); + } + return undefined; + } + + async doStartWorking( + matchingRepoManager: FolderRepositoryManager | undefined, + issueModel: IssueModel, + needsBranchPrompt?: boolean, + ) { + let repoManager = matchingRepoManager; + let githubRepository = issueModel.githubRepository; + let remote = issueModel.remote; + if (!repoManager) { + repoManager = await this.chooseRepo(vscode.l10n.t('Choose which repository you want to work on this issue in.')); + if (!repoManager) { + return; + } + githubRepository = await repoManager.getOrigin(); + remote = githubRepository.remote; + } + + const remoteNameResult = await repoManager.findUpstreamForItem({ githubRepository, remote }); + if (remoteNameResult.needsFork) { + if ((await repoManager.tryOfferToFork(githubRepository)) === undefined) { + return; + } + } + + // Determine whether to checkout the default branch based on workingBaseBranch setting + const workingBaseBranchConfig = vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(WORKING_BASE_BRANCH); + let checkoutDefaultBranch = false; + + if (workingBaseBranchConfig === 'defaultBranch') { + checkoutDefaultBranch = true; + } else if (workingBaseBranchConfig === 'prompt') { + const currentBranchName = repoManager.repository.state.HEAD?.name; + const defaults = await repoManager.getPullRequestDefaults(); + const defaultBranchName = defaults.base; + + if (!currentBranchName) { + // If we can't determine the current branch, default to the default branch + checkoutDefaultBranch = true; + } else if (currentBranchName === defaultBranchName) { + // If already on the default branch, no need to prompt + checkoutDefaultBranch = false; + } else { + const choice = await vscode.window.showQuickPick([currentBranchName, defaultBranchName], { + placeHolder: vscode.l10n.t('Which branch should be used as the base for the new issue branch?'), + }); + if (choice === undefined) { + // User cancelled the prompt + return; + } + checkoutDefaultBranch = choice === defaultBranchName; + } + } + // else workingBaseBranchConfig === 'currentBranch', checkoutDefaultBranch remains false + + await this._stateManager.setCurrentIssue( + repoManager, + new CurrentIssue(issueModel, repoManager, this._stateManager, remoteNameResult.remote, needsBranchPrompt), + checkoutDefaultBranch + ); + } + + async startWorking(issue: any) { + if (issue instanceof IssueModel) { + return this.doStartWorking(this.manager.getManagerForIssueModel(issue), issue); + } else if (issue instanceof vscode.Uri) { + const match = issue.toString().match(ISSUE_OR_URL_EXPRESSION); + const parsed = parseIssueExpressionOutput(match); + const folderManager = this.manager.folderManagers.find(folderManager => + folderManager.gitHubRepositories.find(repo => repo.remote.owner === parsed?.owner && repo.remote.repositoryName === parsed.name)); + if (parsed && folderManager) { + const issueModel = await getIssue(this._stateManager, folderManager, issue.toString(), parsed); + if (issueModel) { + return this.doStartWorking(folderManager, issueModel); + } + } + } + } + + async startWorkingBranchPrompt(issueModel: any) { + if (!(issueModel instanceof IssueModel)) { + return; + } + this.doStartWorking(this.manager.getManagerForIssueModel(issueModel), issueModel, true); + } + + async stopWorking(issueModel: any) { + let folderManager = this.manager.getManagerForIssueModel(issueModel); + if (!folderManager) { + folderManager = await this.chooseRepo(vscode.l10n.t('Choose which repository you want to stop working on this issue in.')); + if (!folderManager) { + return; + } + } + if ( + issueModel instanceof IssueModel && + this._stateManager.currentIssue(folderManager.repository.rootUri)?.issue.number === issueModel.number + ) { + await this._stateManager.setCurrentIssue(folderManager, undefined, true); + } + } + + private async statusBarActions(currentIssue: CurrentIssue) { + const openIssueText: string = vscode.l10n.t('{0} Open #{1} {2}', '$(globe)', currentIssue.issue.number, currentIssue.issue.title); + const pullRequestText: string = vscode.l10n.t({ message: '{0} Create pull request for #{1} (pushes branch)', args: ['$(git-pull-request)', currentIssue.issue.number], comment: ['The first placeholder is an icon and shouldn\'t be localized', 'The second placeholder is the ID number of a GitHub Issue.'] }); + let defaults: PullRequestDefaults | undefined; + try { + defaults = await currentIssue.manager.getPullRequestDefaults(); + } catch (e) { + // leave defaults undefined + } + const stopWorkingText: string = vscode.l10n.t('{0} Stop working on #{1}', '$(primitive-square)', currentIssue.issue.number); + const choices = + currentIssue.branchName && defaults + ? [openIssueText, pullRequestText, stopWorkingText] + : [openIssueText, pullRequestText, stopWorkingText]; + const response: string | undefined = await vscode.window.showQuickPick(choices, { + placeHolder: vscode.l10n.t('Current issue options'), + }); + switch (response) { + case openIssueText: + return this.openIssue(currentIssue.issue); + case pullRequestText: { + const reviewManager = ReviewManager.getReviewManagerForFolderManager( + this.reviewsManager.reviewManagers, + currentIssue.manager, + ); + if (reviewManager) { + return pushAndCreatePR(currentIssue.manager, reviewManager, this._stateManager); + } + break; + } + case stopWorkingText: + return this._stateManager.setCurrentIssue(currentIssue.manager, undefined, true); + } + } + + async statusBar() { + const currentIssues = this._stateManager.currentIssues(); + if (currentIssues.length === 1) { + return this.statusBarActions(currentIssues[0]); + } else { + interface IssueChoice extends vscode.QuickPickItem { + currentIssue: CurrentIssue; + } + const choices: IssueChoice[] = currentIssues.map(currentIssue => { + return { + label: vscode.l10n.t('#{0} from {1}', currentIssue.issue.number, `${currentIssue.issue.githubRepository.remote.owner}/${currentIssue.issue.githubRepository.remote.repositoryName}`), + currentIssue, + }; + }); + const response: IssueChoice | undefined = await vscode.window.showQuickPick(choices); + if (response) { + return this.statusBarActions(response.currentIssue); + } + } + } + + private stringToUint8Array(input: string): Uint8Array { + const encoder = new TextEncoder(); + return encoder.encode(input); + } + + copyIssueNumber(issueModel: any) { + if (issueModel instanceof IssueModel) { + return vscode.env.clipboard.writeText(issueModel.number.toString()); + } + return undefined; + } + + copyIssueUrl(issueModel: any) { + if (issueModel instanceof IssueModel) { + return vscode.env.clipboard.writeText(issueModel.html_url); + } + return undefined; + } + + async createTodoIssueClipboard() { + return this.createTodoIssue(undefined, await vscode.env.clipboard.readText()); + } + + private async createTodoIssueBody(newIssue?: NewIssue, issueBody?: string): Promise { + if (issueBody || newIssue?.document.isUntitled) { + return issueBody; + } + + let contents = ''; + if (newIssue) { + const folderRepoManager = this.manager.getManagerForFile(newIssue.document.uri); + const changeAffectingFile = folderRepoManager?.repository?.state.workingTreeChanges.find(value => value.uri.toString() === newIssue.document.uri.toString()); + if (changeAffectingFile) { + // The file we're creating the issue for has uncommitted changes. + // Add a quote of the line so that the issue body is still meaningful. + contents = `\`\`\`\n${newIssue.line}\n\`\`\`\n\n`; + } + + if (folderRepoManager) { + const relativePath = folderRepoManager.gitRelativeRootPath(newIssue.document.uri.path); + contents += vscode.l10n.t('In file {0}\n', relativePath); + } + } + + contents += (await createSinglePermalink(this.manager, this.gitAPI, true, true, newIssue)).permalink; + return contents; + } + + async createTodoIssue(newIssue?: NewIssue, issueBody?: string) { + let document: vscode.TextDocument; + let titlePlaceholder: string | undefined; + let insertIndex: number | undefined; + let lineNumber: number | undefined; + let assignees: string[] | undefined; + let issueGenerationText: string | undefined; + if (!newIssue && vscode.window.activeTextEditor) { + document = vscode.window.activeTextEditor.document; + issueGenerationText = document.getText(vscode.window.activeTextEditor.selection); + } else if (newIssue) { + document = newIssue.document; + insertIndex = newIssue.insertIndex; + lineNumber = newIssue.lineNumber; + titlePlaceholder = newIssue.line.substring(insertIndex, newIssue.line.length).trim(); + issueGenerationText = document.getText( + newIssue.range.isEmpty ? document.lineAt(newIssue.range.start.line).range : newIssue.range, + ); + } else { + return undefined; + } + const matches = issueGenerationText.match(USER_EXPRESSION); + if (matches && matches.length === 2 && (await this._stateManager.getUserMap(document.uri)).has(matches[1])) { + assignees = [matches[1]]; + } + + // Auto-assign to current user if they are assignable in the repository + const folderManager = this.manager.getManagerForFile(document.uri); + if (folderManager) { + try { + // Get the GitHub repository for the document + const githubRepository = folderManager.gitHubRepositories[0]; + if (githubRepository) { + const currentUser = await folderManager.getCurrentUser(githubRepository); + if (currentUser?.login) { + // Check if the current user is assignable in this repository + const assignableUsers = await folderManager.getAssignableUsers(); + const assignableUsersForRemote = assignableUsers[githubRepository.remote.remoteName] || []; + const isAssignable = assignableUsersForRemote.some(user => user.login === currentUser.login); + if (isAssignable) { + // Add current user to assignees if not already included + if (!assignees) { + assignees = [currentUser.login]; + } else if (!assignees.includes(currentUser.login)) { + assignees.push(currentUser.login); + } + } + } + } + } catch (error) { + // If we can't get the current user or assignable users, just continue without auto-assignment + Logger.debug(`Failed to auto-assign current user: ${error}`, IssueFeatureRegistrar.ID); + } + } + + let title: string | undefined; + const body: string | undefined = await this.createTodoIssueBody(newIssue, issueBody); + + const quickInput = vscode.window.createInputBox(); + quickInput.value = titlePlaceholder ?? ''; + quickInput.prompt = + vscode.l10n.t('Set the issue title. Confirm to create the issue now or use the edit button to edit the issue title and description.'); + quickInput.title = vscode.l10n.t('Create Issue'); + quickInput.buttons = [ + { + iconPath: new vscode.ThemeIcon('edit'), + tooltip: vscode.l10n.t('Edit Description'), + }, + ]; + quickInput.onDidAccept(async () => { + title = quickInput.value; + if (title) { + quickInput.busy = true; + await this.doCreateIssue(document, newIssue, title, body, assignees, undefined, undefined, undefined, lineNumber, insertIndex); + quickInput.busy = false; + } + quickInput.hide(); + }); + quickInput.onDidTriggerButton(async () => { + title = quickInput.value; + quickInput.busy = true; + this.createIssueInfo = { document, newIssue, lineNumber, insertIndex }; + + await this.makeNewIssueFile(document.uri, { title, body, assignees }); + quickInput.busy = false; + quickInput.hide(); + }); + quickInput.show(); + + return undefined; + } + + private async makeNewIssueFile( + originUri: vscode.Uri, + options?: NewIssueFileOptions + ) { + const folderManager = this.manager.getManagerForFile(originUri); + if (!folderManager) { + return; + } + const repoRef = folderManager.findRepo((githubRepo) => githubRepo.remote.remoteName === options?.remote?.name)?.remote.gitProtocol; + const repoUrl = repoRef?.url.toString().endsWith('.git') ? repoRef?.url.toString().slice(0, -4) : repoRef?.url.toString(); + const repoUriParams: RepoUriParams | undefined = repoRef ? { owner: repoRef?.owner, repo: repoRef?.repositoryName, repoRootUri: folderManager.repository.rootUri } : undefined; + const bodyPath = toNewIssueUri({ originUri, repoUriParams }); + if ( + vscode.window.visibleTextEditors.filter( + visibleEditor => visibleEditor.document.uri.scheme === Schemes.NewIssue, + ).length > 0 + ) { + return; + } + await vscode.workspace.fs.delete(bodyPath); + const assigneeLine = `${ASSIGNEES} ${options?.assignees && options.assignees.length > 0 ? options.assignees.map(value => '@' + value).join(', ') + ' ' : '' + }`; + const labelLine = `${LABELS} ${options?.labels && options.labels.length > 0 ? options.labels.join(', ') + ' ' : ''}`; + const milestoneLine = `${MILESTONE} `; + const projectsLine = `${PROJECTS} `; + const cached = this._newIssueCache.get(); + const text = (cached && cached !== '') ? cached : `${options?.title ?? vscode.l10n.t('Issue Title')}\n +${repoRef ? `\n` : ''} +${assigneeLine} +${labelLine} +${milestoneLine} +${projectsLine}\n +${options?.body ?? ''}\n +`; + await vscode.workspace.fs.writeFile(bodyPath, this.stringToUint8Array(text)); + const assigneesDecoration = vscode.window.createTextEditorDecorationType({ + after: { + contentText: vscode.l10n.t(' Comma-separated usernames, either @username or just username.'), + fontStyle: 'italic', + color: new vscode.ThemeColor('issues.newIssueDecoration'), + }, + }); + const labelsDecoration = vscode.window.createTextEditorDecorationType({ + after: { + contentText: vscode.l10n.t(' Comma-separated labels.'), + fontStyle: 'italic', + color: new vscode.ThemeColor('issues.newIssueDecoration'), + }, + }); + const projectsDecoration = vscode.window.createTextEditorDecorationType({ + after: { + contentText: vscode.l10n.t(' Comma-separated projects.'), + fontStyle: 'italic', + color: new vscode.ThemeColor('issues.newIssueDecoration'), + }, + }); + const editorChangeDisposable = vscode.window.onDidChangeActiveTextEditor(textEditor => { + if (textEditor?.document.uri.scheme === Schemes.NewIssue) { + const metadataFirstLine = repoRef ? 4 : 2; + const assigneeLineNum = metadataFirstLine + 0; + const labelLineNum = metadataFirstLine + 1; + const projectsLineNum = metadataFirstLine + 3; + const assigneeFullLine = textEditor.document.lineAt(assigneeLineNum); + if (assigneeFullLine.text.startsWith(ASSIGNEES)) { + textEditor.setDecorations(assigneesDecoration, [ + new vscode.Range( + new vscode.Position(assigneeLineNum, 0), + new vscode.Position(assigneeLineNum, assigneeFullLine.text.length), + ), + ]); + } + const labelFullLine = textEditor.document.lineAt(labelLineNum); + if (labelFullLine.text.startsWith(LABELS)) { + textEditor.setDecorations(labelsDecoration, [ + new vscode.Range(new vscode.Position(labelLineNum, 0), new vscode.Position(labelLineNum, labelFullLine.text.length)), + ]); + } + const projectsFullLine = textEditor.document.lineAt(projectsLineNum); + if (projectsFullLine.text.startsWith(PROJECTS)) { + textEditor.setDecorations(projectsDecoration, [ + new vscode.Range(new vscode.Position(projectsLineNum, 0), new vscode.Position(projectsLineNum, projectsFullLine.text.length)), + ]); + } + } + }); + + const editor = await vscode.window.showTextDocument(bodyPath); + const closeDisposable = vscode.workspace.onDidCloseTextDocument(textDocument => { + if (textDocument === editor.document) { + editorChangeDisposable.dispose(); + closeDisposable.dispose(); + } + }); + } + + private async verifyLabels( + folderManager: FolderRepositoryManager, + createParams: OctokitCommon.IssuesCreateParams, + ): Promise { + if (!createParams.labels) { + return true; + } + const allLabels = (await folderManager.getLabels(undefined, createParams)).map(label => label.name); + const newLabels: string[] = []; + const filteredLabels: string[] = []; + createParams.labels?.forEach(paramLabel => { + let label = typeof paramLabel === 'string' ? paramLabel : paramLabel.name; + if (!label) { + return; + } + + if (allLabels.includes(label)) { + filteredLabels.push(label); + } else { + newLabels.push(label); + } + }); + + if (newLabels.length > 0) { + const yes = vscode.l10n.t('Yes'); + const no = vscode.l10n.t('No'); + const promptResult = await vscode.window.showInformationMessage( + vscode.l10n.t('The following labels don\'t exist in this repository: {0}. \nDo you want to create these labels?', newLabels.join( + ', ', + )), + { modal: true }, + yes, + no, + ); + switch (promptResult) { + case yes: + return true; + case no: { + createParams.labels = filteredLabels; + return true; + } + default: + return false; + } + } + return true; + } + + private async chooseRepo(prompt: string): Promise { + interface RepoChoice extends vscode.QuickPickItem { + repo: FolderRepositoryManager; + } + const choices: RepoChoice[] = []; + for (const folderManager of this.manager.folderManagers) { + try { + const defaults = await folderManager.getPullRequestDefaults(); + choices.push({ + label: `${defaults.owner}/${defaults.repo}`, + repo: folderManager, + }); + } catch (e) { + // ignore + } + } + if (choices.length === 0) { + return; + } else if (choices.length === 1) { + return choices[0].repo; + } + + const choice = await vscode.window.showQuickPick(choices, { placeHolder: prompt }); + return choice?.repo; + } + + private async chooseTemplate(folderManager: FolderRepositoryManager): Promise { + const templateUris = await folderManager.getIssueTemplates(); + if (templateUris.length === 0) { + return { title: undefined, body: undefined, labels: undefined, assignees: undefined, name: undefined, about: undefined }; + } + + interface IssueChoice extends vscode.QuickPickItem { + template: IssueTemplate | undefined; + } + const templates = await Promise.all( + templateUris + .map(async uri => { + try { + const content = await vscode.workspace.fs.readFile(uri); + const text = new TextDecoder('utf-8').decode(content); + const template = this.getDataFromTemplate(text); + + return template; + } catch (e) { + Logger.warn(`Reading issue template failed: ${e}`); + return undefined; + } + }) + ); + const choices: IssueChoice[] = templates.filter(template => !!template && !!template?.name).map(template => { + return { + label: template!.name!, + description: template!.about, + template: template, + }; + }); + choices.push({ + label: vscode.l10n.t('Blank issue'), + template: { title: undefined, body: undefined, labels: undefined, assignees: undefined, name: undefined, about: undefined } + }); + + const selectedTemplate = await vscode.window.showQuickPick(choices, { + placeHolder: vscode.l10n.t('Select a template for the new issue.'), + }); + + return selectedTemplate?.template; + } + + private getDataFromTemplate(template: string): IssueTemplate { + // Try to parse as YAML first (YAML templates have a different structure) + try { + const parsed = yaml.load(template); + // Check if it looks like a YAML issue template (has name and body fields) + if (parsed && typeof parsed === 'object' && (parsed as YamlIssueTemplate).name && (parsed as YamlIssueTemplate).body) { + // This is a YAML template + return this.parseYamlTemplate(parsed as YamlIssueTemplate); + } + } catch (e) { + // Not a valid YAML, continue to Markdown parsing + } + + // Parse as Markdown frontmatter template + const title = template.match(/title:\s*(.*)/)?.[1]?.replace(/^["']|["']$/g, ''); + const name = template.match(/name:\s*(.*)/)?.[1]?.replace(/^["']|["']$/g, ''); + const about = template.match(/about:\s*(.*)/)?.[1]?.replace(/^["']|["']$/g, ''); + const labelsMatch = template.match(/labels:\s*(.*)/)?.[1]; + const labels = labelsMatch ? labelsMatch.split(',').map(label => label.trim()).filter(label => label) : undefined; + const assigneesMatch = template.match(/assignees:\s*(.*)/)?.[1]; + const assignees = assigneesMatch ? assigneesMatch.split(',').map(assignee => assignee.trim()).filter(assignee => assignee) : undefined; + const body = template.match(/---([\s\S]*)---([\s\S]*)/)?.[2]; + return { title, name, about, labels, assignees, body }; + } + + private parseYamlTemplate(parsed: YamlIssueTemplate): IssueTemplate { + const name = parsed.name; + const about = parsed.description || parsed.about; + const title = parsed.title; + + // Extract labels and assignees from YAML + const labels = parsed.labels && Array.isArray(parsed.labels) ? parsed.labels : undefined; + const assignees = parsed.assignees && Array.isArray(parsed.assignees) ? parsed.assignees : undefined; + + // Convert YAML body fields to markdown + let body = ''; + if (parsed.body && Array.isArray(parsed.body)) { + for (const field of parsed.body) { + if (field.type === 'markdown' && field.attributes?.value) { + body += field.attributes.value + '\n\n'; + } else if (field.type === 'textarea' && field.attributes?.label) { + body += `## ${field.attributes.label}\n\n`; + if (field.attributes.description) { + body += `${field.attributes.description}\n\n`; + } + if (field.attributes.placeholder) { + body += `${field.attributes.placeholder}\n\n`; + } else if (field.attributes.value) { + body += `${field.attributes.value}\n\n`; + } + } else if (field.type === 'input' && field.attributes?.label) { + body += `## ${field.attributes.label}\n\n`; + if (field.attributes.description) { + body += `${field.attributes.description}\n\n`; + } + if (field.attributes.placeholder) { + body += `${field.attributes.placeholder}\n\n`; + } + } else if (field.type === 'dropdown' && field.attributes?.label) { + body += `## ${field.attributes.label}\n\n`; + if (field.attributes.description) { + body += `${field.attributes.description}\n\n`; + } + if (field.attributes.options && Array.isArray(field.attributes.options)) { + body += field.attributes.options.map((opt: string | { label?: string }) => typeof opt === 'string' ? `- ${opt}` : `- ${opt.label || ''}`).join('\n') + '\n\n'; + } + } else if (field.type === 'checkboxes' && field.attributes?.label) { + body += `## ${field.attributes.label}\n\n`; + if (field.attributes.description) { + body += `${field.attributes.description}\n\n`; + } + if (field.attributes.options && Array.isArray(field.attributes.options)) { + body += field.attributes.options.map((opt: { label?: string } | string) => `- [ ] ${typeof opt === 'string' ? opt : opt.label || ''}`).join('\n') + '\n\n'; + } + } + } + } + + return { title, name, about, labels, assignees, body: body.trim() || undefined }; + } + + private async doCreateIssue( + document: vscode.TextDocument | undefined, + newIssue: NewIssue | undefined, + title: string, + issueBody: string | undefined, + assignees: string[] | undefined, + labels: string[] | undefined, + milestone: number | undefined, + projects: IProject[] | undefined, + lineNumber: number | undefined, + insertIndex: number | undefined, + originUri?: vscode.Uri + ): Promise { + let origin: PullRequestDefaults | undefined; + let folderManager: FolderRepositoryManager | undefined; + if (originUri && originUri.scheme === Schemes.Repo) { + const repoUriParams = fromRepoUri(originUri); + if (repoUriParams) { + origin = { owner: repoUriParams.owner, repo: repoUriParams.repo, base: '' }; + folderManager = this.manager.getManagerForFile(repoUriParams.repoRootUri); + } + if (!folderManager) { + vscode.window.showErrorMessage(vscode.l10n.t(`Could not find the correct repository for the issue; see logs for more details.`)); + Logger.error(`Could not find the folder manager for the issue originUri: ${originUri.toString()}`, IssueFeatureRegistrar.ID); + return false; + } + } + + if (!folderManager) { + // We don't check for githubIssues.alwaysPromptForNewIssueRepo here because we're + // likely in this scenario due to making an issue from a file selection/etc. + if (document) { + folderManager = this.manager.getManagerForFile(document.uri); + } else if (originUri) { + folderManager = this.manager.getManagerForFile(originUri); + } + } + if (!folderManager) { + folderManager = await this.chooseRepo(vscode.l10n.t('Choose where to create the issue.')); + } + + const assigneesWithoutCopilot = assignees?.filter(assignee => !COPILOT_ACCOUNTS[assignee]); + const copilotAssignee = !!assignees?.find(assignee => COPILOT_ACCOUNTS[assignee]); + + return vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Creating issue') }, async (progress) => { + if (!folderManager) { + return false; + } + const constFolderManager: FolderRepositoryManager = folderManager; + progress.report({ message: vscode.l10n.t('Verifying that issue data is valid...') }); + try { + if (!origin) { + origin = await constFolderManager.getPullRequestDefaults(); + } + } catch (e) { + // There is no remote + vscode.window.showErrorMessage(vscode.l10n.t('There is no remote. Can\'t create an issue.')); + return false; + } + const body: string | undefined = + issueBody || newIssue?.document.isUntitled + ? issueBody + : (await createSinglePermalink(this.manager, this.gitAPI, true, true, newIssue)).permalink; + const createParams: OctokitCommon.IssuesCreateParams = { + owner: origin.owner, + repo: origin.repo, + title, + body, + assignees: assigneesWithoutCopilot, + labels, + milestone + }; + + if (!(await this.verifyLabels(constFolderManager, createParams))) { + return false; + } + progress.report({ message: vscode.l10n.t('Creating issue in {0}...', `${createParams.owner}/${createParams.repo}`) }); + const issue = await constFolderManager.createIssue(createParams); + if (issue) { + if (copilotAssignee) { + const copilotUser = (await folderManager.getAssignableUsers())[issue.remote.remoteName].find(user => COPILOT_ACCOUNTS[user.login]); + if (copilotUser) { + await issue.replaceAssignees([...(issue.assignees ?? []), copilotUser]); + } + } + if (projects) { + await issue.updateProjects(projects); + } + if (document !== undefined && insertIndex !== undefined && lineNumber !== undefined) { + const edit: vscode.WorkspaceEdit = new vscode.WorkspaceEdit(); + const insertText: string = + vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(CREATE_INSERT_FORMAT, 'number') === + 'number' + ? `#${issue.number}` + : issue.html_url; + edit.insert(document.uri, new vscode.Position(lineNumber, insertIndex), ` ${insertText}`); + await vscode.workspace.applyEdit(edit); + } else { + const copyIssueUrl = vscode.l10n.t('Copy Issue Link'); + const openIssue = vscode.l10n.t({ message: 'Open Issue', comment: 'Open the issue description in the editor to see it\'s full contents.' }); + vscode.window.showInformationMessage(vscode.l10n.t('Issue created'), copyIssueUrl, openIssue).then(async result => { + switch (result) { + case copyIssueUrl: + await vscode.env.clipboard.writeText(issue.html_url); + break; + case openIssue: + const identity = { + owner: issue.remote.owner, + repo: issue.remote.repositoryName, + number: issue.number + }; + await IssueOverviewPanel.createOrShow(this.telemetry, this.context.extensionUri, constFolderManager, identity, issue); + break; + } + }); + } + this._stateManager.refreshCacheNeeded(); + return true; + } + return false; + }); + } + + private async getPermalinkWithError(repositoriesManager: RepositoriesManager, includeRange: boolean, includeFile: boolean, context?: LinkContext[]): Promise { + const links = await createGithubPermalink(repositoriesManager, this.gitAPI, includeRange, includeFile, undefined, context); + const firstError = links.find(link => link.error); + if (firstError) { + vscode.window.showWarningMessage(vscode.l10n.t('Unable to create a GitHub permalink for the selection. {0}', firstError.error!)); + } + return links; + } + + private async getHeadLinkWithError(context?: vscode.Uri[], includeRange?: boolean): Promise { + const links = await createGitHubLink(this.manager, context, includeRange); + if (links.length > 0) { + const firstError = links.find(link => link.error); + if (firstError) { + vscode.window.showWarningMessage(vscode.l10n.t('Unable to create a GitHub link for the selection. {0}', firstError.error!)); + } + } + return links; + } + + private async getContextualizedLink(file: vscode.Uri, link: string): Promise { + let uri: vscode.Uri; + try { + uri = await vscode.env.asExternalUri(file); + } catch (e) { + // asExternalUri can throw when in the browser and the embedder doesn't set a uri resolver. + return link; + } + const authority = (uri.scheme === 'https' && /^(insiders\.vscode|vscode|github)\./.test(uri.authority)) ? uri.authority : undefined; + if (!authority) { + return link; + } + const linkUri = vscode.Uri.parse(link); + const linkPath = /^(github)\./.test(uri.authority) ? linkUri.path : `/github${linkUri.path}`; + return linkUri.with({ authority, path: linkPath }).toString(); + } + + private async permalinkInfoToClipboardText(links: PermalinkInfo[], shouldContextualize: boolean = false): Promise { + const withPermalinks: (PermalinkInfo & { permalink: string })[] = links.filter((link): link is PermalinkInfo & { permalink: string } => !!link.permalink); + if (withPermalinks.length !== 0) { + const contextualizedLinks = await Promise.all(withPermalinks.map(async link => (shouldContextualize && link.originalFile) ? await this.getContextualizedLink(link.originalFile, link.permalink) : link.permalink)); + const clipboardText = contextualizedLinks.join('\n'); + Logger.debug(`Will write ${clipboardText} to the clipboard`, PERMALINK_COMPONENT); + return clipboardText; + } + return undefined; + } + + async copyPermalink(repositoriesManager: RepositoriesManager, context?: LinkContext[], includeRange: boolean = true, includeFile: boolean = true, contextualizeLink: boolean = false) { + const links = await this.getPermalinkWithError(repositoriesManager, includeRange, includeFile, context); + const clipboardText = await this.permalinkInfoToClipboardText(links, contextualizeLink); + if (clipboardText) { + return vscode.env.clipboard.writeText(clipboardText); + } + } + + async copyHeadLink(fileUri?: vscode.Uri[], includeRange = true) { + const link = await this.getHeadLinkWithError(fileUri, includeRange); + const clipboardText = await this.permalinkInfoToClipboardText(link); + if (clipboardText) { + return vscode.env.clipboard.writeText(clipboardText); + } + } + + private getMarkdownLinkText(range?: vscode.Range | vscode.NotebookRange): string | undefined { + if (!vscode.window.activeTextEditor) { + return undefined; + } + + // If a specific range is provided (e.g., from a gutter click), use that + // Note: NotebookRange is excluded because getText() only accepts vscode.Range + if (range && !(range instanceof vscode.NotebookRange)) { + const text = vscode.window.activeTextEditor.document.getText(range); + if (text) { + return text; + } + } + + // Otherwise fall back to the current selection + let editorSelection: vscode.Range | undefined = vscode.window.activeTextEditor.selection; + if (editorSelection.start.line !== editorSelection.end.line) { + editorSelection = new vscode.Range( + editorSelection.start, + new vscode.Position(editorSelection.start.line + 1, 0), + ); + } + const selection = vscode.window.activeTextEditor.document.getText(editorSelection); + if (selection) { + return selection; + } + editorSelection = vscode.window.activeTextEditor.document.getWordRangeAtPosition(editorSelection.start); + if (editorSelection) { + return vscode.window.activeTextEditor.document.getText(editorSelection); + } + return undefined; + } + + async copyMarkdownPermalink(repositoriesManager: RepositoriesManager, context: LinkContext[], includeRange: boolean = true) { + const links = await this.getPermalinkWithError(repositoriesManager, includeRange, true, context); + const withPermalinks: (PermalinkInfo & { permalink: string })[] = links.filter((link): link is PermalinkInfo & { permalink: string } => !!link.permalink); + + if (withPermalinks.length === 1) { + const selection = this.getMarkdownLinkText(withPermalinks[0].range); + if (selection) { + return vscode.env.clipboard.writeText(`[${selection.trim()}](${withPermalinks[0].permalink})`); + } + } + const clipboardText = withPermalinks.map(link => `[${basename(link.originalFile?.fsPath ?? '')}](${link.permalink})`).join('\n'); + Logger.debug(`writing ${clipboardText} to the clipboard`, PERMALINK_COMPONENT); + return vscode.env.clipboard.writeText(clipboardText); + } + + async openPermalink(repositoriesManager: RepositoriesManager) { + const links = await this.getPermalinkWithError(repositoriesManager, true, true); + const withPermalinks: (PermalinkInfo & { permalink: string })[] = links.filter((link): link is PermalinkInfo & { permalink: string } => !!link.permalink); + + if (withPermalinks.length > 0) { + return vscode.env.openExternal(vscode.Uri.parse(withPermalinks[0].permalink)); + } + return undefined; + } + + async assignToCodingAgent(issueModel: any) { + if (!issueModel) { + return; + } + + // Check if the issue model is an IssueModel + if (!(issueModel instanceof IssueModel)) { + return; + } + + try { + // Get the folder manager for this issue + const folderManager = this.manager.getManagerForIssueModel(issueModel); + if (!folderManager) { + vscode.window.showErrorMessage(vscode.l10n.t('Failed to find repository for issue #{0}', issueModel.number)); + return; + } + + // Get assignable users and find the copilot user + const assignableUsers = await folderManager.getAssignableUsers(); + const copilotUser = assignableUsers[issueModel.remote.remoteName]?.find(user => COPILOT_ACCOUNTS[user.login]); + + if (!copilotUser) { + vscode.window.showErrorMessage(vscode.l10n.t('Copilot coding agent is not available for assignment in this repository')); + return; + } + + // Assign the issue to the copilot user + await issueModel.replaceAssignees([...(issueModel.assignees ?? []), copilotUser]); + vscode.window.showInformationMessage(vscode.l10n.t('Issue #{0} has been assigned to Copilot coding agent', issueModel.number)); + } catch (error) { + vscode.window.showErrorMessage(vscode.l10n.t('Failed to assign issue to coding agent: {0}', error.message)); + } + } +} diff --git a/src/issues/issueFile.ts b/src/issues/issueFile.ts index b9e4435680..69e0ef6ca3 100644 --- a/src/issues/issueFile.ts +++ b/src/issues/issueFile.ts @@ -4,22 +4,27 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { Remote } from '../api/api'; +import { fromNewIssueUri, Schemes } from '../common/uri'; +import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; +import { IProject } from '../github/interface'; import { RepositoriesManager } from '../github/repositoriesManager'; -export const NEW_ISSUE_SCHEME = 'newIssue'; -export const NEW_ISSUE_FILE = 'NewIssue.md'; +export interface NewIssueFileOptions { + title?: string; + body?: string; + assignees?: string[] | undefined, + labels?: string[] | undefined, + remote?: Remote, +} + export const ASSIGNEES = vscode.l10n.t('Assignees:'); export const LABELS = vscode.l10n.t('Labels:'); +export const MILESTONE = vscode.l10n.t('Milestone:'); +export const PROJECTS = vscode.l10n.t('Projects:'); const NEW_ISSUE_CACHE = 'newIssue.cache'; -export function extractIssueOriginFromQuery(uri: vscode.Uri): vscode.Uri | undefined { - const query = JSON.parse(uri.query); - if (query.origin) { - return vscode.Uri.parse(query.origin); - } -} - export class IssueFileSystemProvider implements vscode.FileSystemProvider { private content: Uint8Array | undefined; private createTime: number = 0; @@ -28,7 +33,7 @@ export class IssueFileSystemProvider implements vscode.FileSystemProvider { vscode.FileChangeEvent[] >(); - constructor(private readonly cache: NewIssueCache) { }; + constructor(private readonly cache: NewIssueCache) { } onDidChangeFile: vscode.Event = this._onDidChangeFile.event; watch(_uri: vscode.Uri, _options: { recursive: boolean; excludes: string[] }): vscode.Disposable { const disposable = this.onDidChangeFile(e => { @@ -79,7 +84,7 @@ export class IssueFileSystemProvider implements vscode.FileSystemProvider { rename(_oldUri: vscode.Uri, _newUri: vscode.Uri, _options: { overwrite: boolean }): void | Thenable { } } -export class LabelCompletionProvider implements vscode.CompletionItemProvider { +export class NewIssueFileCompletionProvider implements vscode.CompletionItemProvider { constructor(private manager: RepositoriesManager) { } async provideCompletionItems( @@ -88,10 +93,11 @@ export class LabelCompletionProvider implements vscode.CompletionItemProvider { _token: vscode.CancellationToken, _context: vscode.CompletionContext, ): Promise { - if (!document.lineAt(position.line).text.startsWith(LABELS)) { + const line = document.lineAt(position.line).text; + if (!line.startsWith(LABELS) && !line.startsWith(MILESTONE) && !line.startsWith(PROJECTS)) { return []; } - const originFile = extractIssueOriginFromQuery(document.uri); + const originFile = fromNewIssueUri(document.uri)?.repoUriParams?.repoRootUri; if (!originFile) { return []; } @@ -100,6 +106,19 @@ export class LabelCompletionProvider implements vscode.CompletionItemProvider { return []; } const defaults = await folderManager.getPullRequestDefaults(); + + if (line.startsWith(LABELS)) { + return this.provideLabelCompletionItems(folderManager, defaults); + } else if (line.startsWith(MILESTONE)) { + return this.provideMilestoneCompletionItems(folderManager); + } else if (line.startsWith(PROJECTS)) { + return this.provideProjectCompletionItems(folderManager); + } else { + return []; + } + } + + private async provideLabelCompletionItems(folderManager: FolderRepositoryManager, defaults: PullRequestDefaults): Promise { const labels = await folderManager.getLabels(undefined, defaults); return labels.map(label => { const item = new vscode.CompletionItem(label.name, vscode.CompletionItemKind.Color); @@ -108,6 +127,25 @@ export class LabelCompletionProvider implements vscode.CompletionItemProvider { return item; }); } + + private async provideMilestoneCompletionItems(folderManager: FolderRepositoryManager): Promise { + const milestones = await (await folderManager.getPullRequestDefaultRepo())?.getMilestones() ?? []; + return milestones.map(milestone => { + const item = new vscode.CompletionItem(milestone.title, vscode.CompletionItemKind.Event); + item.commitCharacters = [' ', ',']; + return item; + }); + } + + private async provideProjectCompletionItems(folderManager: FolderRepositoryManager): Promise { + const repo = await folderManager.getPullRequestDefaultRepo(); + const projects = await folderManager.getAllProjects(repo) ?? []; + return projects.map(project => { + const item = new vscode.CompletionItem(project.title, vscode.CompletionItemKind.Event); + item.commitCharacters = [' ', ',']; + return item; + }); + } } export class NewIssueCache { @@ -126,7 +164,120 @@ export class NewIssueCache { public get(): string | undefined { const content = this.context.workspaceState.get(NEW_ISSUE_CACHE); if (content) { - return content.toString(); + return new TextDecoder().decode(content); + } + } +} + +export async function extractMetadataFromFile(repositoriesManager: RepositoriesManager): Promise<{ labels: string[] | undefined, milestone: number | undefined, projects: IProject[] | undefined, assignees: string[] | undefined, title: string, body: string | undefined, originUri: vscode.Uri, repoUri?: vscode.Uri } | undefined> { + let text: string; + if ( + !vscode.window.activeTextEditor || + vscode.window.activeTextEditor.document.uri.scheme !== Schemes.NewIssue + ) { + return; + } + const params = fromNewIssueUri(vscode.window.activeTextEditor.document.uri); + const originUri = params?.originUri; + if (!originUri) { + return; + } + let folderManager: FolderRepositoryManager | undefined; + if (params.repoUriParams?.repoRootUri) { + folderManager = repositoriesManager.getManagerForFile(params.repoUriParams.repoRootUri); + } else { + folderManager = repositoriesManager.getManagerForFile(originUri); + } + if (!folderManager) { + return; + } + const repo = await folderManager.getPullRequestDefaultRepo(); + text = vscode.window.activeTextEditor.document.getText(); + const indexOfEmptyLineWindows = text.indexOf('\r\n\r\n'); + const indexOfEmptyLineOther = text.indexOf('\n\n'); + let indexOfEmptyLine: number; + if (indexOfEmptyLineWindows < 0 && indexOfEmptyLineOther < 0) { + return; + } else { + if (indexOfEmptyLineWindows < 0) { + indexOfEmptyLine = indexOfEmptyLineOther; + } else if (indexOfEmptyLineOther < 0) { + indexOfEmptyLine = indexOfEmptyLineWindows; + } else { + indexOfEmptyLine = Math.min(indexOfEmptyLineWindows, indexOfEmptyLineOther); + } + } + const title = text.substring(0, indexOfEmptyLine); + if (!title) { + return; + } + let assignees: string[] | undefined; + text = text.substring(indexOfEmptyLine + 2).trim(); + if (text.startsWith(''); + if (indexOfCommentEnd < 0) { + return; + } else { + text = text.substring(indexOfCommentEnd + 3).trim(); + } + } + if (text.startsWith(ASSIGNEES)) { + const lines = text.split(/\r\n|\n/, 1); + if (lines.length === 1) { + assignees = lines[0] + .substring(ASSIGNEES.length) + .split(',') + .map(value => { + value = value.trim(); + if (value.startsWith('@')) { + value = value.substring(1); + } + return value; + }); + text = text.substring(lines[0].length).trim(); + } + } + let labels: string[] | undefined; + if (text.startsWith(LABELS)) { + const lines = text.split(/\r\n|\n/, 1); + if (lines.length === 1) { + labels = lines[0] + .substring(LABELS.length) + .split(',') + .map(value => value.trim()) + .filter(label => label); + text = text.substring(lines[0].length).trim(); + } + } + let milestone: number | undefined; + if (text.startsWith(MILESTONE)) { + const lines = text.split(/\r\n|\n/, 1); + if (lines.length === 1) { + const milestoneTitle = lines[0].substring(MILESTONE.length).trim(); + if (milestoneTitle) { + const repoMilestones = await repo.getMilestones(); + milestone = repoMilestones?.find(milestone => milestone.title === milestoneTitle)?.number; + } + text = text.substring(lines[0].length).trim(); + } + } + let projects: IProject[] | undefined; + if (text.startsWith(PROJECTS)) { + const lines = text.split(/\r\n|\n/, 1); + if (lines.length === 1) { + if (await repo.canGetProjectsNow()) { + const repoProjects = await folderManager.getAllProjects(repo); + projects = lines[0].substring(PROJECTS.length) + .split(',') + .map(value => { + value = value.trim(); + return repoProjects.find(project => project.title === value); + }) + .filter((project): project is IProject => !!project); + } + text = text.substring(lines[0].length).trim(); } } + const body = text ?? ''; + return { labels, milestone, projects, assignees, title, body, originUri }; } diff --git a/src/issues/issueHoverProvider.ts b/src/issues/issueHoverProvider.ts index 4092d13dc3..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 { RepositoriesManager } from '../github/repositoriesManager'; -import { ISSUE_OR_URL_EXPRESSION, ParsedIssue, parseIssueExpressionOutput } from '../github/utils'; import { StateManager } from './stateManager'; import { getIssue, - issueMarkdown, 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 dcbf799f92..0000000000 --- a/src/issues/issueLinkProvider.ts +++ /dev/null @@ -1,88 +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 { 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('wordWrap', '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 826f81236a..a4e24414c6 100644 --- a/src/issues/issueTodoProvider.ts +++ b/src/issues/issueTodoProvider.ts @@ -4,13 +4,19 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { MAX_LINE_LENGTH } from './util'; +import { CREATE_ISSUE_TRIGGERS, ISSUES_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { escapeRegExp } from '../common/utils'; +import { CopilotRemoteAgentManager } from '../github/copilotRemoteAgent'; import { ISSUE_OR_URL_EXPRESSION } from '../github/utils'; -import { ISSUES_CONFIGURATION, MAX_LINE_LENGTH } from './util'; export class IssueTodoProvider implements vscode.CodeActionProvider { private expression: RegExp | undefined; - constructor(context: vscode.ExtensionContext) { + constructor( + context: vscode.ExtensionContext, + private copilotRemoteAgentManager: CopilotRemoteAgentManager + ) { context.subscriptions.push( vscode.workspace.onDidChangeConfiguration(() => { this.updateTriggers(); @@ -20,8 +26,26 @@ export class IssueTodoProvider implements vscode.CodeActionProvider { } private updateTriggers() { - const triggers = vscode.workspace.getConfiguration(ISSUES_CONFIGURATION).get('createIssueTriggers', []); - this.expression = triggers.length > 0 ? new RegExp(triggers.join('|')) : undefined; + const triggers = vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(CREATE_ISSUE_TRIGGERS, []); + 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( @@ -37,27 +61,37 @@ 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 search = truncatedLine.search(this.expression); - if (search >= 0) { - const codeAction: 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, ); - const indexOfWhiteSpace = truncatedLine.substring(search).search(/\s/); - const insertIndex = - search + - (indexOfWhiteSpace > 0 ? indexOfWhiteSpace : truncatedLine.match(this.expression)![0].length); - codeAction.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(codeAction); - break; + codeActions.push(startAgentAction); } + break; } lineNumber++; } while (range.end.line >= lineNumber); diff --git a/src/issues/issuesView.ts b/src/issues/issuesView.ts index 7bfda45956..cb43adc2a3 100644 --- a/src/issues/issuesView.ts +++ b/src/issues/issuesView.ts @@ -5,35 +5,39 @@ 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'; +import { groupBy } from '../common/utils'; import { FolderRepositoryManager, ReposManagerState } from '../github/folderRepositoryManager'; +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, MilestoneItem, StateManager } from './stateManager'; -import { issueMarkdown } from './util'; -export class IssueUriTreeItem extends vscode.TreeItem { +export class QueryNode { constructor( - public readonly uri: vscode.Uri | undefined, - label: string, - collapsibleState?: vscode.TreeItemCollapsibleState, + public readonly repoRootUri: vscode.Uri, + public readonly queryLabel: string, + public readonly isFirst: boolean ) { - super(label, collapsibleState); } +} - get labelAsString(): string | undefined { - return typeof this.label === 'string' ? this.label : this.label?.label; +class IssueGroupNode { + constructor(public readonly repoRootUri: vscode.Uri, public readonly queryLabel, public readonly isInFirstQuery: boolean, public readonly groupLevel: number, public readonly group: string, public readonly groupByOrder: QueryGroup[], public readonly issuesInGroup: IssueItem[]) { } } export class IssuesTreeData - implements vscode.TreeDataProvider { + implements vscode.TreeDataProvider { private _onDidChangeTreeData: vscode.EventEmitter< - FolderRepositoryManager | IssueItem | MilestoneItem | null | undefined | void + FolderRepositoryManager | IssueItem | null | undefined | void > = new vscode.EventEmitter(); public onDidChangeTreeData: vscode.Event< - FolderRepositoryManager | IssueItem | MilestoneItem | null | undefined | void + FolderRepositoryManager | IssueItem | null | undefined | void > = this._onDidChangeTreeData.event; constructor( @@ -57,56 +61,103 @@ export class IssuesTreeData this._onDidChangeTreeData.fire(); }), ); + + // Listen for changes to the avatar display setting + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(change => { + if (change.affectsConfiguration(`${ISSUES_SETTINGS_NAMESPACE}.${ISSUE_AVATAR_DISPLAY}`)) { + this._onDidChangeTreeData.fire(); + } + }), + ); } - getTreeItem(element: FolderRepositoryManager | IssueItem | MilestoneItem | IssueUriTreeItem): IssueUriTreeItem { - let treeItem: IssueUriTreeItem; - if (element instanceof IssueUriTreeItem) { - treeItem = element; - } else if (element instanceof FolderRepositoryManager) { - treeItem = new IssueUriTreeItem( - element.repository.rootUri, - path.basename(element.repository.rootUri.fsPath), - vscode.TreeItemCollapsibleState.Expanded, - ); - } else if (!(element instanceof IssueModel)) { - treeItem = new IssueUriTreeItem( - element.uri, - element.milestone.title, - element.issues.length > 0 - ? vscode.TreeItemCollapsibleState.Expanded - : vscode.TreeItemCollapsibleState.None, - ); - } else { - treeItem = new IssueUriTreeItem( - undefined, - `${element.number}: ${element.title}`, - vscode.TreeItemCollapsibleState.None, - ); - treeItem.iconPath = element.isOpen - ? new vscode.ThemeIcon('issues', new vscode.ThemeColor('issues.open')) - : new vscode.ThemeIcon('issue-closed', new vscode.ThemeColor('issues.closed')); - if (this.stateManager.currentIssue(element.uri)?.issue.number === element.number) { - treeItem.label = `✓ ${treeItem.label!}`; - treeItem.contextValue = 'currentissue'; + private getFolderRepoItem(element: FolderRepositoryManager): vscode.TreeItem { + return new vscode.TreeItem(path.basename(element.repository.rootUri.fsPath), getQueryExpandState(this.context, element, vscode.TreeItemCollapsibleState.Expanded)); + } + + private getQueryItem(element: QueryNode): vscode.TreeItem { + const item = new vscode.TreeItem(element.queryLabel, getQueryExpandState(this.context, element, element.isFirst ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed)); + item.contextValue = 'query'; + return item; + } + + private getIssueGroupItem(element: IssueGroupNode): vscode.TreeItem { + return new vscode.TreeItem(element.group, getQueryExpandState(this.context, element, element.isInFirstQuery ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed)); + } + + private async getIssueTreeItem(element: IssueItem): Promise { + const treeItem = new vscode.TreeItem(element.title, vscode.TreeItemCollapsibleState.None); + + const avatarDisplaySetting = vscode.workspace + .getConfiguration(ISSUES_SETTINGS_NAMESPACE, null) + .get<'author' | 'assignee'>(ISSUE_AVATAR_DISPLAY, 'author'); + + let avatarUser: IAccount | undefined; + if ((avatarDisplaySetting === 'assignee') && element.assignees && (element.assignees.length > 0)) { + avatarUser = element.assignees[0]; + } else if (avatarDisplaySetting === 'author') { + avatarUser = element.author; + } + + if (avatarUser) { + // For enterprise, use placeholder icon instead of trying to fetch avatar + if (!DataUri.isGitHubDotComAvatar(avatarUser.avatarUrl)) { + treeItem.iconPath = new vscode.ThemeIcon('github'); } else { - const savedState = this.stateManager.getSavedIssueState(element.number); - if (savedState.branch) { - treeItem.contextValue = 'continueissue'; - } else { - treeItem.contextValue = 'issue'; - } + treeItem.iconPath = (await DataUri.avatarCirclesAsImageDataUris(this.context, [avatarUser], 16, 16))[0] ?? + (element.isOpen + ? new vscode.ThemeIcon('issues', new vscode.ThemeColor('issues.open')) + : new vscode.ThemeIcon('issue-closed', new vscode.ThemeColor('github.issues.closed'))); } - if (issueBodyHasLink(element)) { - treeItem.contextValue = 'link' + treeItem.contextValue; + } else { + // Use GitHub codicon when assignee setting is selected but no assignees exist + treeItem.iconPath = new vscode.ThemeIcon('github'); + } + + treeItem.command = { + command: 'issue.openDescription', + title: vscode.l10n.t('View Issue Description'), + arguments: [element] + }; + + if (this.stateManager.currentIssue(element.uri)?.issue.number === element.number) { + // 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); + if (savedState.branch) { + treeItem.contextValue = 'continueissue'; + } else { + treeItem.contextValue = 'issue'; } } + if (issueBodyHasLink(element)) { + treeItem.contextValue = 'link' + treeItem.contextValue; + } return treeItem; } + async getTreeItem(element: FolderRepositoryManager | QueryNode | IssueGroupNode | IssueItem): Promise { + if (element instanceof FolderRepositoryManager) { + return this.getFolderRepoItem(element); + } else if (element instanceof QueryNode) { + return this.getQueryItem(element); + } else if (element instanceof IssueGroupNode) { + return this.getIssueGroupItem(element); + } else { + return this.getIssueTreeItem(element); + } + } + getChildren( - element: FolderRepositoryManager | IssueItem | MilestoneItem | IssueUriTreeItem | undefined, - ): FolderRepositoryManager[] | Promise<(IssueItem | MilestoneItem)[]> | IssueItem[] | IssueUriTreeItem[] { + element: FolderRepositoryManager | QueryNode | IssueGroupNode | IssueItem | undefined, + ): FolderRepositoryManager[] | QueryNode[] | Promise { if (element === undefined && this.manager.state !== ReposManagerState.RepositoriesLoaded) { return this.getStateChildren(); } else { @@ -116,7 +167,7 @@ export class IssuesTreeData async resolveTreeItem( item: vscode.TreeItem, - element: FolderRepositoryManager | IssueItem | MilestoneItem | vscode.TreeItem, + element: FolderRepositoryManager | QueryNode | IssueGroupNode | IssueItem, ): Promise { if (element instanceof IssueModel) { item.tooltip = await issueMarkdown(element, this.context, this.manager); @@ -124,7 +175,7 @@ export class IssuesTreeData return item; } - getStateChildren(): IssueUriTreeItem[] { + getStateChildren(): [] { if ((this.manager.state === ReposManagerState.NeedsAuthentication) || !this.manager.folderManagers.length) { return []; @@ -134,50 +185,119 @@ export class IssuesTreeData } } - getQueryItems(folderManager: FolderRepositoryManager): Promise<(IssueItem | MilestoneItem)[]> | IssueUriTreeItem[] { - const issueCollection = this.stateManager.getIssueCollection(folderManager.repository.rootUri); - if (issueCollection.size === 1) { - return Array.from(issueCollection.values())[0]; + private getRootChildren(): FolderRepositoryManager[] | QueryNode[] | Promise { + // If there's only one folder manager go straight to the query nodes + if (this.manager.folderManagers.length === 1) { + return this.getRepoChildren(this.manager.folderManagers[0]); + } else if (this.manager.folderManagers.length > 1) { + return this.manager.folderManagers; + } else { + return []; } + } + + private getRepoChildren(folderManager: FolderRepositoryManager): QueryNode[] | Promise { + const issueCollection = this.stateManager.getIssueCollection(folderManager.repository.rootUri); const queryLabels = Array.from(issueCollection.keys()); - const firstLabel = queryLabels[0]; - return queryLabels.map(label => { - const item = new IssueUriTreeItem(folderManager.repository.rootUri, label); - item.contextValue = 'query'; - item.collapsibleState = - label === firstLabel - ? vscode.TreeItemCollapsibleState.Expanded - : vscode.TreeItemCollapsibleState.Collapsed; + if (queryLabels.length === 1) { + return this.getQueryNodeChildren(new QueryNode(folderManager.repository.rootUri, queryLabels[0], true)); + } + return queryLabels.map((label, index) => { + const item = new QueryNode(folderManager.repository.rootUri, label, index === 0); return item; }); } - getIssuesChildren( - element: FolderRepositoryManager | IssueItem | MilestoneItem | IssueUriTreeItem | undefined, - ): FolderRepositoryManager[] | Promise<(IssueItem | MilestoneItem)[]> | IssueItem[] | IssueUriTreeItem[] { - if (element === undefined) { - // If there's only one query, don't display a title for it - if (this.manager.folderManagers.length === 1) { - return this.getQueryItems(this.manager.folderManagers[0]); - } else if (this.manager.folderManagers.length > 1) { - return this.manager.folderManagers; + private async getQueryNodeChildren(queryNode: QueryNode): Promise { + const issueCollection = this.stateManager.getIssueCollection(queryNode.repoRootUri); + const issueQueryResult = await issueCollection.get(queryNode.queryLabel); + if (!issueQueryResult) { + return []; + } + return this.getIssueGroupsForGroupIndex(queryNode.repoRootUri, queryNode.queryLabel, queryNode.isFirst, issueQueryResult.groupBy, 0, issueQueryResult.issues ?? []); + } + + private getIssueGroupsForGroupIndex(repoRootUri: vscode.Uri, queryLabel: string, isFirst: boolean, groupByOrder: QueryGroup[], indexInGroupByOrder: number, issues: IssueItem[]): IssueGroupNode[] | IssueItem[] { + if (groupByOrder.length <= indexInGroupByOrder) { + return issues; + } + const groupByValue = groupByOrder[indexInGroupByOrder]; + if ((groupByValue !== 'milestone' && groupByValue !== 'repository') || groupByOrder.findIndex(groupBy => groupBy === groupByValue) !== indexInGroupByOrder) { + return this.getIssueGroupsForGroupIndex(repoRootUri, queryLabel, isFirst, groupByOrder, indexInGroupByOrder + 1, issues); + } + + const groups = groupBy(issues, issue => { + if (groupByValue === 'repository') { + return `${issue.remote.owner}/${issue.remote.repositoryName}`; } else { - return []; + return issue.milestone?.title ?? 'No Milestone'; } + }); + const nodes: IssueGroupNode[] = []; + for (const group in groups) { + nodes.push(new IssueGroupNode(repoRootUri, queryLabel, isFirst, indexInGroupByOrder, group, groupByOrder, groups[group])); + } + return nodes; + } + + private async getIssueGroupChildren(issueGroupNode: IssueGroupNode): Promise { + return this.getIssueGroupsForGroupIndex(issueGroupNode.repoRootUri, issueGroupNode.queryLabel, issueGroupNode.isInFirstQuery, issueGroupNode.groupByOrder, issueGroupNode.groupLevel + 1, issueGroupNode.issuesInGroup); + } + + getIssuesChildren( + element: FolderRepositoryManager | QueryNode | IssueGroupNode | IssueItem | undefined, + ): FolderRepositoryManager[] | QueryNode[] | Promise { + if (element === undefined) { + return this.getRootChildren(); } else if (element instanceof FolderRepositoryManager) { - return this.getQueryItems(element); - } else if (element instanceof IssueUriTreeItem) { - return element.uri - ? this.stateManager.getIssueCollection(element.uri).get(element.labelAsString!) ?? [] - : []; - } else if (!(element instanceof IssueModel)) { - return element.issues.map(item => { - const issueItem: IssueItem = Object.assign(item); - issueItem.uri = element.uri; - return issueItem; - }); + return this.getRepoChildren(element); + } else if (element instanceof QueryNode) { + return this.getQueryNodeChildren(element); + } else if (element instanceof IssueGroupNode) { + return this.getIssueGroupChildren(element); } else { return []; } } } + +const EXPANDED_ISSUES_STATE = 'expandedIssuesState'; + +function expandStateId(element: FolderRepositoryManager | QueryNode | IssueGroupNode | IssueItem) { + let id: string | undefined; + if (element instanceof FolderRepositoryManager) { + id = element.repository.rootUri.toString(); + } else if (element instanceof QueryNode) { + id = `${element.repoRootUri.toString()}/${element.queryLabel}`; + } else if (element instanceof IssueGroupNode) { + id = `${element.repoRootUri.toString()}/${element.queryLabel}/${element.groupLevel}/${element.group}`; + } + return id; +} + +export function updateExpandedQueries(context: vscode.ExtensionContext, element: FolderRepositoryManager | QueryNode | IssueGroupNode | IssueItem, isExpanded: boolean) { + const id = expandStateId(element); + + if (id) { + const expandedQueries = new Set(context.workspaceState.get(EXPANDED_ISSUES_STATE, []) as string[]); + if (isExpanded) { + expandedQueries.add(id); + } else { + expandedQueries.delete(id); + } + context.workspaceState.update(EXPANDED_ISSUES_STATE, Array.from(expandedQueries.keys())); + } +} + +function getQueryExpandState(context: vscode.ExtensionContext, element: FolderRepositoryManager | QueryNode | IssueGroupNode, defaultState: vscode.TreeItemCollapsibleState = vscode.TreeItemCollapsibleState.Expanded): vscode.TreeItemCollapsibleState { + const id = expandStateId(element); + if (id) { + const savedValue = context.workspaceState.get(EXPANDED_ISSUES_STATE); + if (!savedValue) { + return defaultState; + } + const expandedQueries = new Set(savedValue as string[]); + return expandedQueries.has(id) ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed; + } + return vscode.TreeItemCollapsibleState.None; +} diff --git a/src/issues/shareProviders.ts b/src/issues/shareProviders.ts new file mode 100644 index 0000000000..f652a0c9d2 --- /dev/null +++ b/src/issues/shareProviders.ts @@ -0,0 +1,263 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +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'; +import Logger from '../common/logger'; +import { fromReviewUri, Schemes } from '../common/uri'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { RepositoriesManager } from '../github/repositoriesManager'; + +export class ShareProviderManager extends Disposable { + + constructor(repositoryManager: RepositoriesManager, gitAPI: GitApiImpl) { + super(); + if (!vscode.window.registerShareProvider) { + return; + } + + this._register(new GitHubDevShareProvider(repositoryManager, gitAPI)); + this._register(new GitHubPermalinkShareProvider(repositoryManager, gitAPI)); + this._register(new GitHubPermalinkAsMarkdownShareProvider(repositoryManager, gitAPI)); + this._register(new GitHubHeadLinkShareProvider(repositoryManager, gitAPI)); + } +} + +const supportedSchemes = [Schemes.File, Schemes.Review, Schemes.Pr, Schemes.VscodeVfs]; + +abstract class AbstractShareProvider extends Disposable implements vscode.ShareProvider { + protected shareProviderRegistrations: vscode.Disposable[] | undefined; + + constructor( + protected repositoryManager: RepositoriesManager, + protected gitAPI: GitApiImpl, + public readonly id: string, + public readonly label: string, + public readonly priority: number, + private readonly origin = 'github.com' + ) { + super(); + this.initialize(); + } + + public override dispose() { + super.dispose(); + this.unregister(); + } + + private async initialize() { + if ((await this.hasGitHubRepositories()) && this.shouldRegister()) { + this.register(); + } + + this._register(this.repositoryManager.onDidLoadAnyRepositories(async () => { + if ((await this.hasGitHubRepositories()) && this.shouldRegister()) { + this.register(); + } + })); + + this._register(this.gitAPI.onDidCloseRepository(() => { + if (!this.hasGitHubRepositories()) { + this.unregister(); + } + })); + } + + private async hasGitHubRepositories() { + for (const folderManager of this.repositoryManager.folderManagers) { + if ((await folderManager.computeAllGitHubRemotes()).length) { + return true; + } + return false; + } + } + + private register() { + if (this.shareProviderRegistrations) { + return; + } + + this.shareProviderRegistrations = supportedSchemes.map((scheme) => vscode.window.registerShareProvider({ scheme }, this)); + } + + private unregister() { + if (this.shareProviderRegistrations) { + disposeAll(this.shareProviderRegistrations); + this.shareProviderRegistrations = undefined; + } + } + + protected abstract shouldRegister(): boolean; + protected abstract getBlob(folderManager: FolderRepositoryManager, uri: vscode.Uri): Promise; + protected abstract getUpstream(repository: Repository, commit: string): Promise; + + public async provideShare(item: vscode.ShareableItem): Promise { + // Get the blob + const folderManager = this.repositoryManager.getManagerForFile(item.resourceUri); + if (!folderManager) { + throw new Error(vscode.l10n.t('Current file does not belong to an open repository.')); + } + const blob = await this.getBlob(folderManager, item.resourceUri); + + // Get the upstream + const repository = folderManager.repository; + const remote = await this.getUpstream(repository, blob); + if (!remote || !remote.fetchUrl) { + throw new Error(vscode.l10n.t('The selection may not exist on any remote.')); + } + + const origin = getUpstreamOrigin(remote, this.origin).replace(/\/$/, ''); + const path = encodeURIComponentExceptSlashes(item.resourceUri.path.substring(repository.rootUri.path.length)); + const range = getRangeSegment(item); + + return vscode.Uri.parse([ + origin, + '/', + getOwnerAndRepo(this.repositoryManager, repository, { ...remote, fetchUrl: remote.fetchUrl }), + '/blob/', + blob, + path, + range + ].join('')); + } +} + +export class GitHubDevShareProvider extends AbstractShareProvider implements vscode.ShareProvider { + constructor(repositoryManager: RepositoriesManager, gitApi: GitApiImpl) { + super(repositoryManager, gitApi, 'githubDevLink', vscode.l10n.t('Copy github.dev Link'), 10, 'github.dev'); + } + + protected shouldRegister(): boolean { + return vscode.env.appHost === 'github.dev'; + } + + protected async getBlob(folderManager: FolderRepositoryManager): Promise { + return getHEAD(folderManager); + } + + protected async getUpstream(repository: Repository): Promise { + return getSimpleUpstream(repository); + } +} + +export class GitHubPermalinkShareProvider extends AbstractShareProvider implements vscode.ShareProvider { + constructor( + repositoryManager: RepositoriesManager, + gitApi: GitApiImpl, + id: string = 'githubComPermalink', + label: string = vscode.l10n.t('Copy GitHub Permalink'), + priority: number = 11 + ) { + super(repositoryManager, gitApi, id, label, priority); + } + + protected shouldRegister() { + return true; + } + + protected async getBlob(folderManager: FolderRepositoryManager, uri: vscode.Uri): Promise { + let commit: Commit | undefined; + let commitHash: string | undefined; + if (uri.scheme === Schemes.Review) { + commitHash = fromReviewUri(uri.query).commit; + } + + if (!commitHash) { + const repository = folderManager.repository; + try { + const log = await repository.log({ maxEntries: 1, path: uri.fsPath }); + if (log.length === 0) { + throw new Error(vscode.l10n.t('No branch on a remote contains the most recent commit for the file.')); + } + // Now that we know that the file existed at some point in the repo, use the head commit to construct the URI. + if (repository.state.HEAD?.commit && (log[0].hash !== repository.state.HEAD?.commit)) { + commit = await repository.getCommit(repository.state.HEAD.commit); + } + if (!commit) { + commit = log[0]; + } + commitHash = commit.hash; + } catch (e) { + commitHash = repository.state.HEAD?.commit; + } + } + + if (commitHash) { + return commitHash; + } + + throw new Error(); + } + + protected async getUpstream(repository: Repository, commit: string): Promise { + return getBestPossibleUpstream(this.repositoryManager, repository, (await repository.getCommit(commit)).hash); + } +} + +export class GitHubPermalinkAsMarkdownShareProvider extends GitHubPermalinkShareProvider { + private static ID = 'GitHubPermalinkAsMarkdownShareProvider'; + + constructor(repositoryManager: RepositoriesManager, gitApi: GitApiImpl) { + super(repositoryManager, gitApi, 'githubComPermalinkAsMarkdown', vscode.l10n.t('Copy GitHub Permalink as Markdown'), 12); + } + + override async provideShare(item: vscode.ShareableItem): Promise { + Logger.trace('providing permalink markdown share', GitHubPermalinkAsMarkdownShareProvider.ID); + const link = await super.provideShare(item); + + const text = await this.getMarkdownLinkText(item); + if (link) { + return `[${text?.trim() ?? ''}](${link.toString()})`; + } + } + + private async getMarkdownLinkText(item: vscode.ShareableItem): Promise { + return pathLib.basename(item.resourceUri.path); + } +} + +export class GitHubHeadLinkShareProvider extends AbstractShareProvider implements vscode.ShareProvider { + constructor(repositoryManager: RepositoriesManager, gitApi: GitApiImpl) { + super(repositoryManager, gitApi, 'githubComHeadLink', vscode.l10n.t('Copy GitHub HEAD Link'), 13); + } + + protected shouldRegister() { + return true; + } + + protected async getBlob(folderManager: FolderRepositoryManager): Promise { + return getHEAD(folderManager); + } + + protected async getUpstream(repository: Repository): Promise { + return getSimpleUpstream(repository); + } +} + +function getRangeSegment(item: vscode.ShareableItem): string { + if (item.resourceUri.scheme === 'vscode-notebook-cell') { + // Do not return a range or selection fragment for notebooks + // since github.com and github.dev do not support notebook deeplinks + return ''; + } + + return rangeString(item.selection); +} + +async function getHEAD(folderManager: FolderRepositoryManager) { + let branchName = folderManager.repository.state.HEAD?.name; + if (!branchName) { + // Fall back to default branch name if we are not currently on a branch + const origin = await folderManager.getOrigin(); + const metadata = await origin.getMetadata(); + branchName = metadata.default_branch; + } + + return encodeURIComponentExceptSlashes(branchName); +} diff --git a/src/issues/stateManager.ts b/src/issues/stateManager.ts index 1bf24f58d4..14316c0818 100644 --- a/src/issues/stateManager.ts +++ b/src/issues/stateManager.ts @@ -5,37 +5,34 @@ 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'; import { parseRepositoryRemotes } from '../common/remote'; +import { + DEFAULT, + DEV_MODE, + IGNORE_MILESTONES, + ISSUES_SETTINGS_NAMESPACE, + PR_SETTINGS_NAMESPACE, + QUERIES, + USE_BRANCH_FOR_ISSUES, +} from '../common/settingKeys'; import { FolderRepositoryManager, - NO_MILESTONE, PullRequestDefaults, ReposManagerState, } from '../github/folderRepositoryManager'; import { IAccount } from '../github/interface'; import { IssueModel } from '../github/issueModel'; -import { MilestoneModel } from '../github/milestoneModel'; import { RepositoriesManager } from '../github/repositoriesManager'; import { getIssueNumberLabel, variableSubstitution } from '../github/utils'; -import { CurrentIssue } from './currentIssue'; -import { - BRANCH_CONFIGURATION, - DEFAULT_QUERY_CONFIGURATION, - ISSUES_CONFIGURATION, - QUERIES_CONFIGURATION, -} from './util'; - -// TODO: make exclude from date words configurable -const excludeFromDate: string[] = ['Recovery']; + const CURRENT_ISSUE_KEY = 'currentIssue'; const ISSUES_KEY = 'issues'; -const IGNORE_MILESTONES_CONFIGURATION = 'ignoreMilestones'; - export interface IssueState { branch?: string; hasDraftPR?: boolean; @@ -50,11 +47,8 @@ interface IssuesState { branches: Record; } -const DEFAULT_QUERY_CONFIGURATION_VALUE = [{ label: vscode.l10n.t('My Issues'), query: 'default' }]; - -export interface MilestoneItem extends MilestoneModel { - uri: vscode.Uri; -} +// eslint-disable-next-line no-template-curly-in-string +const DEFAULT_QUERY_CONFIGURATION_VALUE: { label: string, query: string, groupBy: QueryGroup[] }[] = [{ label: vscode.l10n.t('My Issues'), query: 'is:open assignee:@me repo:${owner}/${repository}', groupBy: ['milestone'] }]; export class IssueItem extends IssueModel { uri: vscode.Uri; @@ -64,12 +58,19 @@ interface SingleRepoState { lastHead?: string; lastBranch?: string; currentIssue?: CurrentIssue; - issueCollection: Map>; + issueCollection: Map>; maxIssueNumber: number; userMap?: Promise>; folderManager: FolderRepositoryManager; } +export type QueryGroup = 'repository' | 'milestone'; + +export interface IssueQueryResult { + groupBy: QueryGroup[]; + issues: IssueItem[] | undefined; +} + export class StateManager { public readonly resolvedIssues: Map> = new Map(); private _singleRepoStates: Map = new Map(); @@ -77,14 +78,14 @@ export class StateManager { public onRefreshCacheNeeded: vscode.Event = this._onRefreshCacheNeeded.event; private _onDidChangeIssueData: vscode.EventEmitter = new vscode.EventEmitter(); public onDidChangeIssueData: vscode.Event = this._onDidChangeIssueData.event; - private _queries: { label: string; query: string }[] = []; + private _queries: { label: string; query: string, groupBy?: QueryGroup[] }[] = []; private _onDidChangeCurrentIssue: vscode.EventEmitter = new vscode.EventEmitter(); public readonly onDidChangeCurrentIssue: vscode.Event = this._onDidChangeCurrentIssue.event; private initializePromise: Promise | undefined; private statusBarItem?: vscode.StatusBarItem; - getIssueCollection(uri: vscode.Uri): Map> { + getIssueCollection(uri: vscode.Uri): Map> { let collection = this._singleRepoStates.get(uri.path)?.issueCollection; if (collection) { return collection; @@ -98,19 +99,18 @@ export class StateManager { readonly gitAPI: GitApiImpl, private manager: RepositoriesManager, private context: vscode.ExtensionContext, - ) { - manager.folderManagers.forEach(folderManager => { - this.context.subscriptions.push(folderManager.onDidChangeRepositories(() => this.refresh())); - }); - } + ) { } - private getOrCreateSingleRepoState(uri: vscode.Uri, folderManager?: FolderRepositoryManager): SingleRepoState { + private getOrCreateSingleRepoState(uri: vscode.Uri, folderManager?: FolderRepositoryManager): SingleRepoState | undefined { let state = this._singleRepoStates.get(uri.path); if (state) { return state; } if (!folderManager) { - folderManager = this.manager.getManagerForFile(uri)!; + folderManager = this.manager.getManagerForFile(uri); + } + if (!folderManager) { + return undefined; } state = { issueCollection: new Map(), @@ -152,6 +152,9 @@ export class StateManager { private registerRepositoryChangeEvent() { async function updateRepository(that: StateManager, repository: Repository) { const state = that.getOrCreateSingleRepoState(repository.rootUri); + if (!state) { + return; + } // setIssueData can cause the last head and branch state to change. Capture them before that can happen. const oldHead = state.lastHead; const oldBranch = state.lastBranch; @@ -170,7 +173,7 @@ export class StateManager { await that.setCurrentIssueFromBranch(state, newBranch, true); } } else { - await that.setCurrentIssue(state, undefined); + await that.setCurrentIssue(state, undefined, !!newBranch); } } state.lastHead = repository.state.HEAD ? repository.state.HEAD.commit : undefined; @@ -198,42 +201,64 @@ export class StateManager { this._onRefreshCacheNeeded.fire(); } - async refresh() { - return this.setAllIssueData(); + async refresh(folderManager?: FolderRepositoryManager) { + if (folderManager) { + return this.setIssueData(folderManager); + } else { + return this.setAllIssueData(); + } } private async doInitialize() { this.cleanIssueState(); this._queries = vscode.workspace - .getConfiguration(ISSUES_CONFIGURATION, null) - .get(QUERIES_CONFIGURATION, DEFAULT_QUERY_CONFIGURATION_VALUE); + .getConfiguration(ISSUES_SETTINGS_NAMESPACE, null) + .get(QUERIES, DEFAULT_QUERY_CONFIGURATION_VALUE); if (this._queries.length === 0) { this._queries = DEFAULT_QUERY_CONFIGURATION_VALUE; } this.context.subscriptions.push( vscode.workspace.onDidChangeConfiguration(change => { - if (change.affectsConfiguration(`${ISSUES_CONFIGURATION}.${QUERIES_CONFIGURATION}`)) { + if (change.affectsConfiguration(`${ISSUES_SETTINGS_NAMESPACE}.${QUERIES}`)) { this._queries = vscode.workspace - .getConfiguration(ISSUES_CONFIGURATION, null) - .get(QUERIES_CONFIGURATION, DEFAULT_QUERY_CONFIGURATION_VALUE); + .getConfiguration(ISSUES_SETTINGS_NAMESPACE, null) + .get(QUERIES, DEFAULT_QUERY_CONFIGURATION_VALUE); this._onRefreshCacheNeeded.fire(); - } else if (change.affectsConfiguration(`${ISSUES_CONFIGURATION}.${IGNORE_MILESTONES_CONFIGURATION}`)) { + } else if (change.affectsConfiguration(`${ISSUES_SETTINGS_NAMESPACE}.${IGNORE_MILESTONES}`)) { this._onRefreshCacheNeeded.fire(); } }), ); this.registerRepositoryChangeEvent(); - await this.setAllIssueData(); + // Skip fetching issues if dev mode is enabled + const devMode = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(DEV_MODE, false); + if (!devMode) { + await this.setAllIssueData(); + } this.context.subscriptions.push( this.onRefreshCacheNeeded(async () => { await this.refresh(); }), ); + for (const folderManager of this.manager.folderManagers) { - const singleRepoState: SingleRepoState = this.getOrCreateSingleRepoState( + this.context.subscriptions.push(folderManager.onDidChangeRepositories(async (e) => { + if (e.added) { + const state = this.getOrCreateSingleRepoState(folderManager.repository.rootUri); + + if (state && ((state.issueCollection.size === 0) || (await Promise.all(state.issueCollection.values())).some(collection => collection.issues === undefined))) { + this.refresh(folderManager); + } + } + })); + + const singleRepoState: SingleRepoState | undefined = this.getOrCreateSingleRepoState( folderManager.repository.rootUri, folderManager, ); + if (!singleRepoState) { + continue; + } singleRepoState.lastHead = folderManager.repository.state.HEAD ? folderManager.repository.state.HEAD.commit : undefined; @@ -272,10 +297,10 @@ export class StateManager { } async getUserMap(uri: vscode.Uri): Promise> { - if (!this.initializePromise) { + const state = this.getOrCreateSingleRepoState(uri); + if (!this.initializePromise || !state) { return Promise.resolve(new Map()); } - const state = this.getOrCreateSingleRepoState(uri); if (!state.userMap || (await state.userMap).size === 0) { state.userMap = this.getUsers(uri); } @@ -292,46 +317,42 @@ export class StateManager { private async setIssueData(folderManager: FolderRepositoryManager) { const singleRepoState = this.getOrCreateSingleRepoState(folderManager.repository.rootUri, folderManager); + if (!singleRepoState) { + return; + } singleRepoState.issueCollection.clear(); - let defaults: PullRequestDefaults | undefined; - let user: string | undefined; - for (const query of this._queries) { - let items: Promise; - if (query.query === DEFAULT_QUERY_CONFIGURATION) { - items = this.setMilestones(folderManager); - } else { - if (!defaults) { - try { - defaults = await folderManager.getPullRequestDefaults(); - } catch (e) { - // leave defaults undefined - } - } - if (!user) { - const enterpriseRemotes = parseRepositoryRemotes(folderManager.repository).filter( - remote => remote.authProviderId === AuthProvider['github-enterprise'] - ); - user = await this.getCurrentUser(enterpriseRemotes.length ? AuthProvider['github-enterprise'] : AuthProvider.github); - } - items = this.setIssues( - folderManager, - // Do not resolve pull request defaults as they will get resolved in the query later per repository - await variableSubstitution(query.query, undefined, undefined, user), - ); + const enterpriseRemotes = parseRepositoryRemotes(folderManager.repository).filter( + remote => remote.isEnterprise + ); + const user = await this.getCurrentUser(enterpriseRemotes.length ? AuthProvider.githubEnterprise : AuthProvider.github); + + for (let query of this._queries) { + let items: Promise | undefined; + if (query.query === DEFAULT) { + query = DEFAULT_QUERY_CONFIGURATION_VALUE[0]; + } + + items = this.setIssues( + folderManager, + // Do not resolve pull request defaults as they will get resolved in the query later per repository + variableSubstitution(query.query, undefined, undefined, user).trim(), + ).then(issues => ({ groupBy: query.groupBy ?? [], issues })); + + if (items) { + singleRepoState.issueCollection.set(query.label, items); } - singleRepoState.issueCollection.set(query.label, items); } singleRepoState.maxIssueNumber = await folderManager.getMaxIssue(); singleRepoState.lastHead = folderManager.repository.state.HEAD?.commit; singleRepoState.lastBranch = folderManager.repository.state.HEAD?.name; } - private setIssues(folderManager: FolderRepositoryManager, query: string): Promise { + private setIssues(folderManager: FolderRepositoryManager, query: string): Promise { return new Promise(async resolve => { - const issues = await folderManager.getIssues({ fetchNextPage: false, fetchOnePagePerRepo: true }, query); + const issues = await folderManager.getIssues(query, { fetchNextPage: false, fetchOnePagePerRepo: true }); this._onDidChangeIssueData.fire(); resolve( - issues.items.map(item => { + issues?.items.map(item => { const issueItem: IssueItem = item as IssueItem; issueItem.uri = folderManager.repository.rootUri; return issueItem; @@ -342,8 +363,8 @@ export class StateManager { private async setCurrentIssueFromBranch(singleRepoState: SingleRepoState, branchName: string, silent: boolean = false) { const createBranchConfig = vscode.workspace - .getConfiguration(ISSUES_CONFIGURATION) - .get(BRANCH_CONFIGURATION); + .getConfiguration(ISSUES_SETTINGS_NAMESPACE) + .get(USE_BRANCH_FOR_ISSUES); if (createBranchConfig === 'off') { return; } @@ -356,7 +377,7 @@ export class StateManager { return; } if (branchName === defaults.base) { - await this.setCurrentIssue(singleRepoState, undefined); + await this.setCurrentIssue(singleRepoState, undefined, false); return; } @@ -376,6 +397,7 @@ export class StateManager { await this.setCurrentIssue( singleRepoState, new CurrentIssue(issueModel, singleRepoState.folderManager, this), + false, silent ); } @@ -384,65 +406,6 @@ export class StateManager { } } - private setMilestones(folderManager: FolderRepositoryManager): Promise { - return new Promise(async resolve => { - const now = new Date(); - const skipMilestones: string[] = vscode.workspace - .getConfiguration(ISSUES_CONFIGURATION) - .get(IGNORE_MILESTONES_CONFIGURATION, []); - const milestones = await folderManager.getMilestoneIssues( - { fetchNextPage: false }, - skipMilestones.indexOf(NO_MILESTONE) < 0, - ); - let mostRecentPastTitleTime: Date | undefined = undefined; - const milestoneDateMap: Map = new Map(); - const milestonesToUse: MilestoneItem[] = []; - - // The number of milestones is expected to be very low, so two passes through is negligible - for (let i = 0; i < milestones.items.length; i++) { - const item: MilestoneItem = milestones.items[i] as MilestoneItem; - item.uri = folderManager.repository.rootUri; - const milestone = milestones.items[i].milestone; - if ((item.issues && item.issues.length <= 0) || skipMilestones.indexOf(milestone.title) >= 0) { - continue; - } - - milestonesToUse.push(item); - let milestoneDate = milestone.dueOn ? new Date(milestone.dueOn) : undefined; - if (!milestoneDate) { - milestoneDate = new Date(this.removeDateExcludeStrings(milestone.title)); - if (isNaN(milestoneDate.getTime())) { - milestoneDate = new Date(milestone.createdAt!); - } - } - if ( - milestoneDate < now && - (mostRecentPastTitleTime === undefined || milestoneDate > mostRecentPastTitleTime) - ) { - mostRecentPastTitleTime = milestoneDate; - } - milestoneDateMap.set(milestone.id ? milestone.id : milestone.title, milestoneDate); - } - - milestonesToUse.sort((a: MilestoneModel, b: MilestoneModel): number => { - const dateA = milestoneDateMap.get(a.milestone.id ? a.milestone.id : a.milestone.title)!; - const dateB = milestoneDateMap.get(b.milestone.id ? b.milestone.id : b.milestone.title)!; - if (mostRecentPastTitleTime && dateA >= mostRecentPastTitleTime && dateB >= mostRecentPastTitleTime) { - return dateA <= dateB ? -1 : 1; - } else { - return dateA >= dateB ? -1 : 1; - } - }); - this._onDidChangeIssueData.fire(); - resolve(milestonesToUse); - }); - } - - private removeDateExcludeStrings(possibleDate: string): string { - excludeFromDate.forEach(exclude => (possibleDate = possibleDate.replace(exclude, ''))); - return possibleDate; - } - currentIssue(uri: vscode.Uri): CurrentIssue | undefined { return this._singleRepoStates.get(uri.path)?.currentIssue; } @@ -458,7 +421,7 @@ export class StateManager { } private isSettingIssue: boolean = false; - async setCurrentIssue(repoState: SingleRepoState | FolderRepositoryManager, issue: CurrentIssue | undefined, silent: boolean = false) { + async setCurrentIssue(repoState: SingleRepoState | FolderRepositoryManager, issue: CurrentIssue | undefined, checkoutDefaultBranch: boolean, silent: boolean = false) { if (this.isSettingIssue && issue === undefined) { return; } @@ -474,8 +437,12 @@ export class StateManager { if (repoState.currentIssue && issue?.issue.number === repoState.currentIssue.issue.number) { return; } + // Check if branch management is disabled + const createBranchConfig = vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(USE_BRANCH_FOR_ISSUES); + const shouldCheckoutDefaultBranch = createBranchConfig === 'off' ? false : checkoutDefaultBranch; + if (repoState.currentIssue) { - await repoState.currentIssue.stopWorking(); + await repoState.currentIssue.stopWorking(shouldCheckoutDefaultBranch); } if (issue) { this.context.subscriptions.push(issue.onDidChangeCurrentIssueState(() => this.updateStatusBar())); diff --git a/src/issues/userCompletionProvider.ts b/src/issues/userCompletionProvider.ts index 43e19a4172..fbd5413cfd 100644 --- a/src/issues/userCompletionProvider.ts +++ b/src/issues/userCompletionProvider.ts @@ -3,15 +3,27 @@ * 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'; -import { Schemes } from '../common/uri'; -import { User } from '../github/interface'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { ASSIGNEES, extractIssueOriginFromQuery, NEW_ISSUE_SCHEME } from './issueFile'; +import Logger from '../common/logger'; +import { IGNORE_USER_COMPLETION_TRIGGER, ISSUES_SETTINGS_NAMESPACE } from '../common/settingKeys'; +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, ISSUES_CONFIGURATION, UserCompletion, userMarkdown } from './util'; +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'; export class UserCompletionProvider implements vscode.CompletionItemProvider { + private static readonly ID: string = 'UserCompletionProvider'; + private _gitBlameCache: { [key: string]: string } = {}; + constructor( private stateManager: StateManager, private manager: RepositoriesManager, @@ -38,7 +50,7 @@ export class UserCompletionProvider implements vscode.CompletionItemProvider { // If the suggest was not triggered by the trigger character, require that the previous character be the trigger character if ( document.languageId !== 'scminput' && - document.uri.scheme !== NEW_ISSUE_SCHEME && + document.uri.scheme !== Schemes.NewIssue && position.character > 0 && context.triggerKind === vscode.CompletionTriggerKind.Invoke && wordAtPos?.charAt(0) !== '@' @@ -48,7 +60,7 @@ export class UserCompletionProvider implements vscode.CompletionItemProvider { // If the suggest was not triggered by the trigger character and it's in a new issue file, make sure it's on the Assignees line. if ( - (document.uri.scheme === NEW_ISSUE_SCHEME) && + (document.uri.scheme === Schemes.NewIssue) && (context.triggerKind === vscode.CompletionTriggerKind.Invoke) && (document.getText(new vscode.Range(position.with(undefined, 0), position.with(undefined, ASSIGNEES.length))) !== ASSIGNEES) ) { @@ -58,14 +70,16 @@ export class UserCompletionProvider implements vscode.CompletionItemProvider { if ( context.triggerKind === vscode.CompletionTriggerKind.TriggerCharacter && vscode.workspace - .getConfiguration(ISSUES_CONFIGURATION) - .get('ignoreUserCompletionTrigger', []) + .getConfiguration(ISSUES_SETTINGS_NAMESPACE) + .get(IGNORE_USER_COMPLETION_TRIGGER, []) .find(value => value === document.languageId) ) { 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 []; } @@ -77,13 +91,22 @@ export class UserCompletionProvider implements vscode.CompletionItemProvider { } let uri: vscode.Uri | undefined = document.uri; - if (document.uri.scheme === NEW_ISSUE_SCHEME) { - uri = extractIssueOriginFromQuery(document.uri) ?? document.uri; + if (document.uri.scheme === Schemes.NewIssue) { + const params = fromNewIssueUri(document.uri); + uri = params?.originUri ?? document.uri; } else if (document.languageId === 'scminput') { uri = getRootUriFromScmInputUri(document.uri); } else if (document.uri.scheme === Schemes.Comment) { const activeTab = vscode.window.tabGroups.activeTabGroup.activeTab?.input; - uri = activeTab instanceof vscode.TabInputText ? activeTab.uri : (activeTab instanceof vscode.TabInputTextDiff ? activeTab.modified : undefined); + if (activeTab instanceof vscode.TabInputText) { + uri = activeTab.uri; + } else if (activeTab instanceof vscode.TabInputTextDiff) { + uri = activeTab.modified; + } else if ((activeTab as { textDiffs?: { modified: vscode.Uri, original: vscode.Uri }[] }).textDiffs) { + uri = (activeTab as { textDiffs: { modified: vscode.Uri, original: vscode.Uri }[] }).textDiffs[0].modified; + } else { + uri = vscode.workspace.workspaceFolders ? vscode.workspace.workspaceFolders[0].uri : undefined; + } } if (!uri) { @@ -92,27 +115,33 @@ export class UserCompletionProvider implements vscode.CompletionItemProvider { const repoUri = this.manager.getManagerForFile(uri)?.repository.rootUri ?? uri; - const completionItems: vscode.CompletionItem[] = []; - (await this.stateManager.getUserMap(repoUri)).forEach(item => { + let completionItems: vscode.CompletionItem[] = []; + const userMap = await this.stateManager.getUserMap(repoUri); + userMap.forEach(item => { + const login = item.specialDisplayName ?? item.login; const completionItem: UserCompletion = new UserCompletion( - { label: item.login, description: item.name }, vscode.CompletionItemKind.User); - completionItem.insertText = `@${item.login}`; + { label: login, description: item.name }, vscode.CompletionItemKind.User); + completionItem.insertText = `@${login}`; completionItem.login = item.login; completionItem.uri = repoUri; completionItem.range = range; completionItem.detail = item.name; completionItem.filterText = `@ ${item.login} ${item.name}`; - if (document.uri.scheme === NEW_ISSUE_SCHEME) { + if (document.uri.scheme === Schemes.NewIssue) { completionItem.commitCharacters = [' ', ',']; } completionItems.push(completionItem); }); + const commentSpecificSuggestions = await this.getCommentSpecificSuggestions(userMap, document, position); + if (commentSpecificSuggestions) { + completionItems = completionItems.concat(commentSpecificSuggestions); + } return completionItems; } private isCodeownersFiles(uri: vscode.Uri): boolean { const repositoryManager = this.manager.getManagerForFile(uri); - if (!repositoryManager || !uri.path.startsWith(repositoryManager.repository.rootUri.path)) { + if (!repositoryManager || !isDescendant(repositoryManager.repository.rootUri.fsPath, uri.fsPath)) { return false; } const subpath = uri.path.substring(repositoryManager.repository.rootUri.path.length).toLowerCase(); @@ -136,4 +165,183 @@ export class UserCompletionProvider implements vscode.CompletionItemProvider { } return item; } + + private cachedPrUsers: UserCompletion[] = []; + private cachedPrTimelineEvents: TimelineEvent[] = []; + private cachedForPrNumber: number | undefined; + private async getCommentSpecificSuggestions( + alreadyIncludedUsers: Map, + document: vscode.TextDocument, + position: vscode.Position) { + try { + const query = JSON.parse(document.uri.query); + if ((document.uri.scheme !== Schemes.Comment) || compareIgnoreCase(query.extensionId, EXTENSION_ID) !== 0) { + return; + } + + const wordRange = document.getWordRangeAtPosition( + position, + /@([a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38})?/i, + ); + if (!wordRange || wordRange.isEmpty) { + return; + } + const activeTextEditors = vscode.window.visibleTextEditors; + if (!activeTextEditors.length) { + return; + } + + let foundRepositoryManager: FolderRepositoryManager | undefined; + + let activeTextEditor: vscode.TextEditor | undefined; + let prNumber: number | undefined; + let remoteName: string | undefined; + + for (const editor of activeTextEditors) { + foundRepositoryManager = this.manager.getManagerForFile(editor.document.uri); + if (foundRepositoryManager) { + if (foundRepositoryManager.activePullRequest) { + prNumber = foundRepositoryManager.activePullRequest.number; + remoteName = foundRepositoryManager.activePullRequest.remote.remoteName; + break; + } else if (editor.document.uri.scheme === Schemes.Pr) { + const params = fromPRUri(editor.document.uri); + prNumber = params!.prNumber; + remoteName = params!.remoteName; + break; + } + } + } + + if (!foundRepositoryManager) { + return; + } + const repositoryManager = foundRepositoryManager; + + if (prNumber && prNumber === this.cachedForPrNumber) { + return this.cachedPrUsers; + } + + let prRelatedusers: { login: string; name?: string }[] = []; + const fileRelatedUsersNames: { [key: string]: boolean } = {}; + let mentionableUsers: { [key: string]: { login: string; name?: string }[] } = {}; + + const prRelatedUsersPromise = new Promise(async resolve => { + if (prNumber && remoteName) { + Logger.debug('get Timeline Events and parse users', UserCompletionProvider.ID); + if (this.cachedForPrNumber === prNumber) { + return this.cachedPrTimelineEvents; + } + + const githubRepo = repositoryManager.gitHubRepositories.find( + repo => repo.remote.remoteName === remoteName, + ); + + if (githubRepo) { + const pr = await githubRepo.getPullRequest(prNumber); + this.cachedForPrNumber = prNumber; + this.cachedPrTimelineEvents = await pr!.getTimelineEvents(); + } + + prRelatedusers = getRelatedUsersFromTimelineEvents(this.cachedPrTimelineEvents); + resolve(); + } + + resolve(); + }); + + const fileRelatedUsersNamesPromise = new Promise(async resolve => { + if (activeTextEditors.length) { + try { + Logger.debug('git blame and parse users', UserCompletionProvider.ID); + const fsPath = path.resolve(activeTextEditors[0].document.uri.fsPath); + let blames: string | undefined; + if (this._gitBlameCache[fsPath]) { + blames = this._gitBlameCache[fsPath]; + } else { + blames = await repositoryManager.repository.blame(fsPath); + this._gitBlameCache[fsPath] = blames; + } + + const blameLines = blames.split('\n'); + + for (const line of blameLines) { + const matches = /^\w{11} \S*\s*\((.*)\s*\d{4}\-/.exec(line); + + if (matches && matches.length === 2) { + const name = matches[1].trim(); + fileRelatedUsersNames[name] = true; + } + } + } catch (err) { + Logger.debug(err, UserCompletionProvider.ID); + } + } + + resolve(); + }); + + const getMentionableUsersPromise = new Promise(async resolve => { + Logger.debug('get mentionable users', UserCompletionProvider.ID); + mentionableUsers = await repositoryManager.getMentionableUsers(); + resolve(); + }); + + await Promise.all([ + prRelatedUsersPromise, + fileRelatedUsersNamesPromise, + getMentionableUsersPromise, + ]); + + this.cachedPrUsers = []; + const prRelatedUsersMap: { [key: string]: { login: string; name?: string } } = {}; + Logger.debug('prepare user suggestions', UserCompletionProvider.ID); + + prRelatedusers.forEach(user => { + if (!prRelatedUsersMap[user.login]) { + prRelatedUsersMap[user.login] = user; + } + }); + + const secondMap: { [key: string]: boolean } = {}; + + for (const mentionableUserGroup in mentionableUsers) { + for (const user of mentionableUsers[mentionableUserGroup]) { + if (!prRelatedUsersMap[user.login] && !secondMap[user.login] && !alreadyIncludedUsers.get(user.login)) { + secondMap[user.login] = true; + + let priority = 2; + if ( + fileRelatedUsersNames[user.login] || + (user.name && fileRelatedUsersNames[user.name]) + ) { + priority = 1; + } + + if (prRelatedUsersMap[user.login]) { + priority = 0; + } + + const completionItem: UserCompletion = new UserCompletion( + { label: user.login, description: user.name }, vscode.CompletionItemKind.User); + completionItem.insertText = `@${user.login}`; + completionItem.login = user.login; + completionItem.uri = repositoryManager.repository.rootUri; + completionItem.detail = user.name; + completionItem.filterText = `@ ${user.login} ${user.name}`; + completionItem.sortText = `${priority}_${user.login}`; + if (activeTextEditor?.document.uri.scheme === Schemes.NewIssue) { + completionItem.commitCharacters = [' ', ',']; + } + this.cachedPrUsers.push(completionItem); + } + } + } + + Logger.debug('done', UserCompletionProvider.ID); + return this.cachedPrUsers; + } catch (e) { + return []; + } + } } diff --git a/src/issues/userHoverProvider.ts b/src/issues/userHoverProvider.ts index 31f17f9589..671e71510f 100644 --- a/src/issues/userHoverProvider.ts +++ b/src/issues/userHoverProvider.ts @@ -4,16 +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, userMarkdown } from './util'; - - -// https://jsdoc.app/index.html -const JSDOC_NON_USERS = ['abstract', 'virtual', 'access', 'alias', 'async', 'augments', 'extends', 'author', 'borrows', 'callback', 'class', 'constructor', 'classdesc', 'constant', 'const', 'constructs', 'copyright', 'default', 'defaultvalue', 'deprecated', 'description', 'desc', 'enum', 'event', 'example', 'exports', 'external', 'host', 'file', 'fileoverview', 'overview', 'fires', 'emits', 'function', 'func', 'method', 'generator', 'global', 'hideconstructor', 'ignore', 'implements', 'inheritdoc', 'inner', 'instance', 'interface', 'kind', 'lends', 'license', 'listens', 'member', 'var', 'memberof', 'mixes', 'mixin', 'module', 'name', 'namespace', 'override', 'package', 'param', 'arg', 'argument', 'private', 'property', 'prop', 'protected', 'public', 'readonly', 'requires', 'returns', 'return', 'see', 'since', 'static', 'summary', 'this', 'throws', 'exception', 'todo', 'tutorial', 'type', 'typedef', 'variation', 'version', 'yields', 'yield', 'link']; - -// https://github.com/php-fig/fig-standards/blob/master/proposed/phpdoc-tags.md -const PHPDOC_NON_USERS = ['api', 'author', 'copyright', 'deprecated', 'generated', 'internal', 'link', 'method', 'package', 'param', 'property', 'return', 'see', 'since', 'throws', 'todo', 'uses', 'var', 'version']; export class UserHoverProvider implements vscode.HoverProvider { constructor(private manager: RepositoriesManager, private telemetry: ITelemetry) { } @@ -38,7 +33,13 @@ export class UserHoverProvider implements vscode.HoverProvider { if (match) { const username = match[1]; // JS and TS doc checks - if (((document.languageId === 'javascript') || (document.languageId === 'typescript')) + const JS_TS_LANGUAGE_IDS = [ + 'javascript', + 'javascriptreact', + 'typescript', + 'typescriptreact', + ]; + if (JS_TS_LANGUAGE_IDS.includes(document.languageId) && JSDOC_NON_USERS.indexOf(username) >= 0) { return; } @@ -46,6 +47,10 @@ export class UserHoverProvider implements vscode.HoverProvider { if ((document.languageId === 'php') && PHPDOC_NON_USERS.indexOf(username) >= 0) { return; } + const isDoxygenLanguage = document.languageId === 'cpp' || document.languageId === 'c' || document.languageId === 'csharp' || document.languageId === 'java' || document.languageId === 'objective-c' || document.languageId === 'php'; + if (isDoxygenLanguage && DOXYGEN_NON_USERS.indexOf(username) >= 0) { + return; + } return this.createHover(document.uri, username, wordPosition); } } else { diff --git a/src/issues/util.ts b/src/issues/util.ts index 76c8c4c914..3e54082abe 100644 --- a/src/issues/util.ts +++ b/src/issues/util.ts @@ -1,759 +1,574 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { URL } from 'url'; -import LRUCache from 'lru-cache'; -import * as marked from 'marked'; -import 'url-search-params-polyfill'; -import * as vscode from 'vscode'; -import { gitHubLabelColor } from '../../src/common/utils'; -import { Commit, Ref, Remote, Repository, UpstreamRef } from '../api/api'; -import { GitApiImpl } from '../api/api1'; -import { Protocol } from '../common/protocol'; -import { fromReviewUri, Schemes } from '../common/uri'; -import { FolderRepositoryManager, NoGitHubReposError, PullRequestDefaults } from '../github/folderRepositoryManager'; -import { GithubItemStateEnum, User } from '../github/interface'; -import { IssueModel } from '../github/issueModel'; -import { PullRequestModel } from '../github/pullRequestModel'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { getEnterpriseUri, getIssueNumberLabelFromParsed, getRepositoryForFile, ISSUE_OR_URL_EXPRESSION, ParsedIssue, parseIssueExpressionOutput } from '../github/utils'; -import { ReviewManager } from '../view/reviewManager'; -import { CODE_PERMALINK, findCodeLinkLocally } from './issueLinkLookup'; -import { StateManager } from './stateManager'; - -export const USER_EXPRESSION: RegExp = /\@([^\s]+)/; - -export const MAX_LINE_LENGTH = 150; - -export const ISSUES_CONFIGURATION: string = 'githubIssues'; -export const QUERIES_CONFIGURATION = 'queries'; -export const DEFAULT_QUERY_CONFIGURATION = 'default'; -export const BRANCH_NAME_CONFIGURATION = 'issueBranchTitle'; -export const BRANCH_CONFIGURATION = 'useBranchForIssues'; -export const SCM_MESSAGE_CONFIGURATION = 'workingIssueFormatScm'; - -export async function getIssue( - stateManager: StateManager, - manager: FolderRepositoryManager, - issueValue: string, - parsed: ParsedIssue, -): Promise { - const alreadyResolved = stateManager.resolvedIssues.get(manager.repository.rootUri.path)?.get(issueValue); - if (alreadyResolved) { - return alreadyResolved; - } else { - let owner: string | undefined = undefined; - let name: string | undefined = undefined; - let issueNumber: number | undefined = undefined; - const remotes = await manager.getGitHubRemotes(); - for (const remote of remotes) { - if (!parsed) { - const tryParse = parseIssueExpressionOutput(issueValue.match(ISSUE_OR_URL_EXPRESSION)); - if (tryParse && (!tryParse.name || !tryParse.owner)) { - owner = remote.owner; - name = remote.repositoryName; - } - } else { - owner = parsed.owner ? parsed.owner : remote.owner; - name = parsed.name ? parsed.name : remote.repositoryName; - issueNumber = parsed.issueNumber; - } - - if (owner && name && issueNumber !== undefined) { - let issue = await manager.resolveIssue(owner, name, issueNumber, !!parsed.commentNumber); - if (!issue) { - issue = await manager.resolvePullRequest(owner, name, issueNumber); - } - if (issue) { - let cached: LRUCache; - if (!stateManager.resolvedIssues.has(manager.repository.rootUri.path)) { - stateManager.resolvedIssues.set( - manager.repository.rootUri.path, - (cached = new LRUCache(50)), - ); - } else { - cached = stateManager.resolvedIssues.get(manager.repository.rootUri.path)!; - } - cached.set(issueValue, issue); - return issue; - } - } - } - } - return undefined; -} - -function repoCommitDate(user: User, repoNameWithOwner: string): string | undefined { - let date: string | undefined = undefined; - user.commitContributions.forEach(element => { - if (repoNameWithOwner.toLowerCase() === element.repoNameWithOwner.toLowerCase()) { - date = element.createdAt.toLocaleString('default', { day: 'numeric', month: 'short', year: 'numeric' }); - } - }); - return date; -} - -export class UserCompletion extends vscode.CompletionItem { - login: string; - uri: vscode.Uri; -} - -export function userMarkdown(origin: PullRequestDefaults, user: User): vscode.MarkdownString { - const markdown: vscode.MarkdownString = new vscode.MarkdownString(undefined, true); - markdown.appendMarkdown( - `![Avatar](${user.avatarUrl}|height=50,width=50) **${user.name}** [${user.login}](${user.url})`, - ); - if (user.bio) { - markdown.appendText(' \r\n' + user.bio.replace(/\r\n/g, ' ')); - } - - const date = repoCommitDate(user, origin.owner + '/' + origin.repo); - if (user.location || date) { - markdown.appendMarkdown(' \r\n\r\n---'); - } - if (user.location) { - markdown.appendMarkdown(` \r\n${vscode.l10n.t('{0} {1}', '$(location)', user.location)}`); - } - if (date) { - markdown.appendMarkdown(` \r\n${vscode.l10n.t('{0} Committed to this repository on {1}', '$(git-commit)', date)}`); - } - if (user.company) { - markdown.appendMarkdown(` \r\n${vscode.l10n.t({ message: '{0} Member of {1}', args: ['$(jersey)', user.company], comment: ['An organization that the user is a member of.', 'The first placeholder is an icon and shouldn\'t be localized.', 'The second placeholder is the name of the organization.'] })}`); - } - return markdown; -} - - -function makeLabel(color: string, text: string): string { - const isDarkTheme = vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark; - const labelColor = gitHubLabelColor(color, isDarkTheme, true); - return `  ${text}  `; -} - -async function findAndModifyString( - text: string, - find: RegExp, - transformer: (match: RegExpMatchArray) => Promise, -): Promise { - let searchResult = text.search(find); - let position = 0; - while (searchResult >= 0 && searchResult < text.length) { - let newBodyFirstPart: string | undefined; - if (searchResult === 0 || text.charAt(searchResult - 1) !== '&') { - const match = text.substring(searchResult).match(find)!; - if (match) { - const transformed = await transformer(match); - if (transformed) { - newBodyFirstPart = text.slice(0, searchResult) + transformed; - text = newBodyFirstPart + text.slice(searchResult + match[0].length); - } - } - } - position = newBodyFirstPart ? newBodyFirstPart.length : searchResult + 1; - const newSearchResult = text.substring(position).search(find); - searchResult = newSearchResult > 0 ? position + newSearchResult : newSearchResult; - } - return text; -} - -function findLinksInIssue(body: string, issue: IssueModel): Promise { - return findAndModifyString(body, ISSUE_OR_URL_EXPRESSION, async (match: RegExpMatchArray) => { - const tryParse = parseIssueExpressionOutput(match); - if (tryParse) { - const issueNumberLabel = getIssueNumberLabelFromParsed(tryParse); // get label before setting owner and name. - if (!tryParse.owner || !tryParse.name) { - tryParse.owner = issue.remote.owner; - tryParse.name = issue.remote.repositoryName; - } - return `[${issueNumberLabel}](https://github.com/${tryParse.owner}/${tryParse.name}/issues/${tryParse.issueNumber})`; - } - return undefined; - }); -} - -async function findCodeLinksInIssue(body: string, repositoriesManager: RepositoriesManager) { - return findAndModifyString(body, CODE_PERMALINK, async (match: RegExpMatchArray) => { - const codeLink = await findCodeLinkLocally(match, repositoriesManager); - if (codeLink) { - const textDocument = await vscode.workspace.openTextDocument(codeLink?.file); - const endingTextDocumentLine = textDocument.lineAt( - codeLink.end < textDocument.lineCount ? codeLink.end : textDocument.lineCount - 1, - ); - const query = [ - codeLink.file, - { - selection: { - start: { - line: codeLink.start, - character: 0, - }, - end: { - line: codeLink.end, - character: endingTextDocumentLine.text.length, - }, - }, - }, - ]; - const openCommand = vscode.Uri.parse(`command:vscode.open?${encodeURIComponent(JSON.stringify(query))}`); - return `[${match[0]}](${openCommand} "Open ${codeLink.file.fsPath}")`; - } - return undefined; - }); -} - -export const ISSUE_BODY_LENGTH: number = 200; -export async function issueMarkdown( - issue: IssueModel, - context: vscode.ExtensionContext, - repositoriesManager: RepositoriesManager, - commentNumber?: number, -): Promise { - const markdown: vscode.MarkdownString = new vscode.MarkdownString(undefined, true); - markdown.isTrusted = true; - const date = new Date(issue.createdAt); - const ownerName = `${issue.remote.owner}/${issue.remote.repositoryName}`; - markdown.appendMarkdown( - `[${ownerName}](https://github.com/${ownerName}) on ${date.toLocaleString('default', { - day: 'numeric', - month: 'short', - year: 'numeric', - })} \n`, - ); - const title = marked - .parse(issue.title, { - renderer: new PlainTextRenderer(), - }) - .trim(); - markdown.appendMarkdown( - `${getIconMarkdown(issue)} **${title}** [#${issue.number}](${issue.html_url}) \n`, - ); - let body = marked.parse(issue.body, { - renderer: new PlainTextRenderer(), - }); - markdown.appendMarkdown(' \n'); - body = body.length > ISSUE_BODY_LENGTH ? body.substr(0, ISSUE_BODY_LENGTH) + '...' : body; - body = await findLinksInIssue(body, issue); - body = await findCodeLinksInIssue(body, repositoriesManager); - - markdown.appendMarkdown(body + ' \n'); - markdown.appendMarkdown('  \n'); - - if (issue.item.labels.length > 0) { - issue.item.labels.forEach(label => { - markdown.appendMarkdown( - `[${makeLabel(label.color, label.name)}](https://github.com/${ownerName}/labels/${encodeURIComponent( - label.name, - )}) `, - ); - }); - } - - if (issue.item.comments && commentNumber) { - for (const comment of issue.item.comments) { - if (comment.databaseId === commentNumber) { - markdown.appendMarkdown(' \r\n\r\n---\r\n'); - markdown.appendMarkdown('  \n'); - markdown.appendMarkdown( - `![Avatar](${comment.author.avatarUrl}|height=15,width=15)   **${comment.author.login}** commented`, - ); - markdown.appendMarkdown('  \n'); - let commentText = marked.parse( - comment.body.length > ISSUE_BODY_LENGTH - ? comment.body.substr(0, ISSUE_BODY_LENGTH) + '...' - : comment.body, - { renderer: new PlainTextRenderer() }, - ); - commentText = await findLinksInIssue(commentText, issue); - markdown.appendMarkdown(commentText); - } - } - } - return markdown; -} - -function getIconString(issue: IssueModel) { - switch (issue.state) { - case GithubItemStateEnum.Open: { - return issue instanceof PullRequestModel ? '$(git-pull-request)' : '$(issues)'; - } - case GithubItemStateEnum.Closed: { - return issue instanceof PullRequestModel ? '$(git-pull-request)' : '$(issue-closed)'; - } - case GithubItemStateEnum.Merged: - return '$(git-merge)'; - } -} - -function getIconMarkdown(issue: IssueModel) { - if (issue instanceof PullRequestModel) { - return getIconString(issue); - } - switch (issue.state) { - case GithubItemStateEnum.Open: { - return `$(issues)`; - } - case GithubItemStateEnum.Closed: { - return `$(issue-closed)`; - } - } -} - -export interface NewIssue { - document: vscode.TextDocument; - lineNumber: number; - line: string; - insertIndex: number; - range: vscode.Range | vscode.Selection; -} - -const HEAD = 'HEAD'; -const UPSTREAM = 1; -const UPS = 2; -const ORIGIN = 3; -const OTHER = 4; -const REMOTE_CONVENTIONS = new Map([ - ['upstream', UPSTREAM], - ['ups', UPS], - ['origin', ORIGIN], -]); - -async function getUpstream(repositoriesManager: RepositoriesManager, repository: Repository, commit: Commit): Promise { - const currentRemoteName: string | undefined = - repository.state.HEAD?.upstream && !REMOTE_CONVENTIONS.has(repository.state.HEAD.upstream.remote) - ? repository.state.HEAD.upstream.remote - : undefined; - let currentRemote: Remote | undefined; - // getBranches is slow if we don't pass a very specific pattern - // so we can't just get all branches then filter/sort. - // Instead, we need to create parameters for getBranches such that there is only ever on possible return value, - // which makes it much faster. - // To do this, create very specific remote+branch patterns to look for and sort from "best" to "worst". - // Then, call getBranches with each pattern until one of them succeeds. - const remoteNames: { name: string; remote?: Remote }[] = repository.state.remotes - .map(remote => { - return { name: remote.name, remote }; - }) - .filter(value => { - // While we're already here iterating through all values, find the current remote for use later. - if (value.name === currentRemoteName) { - currentRemote = value.remote; - } - return REMOTE_CONVENTIONS.has(value.name); - }) - .sort((a, b): number => { - const aVal = REMOTE_CONVENTIONS.get(a.name) ?? OTHER; - const bVal = REMOTE_CONVENTIONS.get(b.name) ?? OTHER; - return aVal - bVal; - }); - - if (currentRemoteName) { - remoteNames.push({ name: currentRemoteName, remote: currentRemote }); - } - - const branchNames = [HEAD]; - if (repository.state.HEAD?.name && repository.state.HEAD.name !== HEAD) { - branchNames.unshift(repository.state.HEAD?.name); - } - let defaultBranch: PullRequestDefaults | undefined; - try { - defaultBranch = await repositoriesManager.getManagerForFile(repository.rootUri)?.getPullRequestDefaults(); - } catch (e) { - if (!(e instanceof NoGitHubReposError)) { - throw e; - } - } - if (defaultBranch) { - branchNames.push(defaultBranch.base); - } - let bestRef: Ref | undefined; - let bestRemote: Remote | undefined; - for (let branchIndex = 0; branchIndex < branchNames.length && !bestRef; branchIndex++) { - for (let remoteIndex = 0; remoteIndex < remoteNames.length && !bestRef; remoteIndex++) { - try { - const remotes = ( - await repository.getBranches({ - contains: commit.hash, - remote: true, - pattern: `remotes/${remoteNames[remoteIndex].name}/${branchNames[branchIndex]}`, - count: 1, - }) - ).filter(value => value.remote && value.name); - if (remotes && remotes.length > 0) { - bestRef = remotes[0]; - bestRemote = remoteNames[remoteIndex].remote; - } - } catch (e) { - // continue - } - } - } - - return bestRemote; -} - -function getFileAndPosition(fileUri?: vscode.Uri, positionInfo?: NewIssue): { uri: vscode.Uri | undefined, range: vscode.Range | vscode.NotebookRange | undefined } { - let uri: vscode.Uri; - let range: vscode.Range | vscode.NotebookRange | undefined; - if (fileUri) { - uri = fileUri; - if (vscode.window.activeTextEditor?.document.uri.fsPath === uri.fsPath) { - range = vscode.window.activeTextEditor.selection; - } - } else if (!positionInfo && vscode.window.activeTextEditor) { - uri = vscode.window.activeTextEditor.document.uri; - range = vscode.window.activeTextEditor.selection; - } else if (!positionInfo && vscode.window.activeNotebookEditor) { - uri = vscode.window.activeNotebookEditor.notebook.uri; - range = vscode.window.activeNotebookEditor.selection; - } else if (!positionInfo && vscode.window.tabGroups.activeTabGroup.activeTab?.input instanceof vscode.TabInputCustom) { - uri = vscode.window.tabGroups.activeTabGroup.activeTab.input.uri; - } else if (positionInfo) { - uri = positionInfo.document.uri; - range = positionInfo.range; - } else { - return { uri: undefined, range: undefined }; - } - return { uri, range }; -} - -export interface PermalinkInfo { - permalink: string | undefined; - error: string | undefined; - originalFile: vscode.Uri | undefined; -} - -function getSimpleUpstream(repository: Repository) { - const upstream: UpstreamRef | undefined = repository.state.HEAD?.upstream; - for (const remote of repository.state.remotes) { - // If we don't have an upstream, then just use the first remote. - if (!upstream || (upstream.remote === remote.name)) { - return remote; - } - } -} - -async function getBestPossibleUpstream(repositoriesManager: RepositoriesManager, repository: Repository, commit: Commit | undefined): Promise { - const fallbackUpstream = new Promise(resolve => { - resolve(getSimpleUpstream(repository)); - }); - - let upstream: Remote | undefined = commit ? await Promise.race([ - getUpstream(repositoriesManager, repository, commit), - new Promise(resolve => { - setTimeout(() => { - resolve(fallbackUpstream); - }, 1500); - }), - ]) : await fallbackUpstream; - - if (!upstream || !upstream.fetchUrl) { - // Check fallback - upstream = await fallbackUpstream; - if (!upstream || !upstream.fetchUrl) { - return undefined; - } - } - return upstream; -} - -function getOwnerAndRepo(repositoriesManager: RepositoriesManager, repository: Repository, upstream: Remote & { fetchUrl: string }): string { - const folderManager = repositoriesManager.getManagerForFile(repository.rootUri); - // Find the GitHub repository that matches the chosen upstream remote - const githubRepository = folderManager?.gitHubRepositories.find(githubRepository => { - return githubRepository.remote.remoteName === upstream.name; - }); - if (githubRepository) { - return `${githubRepository.remote.owner}/${githubRepository.remote.repositoryName}`; - } else { - return new Protocol(upstream.fetchUrl).nameWithOwner; - } -} - -export async function createGithubPermalink( - repositoriesManager: RepositoriesManager, - gitAPI: GitApiImpl, - positionInfo?: NewIssue, - fileUri?: vscode.Uri -): Promise { - const { uri, range } = getFileAndPosition(fileUri, positionInfo); - if (!uri) { - return { permalink: undefined, error: vscode.l10n.t('No active text editor position to create permalink from.'), originalFile: undefined }; - } - - const repository = getRepositoryForFile(gitAPI, uri); - if (!repository) { - return { permalink: undefined, error: vscode.l10n.t('The current file isn\'t part of repository.'), originalFile: uri }; - } - - let commit: Commit | undefined; - let commitHash: string | undefined; - if (uri.scheme === Schemes.Review) { - commitHash = fromReviewUri(uri.query).commit; - } - - if (!commitHash) { - try { - const log = await repository.log({ maxEntries: 1, path: uri.fsPath }); - if (log.length === 0) { - return { permalink: undefined, error: vscode.l10n.t('No branch on a remote contains the most recent commit for the file.'), originalFile: uri }; - } - // Now that we know that the file existed at some point in the repo, use the head commit to construct the URI. - if (repository.state.HEAD?.commit && (log[0].hash !== repository.state.HEAD?.commit)) { - commit = await repository.getCommit(repository.state.HEAD.commit); - } - if (!commit) { - commit = log[0]; - } - commitHash = commit.hash; - } catch (e) { - commitHash = repository.state.HEAD?.commit; - } - } - - const rawUpstream = await getBestPossibleUpstream(repositoriesManager, repository, commit); - 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 pathSegment = uri.path.substring(repository.rootUri.path.length); - const originOfFetchUrl = getUpstreamOrigin(rawUpstream).replace(/\/$/, ''); - return { - permalink: `${originOfFetchUrl}/${getOwnerAndRepo(repositoriesManager, repository, upstream)}/blob/${commitHash - }${pathSegment}${rangeString(range)}`, - error: undefined, - originalFile: uri - }; -} - -function getUpstreamOrigin(upstream: Remote) { - let resultHost: string = 'github.com'; - const enterpriseUri = getEnterpriseUri(); - let fetchUrl = upstream.fetchUrl; - if (enterpriseUri && fetchUrl) { - // upstream's origin by https - if (fetchUrl.startsWith('https://') && !fetchUrl.startsWith('https://github.com/')) { - const host = new URL(fetchUrl).host; - if (host.startsWith(enterpriseUri.authority)) { - resultHost = enterpriseUri.authority; - } - } - if (fetchUrl.startsWith('ssh://')) { - fetchUrl = fetchUrl.substr('ssh://'.length); - } - // upstream's origin by ssh - if (fetchUrl.startsWith('git@') && !fetchUrl.startsWith('git@github.com')) { - const host = fetchUrl.split('@')[1]?.split(':')[0]; - if (host.startsWith(enterpriseUri.authority)) { - resultHost = enterpriseUri.authority; - } - } - } - return `https://${resultHost}`; -} - -function rangeString(range: vscode.Range | vscode.NotebookRange | undefined) { - if (!range || (range instanceof vscode.NotebookRange)) { - return ''; - } - let hash = `#L${range.start.line + 1}`; - if (range.start.line !== range.end.line) { - hash += `-L${range.end.line + 1}`; - } - return hash; -} - -export async function createGitHubLink( - managers: RepositoriesManager, - fileUri?: vscode.Uri -): Promise { - const { uri, range } = getFileAndPosition(fileUri); - if (!uri) { - return { permalink: undefined, error: vscode.l10n.t('No active text editor position to create permalink from.'), originalFile: undefined }; - } - const folderManager = managers.getManagerForFile(uri); - if (!folderManager) { - return { permalink: undefined, error: vscode.l10n.t('Current file does not belong to an open repository.'), originalFile: undefined }; - } - let branchName = folderManager.repository.state.HEAD?.name; - if (!branchName) { - // Fall back to default branch name if we are not currently on a branch - const origin = await folderManager.getOrigin(); - const metadata = await origin.getMetadata(); - branchName = metadata.default_branch; - } - const upstream = getSimpleUpstream(folderManager.repository); - if (!upstream?.fetchUrl) { - return { permalink: undefined, error: vscode.l10n.t('Repository does not have any remotes.'), originalFile: undefined }; - } - const pathSegment = uri.path.substring(folderManager.repository.rootUri.path.length); - const originOfFetchUrl = getUpstreamOrigin(upstream).replace(/\/$/, ''); - return { - permalink: `${originOfFetchUrl}/${new Protocol(upstream.fetchUrl).nameWithOwner}/blob/${branchName - }${pathSegment}${rangeString(range)}`, - error: undefined, - originalFile: uri - }; -} - -async function commitWithDefault(manager: FolderRepositoryManager, stateManager: StateManager, all: boolean) { - const message = await stateManager.currentIssue(manager.repository.rootUri)?.getCommitMessage(); - if (message) { - return manager.repository.commit(message, { all }); - } -} - -const commitStaged = vscode.l10n.t('Commit Staged'); -const commitAll = vscode.l10n.t('Commit All'); -export async function pushAndCreatePR( - manager: FolderRepositoryManager, - reviewManager: ReviewManager, - stateManager: StateManager, -): Promise { - if (manager.repository.state.workingTreeChanges.length > 0 || manager.repository.state.indexChanges.length > 0) { - const responseOptions: string[] = []; - if (manager.repository.state.indexChanges) { - responseOptions.push(commitStaged); - } - if (manager.repository.state.workingTreeChanges) { - responseOptions.push(commitAll); - } - const changesResponse = await vscode.window.showInformationMessage( - vscode.l10n.t('There are uncommitted changes. Do you want to commit them with the default commit message?'), - { modal: true }, - ...responseOptions, - ); - switch (changesResponse) { - case commitStaged: { - await commitWithDefault(manager, stateManager, false); - break; - } - case commitAll: { - await commitWithDefault(manager, stateManager, true); - break; - } - default: - return false; - } - } - - if (manager.repository.state.HEAD?.upstream) { - await manager.repository.push(); - await reviewManager.createPullRequest(undefined); - return true; - } else { - let remote: string | undefined; - if (manager.repository.state.remotes.length === 1) { - remote = manager.repository.state.remotes[0].name; - } else if (manager.repository.state.remotes.length > 1) { - remote = await vscode.window.showQuickPick( - manager.repository.state.remotes.map(value => value.name), - { placeHolder: vscode.l10n.t('Remote to push to') }, - ); - } - if (remote) { - await manager.repository.push(remote, manager.repository.state.HEAD?.name, true); - await reviewManager.createPullRequest(undefined); - return true; - } else { - vscode.window.showWarningMessage( - vscode.l10n.t('The current repository has no remotes to push to. Please set up a remote and try again.'), - ); - return false; - } - } -} - -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; - } - } - return true; -} - -export async function shouldShowHover(document: vscode.TextDocument, position: vscode.Position): Promise { - if (document.lineAt(position.line).range.end.character > 10000) { - return false; - } - - return isComment(document, position); -} - -export function getRootUriFromScmInputUri(uri: vscode.Uri): vscode.Uri | undefined { - const rootUri = new URLSearchParams(uri.query).get('rootUri'); - return rootUri ? vscode.Uri.parse(rootUri) : undefined; -} - -export class PlainTextRenderer extends marked.Renderer { - code(code: string): string { - return code; - } - blockquote(quote: string): string { - return quote; - } - html(_html: string): string { - return ''; - } - heading(text: string, _level: 1 | 2 | 3 | 4 | 5 | 6, _raw: string, _slugger: marked.Slugger): string { - return text + ' '; - } - hr(): string { - return ''; - } - list(body: string, _ordered: boolean, _start: number): string { - return body; - } - listitem(text: string): string { - return ' ' + text; - } - checkbox(_checked: boolean): string { - return ''; - } - paragraph(text: string): string { - return text.replace(/\/g, '\\>') + ' '; - } - table(header: string, body: string): string { - return header + ' ' + body; - } - tablerow(content: string): string { - return content; - } - tablecell( - content: string, - _flags: { - header: boolean; - align: 'center' | 'left' | 'right' | null; - }, - ): string { - return content; - } - strong(text: string): string { - return text; - } - em(text: string): string { - return text; - } - codespan(code: string): string { - return `\\\`${code}\\\``; - } - br(): string { - return ' '; - } - del(text: string): string { - return text; - } - image(_href: string, _title: string, _text: string): string { - return ''; - } - text(text: string): string { - return text; - } - link(href: string, title: string, text: string): string { - return text + ' '; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +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'; +import { Protocol } from '../common/protocol'; +import { fromReviewUri, Schemes } from '../common/uri'; +import { FolderRepositoryManager, NoGitHubReposError, PullRequestDefaults } from '../github/folderRepositoryManager'; +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'; + +export const USER_EXPRESSION: RegExp = /\@([^\s]+)/; + +export const MAX_LINE_LENGTH = 150; +export const PERMALINK_COMPONENT = 'Permalink'; + +export async function getIssue( + stateManager: StateManager, + manager: FolderRepositoryManager, + issueValue: string, + parsed: ParsedIssue, +): Promise { + const alreadyResolved = stateManager.resolvedIssues.get(manager.repository.rootUri.path)?.get(issueValue); + if (alreadyResolved) { + return alreadyResolved; + } else { + let owner: string | undefined = undefined; + let name: string | undefined = undefined; + let issueNumber: number | undefined = undefined; + const remotes = await manager.getGitHubRemotes(); + for (const remote of remotes) { + if (!parsed) { + const tryParse = parseIssueExpressionOutput(issueValue.match(ISSUE_OR_URL_EXPRESSION)); + if (tryParse && (!tryParse.name || !tryParse.owner)) { + owner = remote.owner; + name = remote.repositoryName; + } + } else { + owner = parsed.owner ? parsed.owner : remote.owner; + name = parsed.name ? parsed.name : remote.repositoryName; + issueNumber = parsed.issueNumber; + } + + if (owner && name && issueNumber !== undefined) { + let issue = await manager.resolveIssue(owner, name, issueNumber, !!parsed.commentNumber); + if (!issue) { + issue = await manager.resolvePullRequest(owner, name, issueNumber); + } + if (issue) { + let cached: LRUCache; + if (!stateManager.resolvedIssues.has(manager.repository.rootUri.path)) { + stateManager.resolvedIssues.set( + manager.repository.rootUri.path, + (cached = new LRUCache(50)), + ); + } else { + cached = stateManager.resolvedIssues.get(manager.repository.rootUri.path)!; + } + cached.set(issueValue, issue); + return issue; + } + } + } + } + return undefined; +} + +export class UserCompletion extends vscode.CompletionItem { + login: string; + uri: vscode.Uri; +} + +export interface NewIssue { + document: vscode.TextDocument; + lineNumber: number; + line: string; + insertIndex: number; + range: vscode.Range | vscode.Selection; +} + +export interface IssueTemplate { + name: string | undefined, + about: string | undefined, + title: string | undefined, + labels: string[] | undefined, + assignees: string[] | undefined, + body: string | undefined +} + +export interface YamlIssueTemplate { + name?: string; + description?: string; + about?: string; + title?: string; + labels?: string[]; + assignees?: string[]; + body?: YamlTemplateField[]; +} + +export interface YamlTemplateField { + type: 'markdown' | 'textarea' | 'input' | 'dropdown' | 'checkboxes'; + id?: string; + attributes?: { + label?: string; + description?: string; + placeholder?: string; + value?: string; + options?: (string | { label?: string; required?: boolean })[]; + }; + validations?: { + required?: boolean; + }; +} + +const HEAD = 'HEAD'; +const UPSTREAM = 1; +const UPS = 2; +const ORIGIN = 3; +const OTHER = 4; +const REMOTE_CONVENTIONS = new Map([ + ['upstream', UPSTREAM], + ['ups', UPS], + ['origin', ORIGIN], +]); + +async function getUpstream(repositoriesManager: RepositoriesManager, repository: Repository, commitHash: string): Promise { + const currentRemoteName: string | undefined = + repository.state.HEAD?.upstream && !REMOTE_CONVENTIONS.has(repository.state.HEAD.upstream.remote) + ? repository.state.HEAD.upstream.remote + : undefined; + let currentRemote: Remote | undefined; + // getBranches is slow if we don't pass a very specific pattern + // so we can't just get all branches then filter/sort. + // Instead, we need to create parameters for getBranches such that there is only ever on possible return value, + // which makes it much faster. + // To do this, create very specific remote+branch patterns to look for and sort from "best" to "worst". + // Then, call getBranches with each pattern until one of them succeeds. + const remoteNames: { name: string; remote?: Remote }[] = repository.state.remotes + .map(remote => { + return { name: remote.name, remote }; + }) + .filter(value => { + // While we're already here iterating through all values, find the current remote for use later. + if (value.name === currentRemoteName) { + currentRemote = value.remote; + } + return REMOTE_CONVENTIONS.has(value.name); + }) + .sort((a, b): number => { + const aVal = REMOTE_CONVENTIONS.get(a.name) ?? OTHER; + const bVal = REMOTE_CONVENTIONS.get(b.name) ?? OTHER; + return aVal - bVal; + }); + + if (currentRemoteName) { + remoteNames.push({ name: currentRemoteName, remote: currentRemote }); + } + + const branchNames = [HEAD]; + if (repository.state.HEAD?.name && repository.state.HEAD.name !== HEAD) { + branchNames.unshift(repository.state.HEAD?.name); + } + let defaultBranch: PullRequestDefaults | undefined; + try { + defaultBranch = await repositoriesManager.getManagerForFile(repository.rootUri)?.getPullRequestDefaults(); + } catch (e) { + if (!(e instanceof NoGitHubReposError)) { + throw e; + } + } + if (defaultBranch) { + branchNames.push(defaultBranch.base); + } + let bestRef: Ref | undefined; + let bestRemote: Remote | undefined; + for (let branchIndex = 0; branchIndex < branchNames.length && !bestRef; branchIndex++) { + for (let remoteIndex = 0; remoteIndex < remoteNames.length && !bestRef; remoteIndex++) { + try { + const remotes = ( + await repository.getBranches({ + contains: commitHash, + remote: true, + pattern: `remotes/${remoteNames[remoteIndex].name}/${branchNames[branchIndex]}`, + count: 1, + }) + ).filter(value => value.remote && value.name); + if (remotes && remotes.length > 0) { + bestRef = remotes[0]; + bestRemote = remoteNames[remoteIndex].remote; + } + } catch (e) { + // continue + } + } + } + + return bestRemote; +} + +function extractContext(context: LinkContext): { fileUri: vscode.Uri | undefined, lineNumber: number | undefined } { + if (context instanceof vscode.Uri) { + return { fileUri: context, lineNumber: undefined }; + } + const asEditorLineNumberContext = context as Partial | undefined; + return { fileUri: asEditorLineNumberContext?.uri, lineNumber: asEditorLineNumberContext?.lineNumber }; +} + +function getFileAndPosition(context: LinkContext, positionInfo?: NewIssue): { uri: vscode.Uri | undefined, range: vscode.Range | vscode.NotebookRange | undefined } { + Logger.debug(`getting file and position`, PERMALINK_COMPONENT); + let uri: vscode.Uri; + let range: vscode.Range | vscode.NotebookRange | undefined; + + const { fileUri, lineNumber } = extractContext(context); + + if (fileUri) { + uri = fileUri; + if (vscode.window.activeTextEditor?.document.uri.fsPath === uri.fsPath && !vscode.window.activeNotebookEditor) { + if (lineNumber !== undefined && (vscode.window.activeTextEditor.selection.isEmpty || !vscode.window.activeTextEditor.selection.contains(new vscode.Position(lineNumber - 1, 0)))) { + range = new vscode.Range(new vscode.Position(lineNumber - 1, 0), new vscode.Position(lineNumber - 1, vscode.window.activeTextEditor.document.lineAt(lineNumber - 1).text.length)); + } else { + range = vscode.window.activeTextEditor.selection; + } + } + } else if (!positionInfo && vscode.window.activeTextEditor) { + uri = vscode.window.activeTextEditor.document.uri; + range = vscode.window.activeTextEditor.selection; + } else if (!positionInfo && vscode.window.activeNotebookEditor) { + uri = vscode.window.activeNotebookEditor.notebook.uri; + range = vscode.window.activeNotebookEditor.selection; + } else if (!positionInfo && vscode.window.tabGroups.activeTabGroup.activeTab?.input instanceof vscode.TabInputCustom) { + uri = vscode.window.tabGroups.activeTabGroup.activeTab.input.uri; + } else if (positionInfo) { + uri = positionInfo.document.uri; + range = positionInfo.range; + } else { + return { uri: undefined, range: undefined }; + } + Logger.debug(`got file and position: ${uri.fsPath} ${range?.start ? (range.start instanceof vscode.Position ? `${range.start.line}:${range.start.character}` : range.start) : 'unknown'}`, PERMALINK_COMPONENT); + return { uri, range }; +} + +export interface PermalinkInfo { + permalink: string | undefined; + error: string | undefined; + originalFile: vscode.Uri | undefined; + range: vscode.Range | vscode.NotebookRange | undefined; +} + +export function getSimpleUpstream(repository: Repository) { + const upstream: UpstreamRef | undefined = repository.state.HEAD?.upstream; + for (const remote of repository.state.remotes) { + // If we don't have an upstream, then just use the first remote. + if (!upstream || (upstream.remote === remote.name)) { + return remote; + } + } +} + +export async function getBestPossibleUpstream(repositoriesManager: RepositoriesManager, repository: Repository, commitHash: string | undefined): Promise { + const fallbackUpstream = new Promise(resolve => { + resolve(getSimpleUpstream(repository)); + }); + + let upstream: Remote | undefined = commitHash ? await Promise.race([ + getUpstream(repositoriesManager, repository, commitHash), + new Promise(resolve => { + setTimeout(() => { + resolve(fallbackUpstream); + }, 1500); + }), + ]) : await fallbackUpstream; + + if (!upstream || !upstream.fetchUrl) { + // Check fallback + upstream = await fallbackUpstream; + if (!upstream || !upstream.fetchUrl) { + return undefined; + } + } + return upstream; +} + +export function getOwnerAndRepo(repositoriesManager: RepositoriesManager, repository: Repository, upstream: Remote & { fetchUrl: string }): string { + const folderManager = repositoriesManager.getManagerForFile(repository.rootUri); + // Find the GitHub repository that matches the chosen upstream remote + const githubRepository = folderManager?.gitHubRepositories.find(githubRepository => { + return githubRepository.remote.remoteName === upstream.name; + }); + if (githubRepository) { + return `${githubRepository.remote.owner}/${githubRepository.remote.repositoryName}`; + } else { + return new Protocol(upstream.fetchUrl).nameWithOwner; + } +} + +export async function createSinglePermalink( + repositoriesManager: RepositoriesManager, + gitAPI: GitApiImpl, + includeRange: boolean, + includeFile: boolean, + positionInfo?: NewIssue, + context?: LinkContext +): Promise { + const { uri, range } = getFileAndPosition(context, positionInfo); + if (!uri) { + return { permalink: undefined, error: vscode.l10n.t('No active text editor position to create permalink from.'), originalFile: undefined, range: undefined }; + } + + const repository = getRepositoryForFile(gitAPI, uri); + if (!repository) { + return { permalink: undefined, error: vscode.l10n.t('The current file isn\'t part of repository.'), originalFile: uri, range }; + } + + let commitHash: string | undefined; + if (uri.scheme === Schemes.Review) { + commitHash = fromReviewUri(uri.query).commit; + } + + if (!commitHash) { + try { + const log = await repository.log({ maxEntries: 1, path: uri.fsPath }); + if (log.length === 0) { + return { permalink: undefined, error: vscode.l10n.t('No branch on a remote contains the most recent commit for the file.'), originalFile: uri, range }; + } + // Now that we know that the file existed at some point in the repo, use the head commit to construct the URI. + if (repository.state.HEAD?.commit && (log[0].hash !== repository.state.HEAD?.commit)) { + commitHash = repository.state.HEAD.commit; + } else { + commitHash = log[0].hash; + } + } catch (e) { + commitHash = repository.state.HEAD?.commit; + } + } + + Logger.debug(`commit hash: ${commitHash}`, PERMALINK_COMPONENT); + + const rawUpstream = await getBestPossibleUpstream(repositoriesManager, repository, commitHash); + if (!rawUpstream || !rawUpstream.fetchUrl) { + return { permalink: undefined, error: vscode.l10n.t('The selection may not exist on any remote.'), originalFile: uri, range }; + } + const upstream: Remote & { fetchUrl: string } = rawUpstream as Remote & { fetchUrl: string }; + + Logger.debug(`upstream: ${upstream.fetchUrl}`, PERMALINK_COMPONENT); + + const encodedPathSegment = encodeURIComponentExceptSlashes(uri.path.substring(repository.rootUri.path.length)); + const originOfFetchUrl = getUpstreamOrigin(rawUpstream).replace(/\/$/, ''); + const result = { + permalink: (`${originOfFetchUrl}/${getOwnerAndRepo(repositoriesManager, repository, upstream)}/blob/${commitHash + }${includeFile ? `${encodedPathSegment}${includeRange ? rangeString(range) : ''}` : ''}`), + error: undefined, + originalFile: uri, + range + }; + Logger.debug(`permalink generated: ${result.permalink}`, PERMALINK_COMPONENT); + return result; +} + +export async function createGithubPermalink( + repositoriesManager: RepositoriesManager, + gitAPI: GitApiImpl, + includeRange: boolean, + includeFile: boolean, + positionInfo?: NewIssue, + contexts?: LinkContext[] +): Promise { + return vscode.window.withProgress({ location: vscode.ProgressLocation.Window }, async (progress) => { + progress.report({ message: vscode.l10n.t('Creating permalink...') }); + let contextIndex = 0; + let context: LinkContext | undefined = contexts ? contexts[contextIndex++] : undefined; + const links: Promise[] = []; + do { + links.push(createSinglePermalink(repositoriesManager, gitAPI, includeRange, includeFile, positionInfo, context)); + context = contexts ? contexts[contextIndex++] : undefined; + } while (context); + + return Promise.all(links); + }); +} + +export function getUpstreamOrigin(upstream: Remote, resultHost: string = 'github.com') { + const enterpriseUri = getEnterpriseUri(); + let fetchUrl = upstream.fetchUrl; + if (enterpriseUri && fetchUrl) { + const protocol = new Protocol(fetchUrl); + if (protocol.host.startsWith(enterpriseUri.authority) || !protocol.host.includes('github.com')) { + resultHost = enterpriseUri.authority; + } + } + return `https://${resultHost}`; +} + +export function encodeURIComponentExceptSlashes(path: string) { + // There may be special characters like # and whitespace in the path. + // These characters are not escaped by encodeURI(), so it is not sufficient to + // feed the full URI to encodeURI(). + // Additonally, if we feed the full path into encodeURIComponent(), + // this will also encode the path separators, leading to an invalid path. + // Therefore, split on the path separator and encode each segment individually. + return path.split('/').map((segment) => encodeURIComponent(segment)).join('/'); +} + +export function rangeString(range: vscode.Range | vscode.NotebookRange | undefined) { + if (!range || (range instanceof vscode.NotebookRange)) { + return ''; + } + let hash = `#L${range.start.line + 1}`; + if (range.start.line !== range.end.line) { + hash += `-L${range.end.line + 1}`; + } + return hash; +} + +interface EditorLineNumberContext { + uri: vscode.Uri; + lineNumber: number; +} +export type LinkContext = vscode.Uri | EditorLineNumberContext | undefined; + +export async function createSingleGitHubLink( + managers: RepositoriesManager, + context?: vscode.Uri, + includeRange?: boolean +): Promise { + const { uri, range } = getFileAndPosition(context); + if (!uri) { + return { permalink: undefined, error: vscode.l10n.t('No active text editor position to create permalink from.'), originalFile: undefined, range: undefined }; + } + const folderManager = managers.getManagerForFile(uri); + if (!folderManager) { + return { permalink: undefined, error: vscode.l10n.t('Current file does not belong to an open repository.'), originalFile: undefined, range: undefined }; + } + let branchName = folderManager.repository.state.HEAD?.name; + if (!branchName) { + // Fall back to default branch name if we are not currently on a branch + const origin = await folderManager.getOrigin(); + const metadata = await origin.getMetadata(); + branchName = metadata.default_branch; + } + const upstream = getSimpleUpstream(folderManager.repository); + if (!upstream?.fetchUrl) { + return { permalink: undefined, error: vscode.l10n.t('Repository does not have any remotes.'), originalFile: undefined, range: undefined }; + } + const pathSegment = uri.path.substring(folderManager.repository.rootUri.path.length); + const originOfFetchUrl = getUpstreamOrigin(upstream).replace(/\/$/, ''); + const encodedBranchAndFilePath = encodeURIComponentExceptSlashes(`${branchName}${pathSegment}`); + return { + permalink: (`${originOfFetchUrl}/${new Protocol(upstream.fetchUrl).nameWithOwner}/blob/${encodedBranchAndFilePath + }${includeRange ? rangeString(range) : ''}`), + error: undefined, + originalFile: uri, + range + }; +} + +export async function createGitHubLink( + managers: RepositoriesManager, + contexts?: vscode.Uri[], + includeRange?: boolean +): Promise { + let contextIndex = 0; + let context: vscode.Uri | undefined = contexts ? contexts[contextIndex++] : undefined; + const links: Promise[] = []; + do { + links.push(createSingleGitHubLink(managers, context, includeRange)); + context = contexts ? contexts[contextIndex++] : undefined; + } while (context); + + return Promise.all(links); +} + +async function commitWithDefault(manager: FolderRepositoryManager, stateManager: StateManager, all: boolean) { + const message = await stateManager.currentIssue(manager.repository.rootUri)?.getCommitMessage(); + if (message) { + return manager.repository.commit(message, { all }); + } +} + +const commitStaged = vscode.l10n.t('Commit Staged'); +const commitAll = vscode.l10n.t('Commit All'); +export async function pushAndCreatePR( + manager: FolderRepositoryManager, + reviewManager: ReviewManager, + stateManager: StateManager, +): Promise { + if (manager.repository.state.workingTreeChanges.length > 0 || manager.repository.state.indexChanges.length > 0) { + const responseOptions: string[] = []; + if (manager.repository.state.indexChanges) { + responseOptions.push(commitStaged); + } + if (manager.repository.state.workingTreeChanges) { + responseOptions.push(commitAll); + } + const changesResponse = await vscode.window.showInformationMessage( + vscode.l10n.t('There are uncommitted changes. Do you want to commit them with the default commit message?'), + { modal: true }, + ...responseOptions, + ); + switch (changesResponse) { + case commitStaged: { + await commitWithDefault(manager, stateManager, false); + break; + } + case commitAll: { + await commitWithDefault(manager, stateManager, true); + break; + } + default: + return false; + } + } + + if (manager.repository.state.HEAD?.upstream) { + await manager.repository.push(); + await reviewManager.createPullRequest(undefined); + return true; + } else { + let remote: string | undefined; + if (manager.repository.state.remotes.length === 1) { + remote = manager.repository.state.remotes[0].name; + } else if (manager.repository.state.remotes.length > 1) { + remote = await vscode.window.showQuickPick( + manager.repository.state.remotes.map(value => value.name), + { placeHolder: vscode.l10n.t('Remote to push to') }, + ); + } + if (remote) { + await manager.repository.push(remote, manager.repository.state.HEAD?.name, true); + await reviewManager.createPullRequest(undefined); + return true; + } else { + vscode.window.showWarningMessage( + vscode.l10n.t('The current repository has no remotes to push to. Please set up a remote and try again.'), + ); + return false; + } + } +} + +export async function isComment(document: vscode.TextDocument, position: vscode.Position): Promise { + const tokenInfo = await vscode.languages.getTokenInformationAtPosition(document, position); + if (tokenInfo.type !== vscode.StandardTokenType.Comment) { + return false; + } + + return true; +} + +export async function shouldShowHover(document: vscode.TextDocument, position: vscode.Position): Promise { + if (document.lineAt(position.line).range.end.character > 10000) { + return false; + } + + return isComment(document, position); +} + +export function getRootUriFromScmInputUri(uri: vscode.Uri): vscode.Uri | undefined { + const rootUri = new URLSearchParams(uri.query).get('rootUri'); + return rootUri ? vscode.Uri.parse(rootUri) : undefined; +} + +export function escapeMarkdown(text: string): string { + return text.replace(/([_~*])/g, '\\$1'); +} + diff --git a/src/lm/issueContextProvider.ts b/src/lm/issueContextProvider.ts new file mode 100644 index 0000000000..83a36c5067 --- /dev/null +++ b/src/lm/issueContextProvider.ts @@ -0,0 +1,101 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CommentEvent, EventType } from '../common/timelineEvent'; +import { IssueModel } from '../github/issueModel'; +import { IssueOverviewPanel } from '../github/issueOverview'; +import { issueMarkdown } from '../github/markdownUtils'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { getIssueNumberLabel } from '../github/utils'; +import { IssueQueryResult, StateManager } from '../issues/stateManager'; + +export interface IssueChatContextItem extends vscode.ChatContextItem { + issue: IssueModel; +} + +export namespace IssueChatContextItem { + export function is(item: unknown): item is IssueChatContextItem { + return (item as IssueChatContextItem).issue !== undefined; + } +} + +export class IssueContextProvider implements vscode.ChatContextProvider { + constructor(private readonly _stateManager: StateManager, + private readonly _reposManager: RepositoriesManager, + private readonly _context: vscode.ExtensionContext + ) { } + + async provideChatContextForResource(_options: { resource: vscode.Uri }, _token: vscode.CancellationToken): Promise { + const item = IssueOverviewPanel.currentPanel?.getCurrentItem(); + if (item) { + return this._issueToUnresolvedContext(item); + } + } + + async resolveChatContext(context: IssueChatContextItem, _token: vscode.CancellationToken): Promise { + context.value = await this._resolvedIssueValue(context.issue); + context.modelDescription = 'All the information about the GitHub issue the user is viewing, including comments.'; + context.tooltip = await issueMarkdown(context.issue, this._context, this._reposManager); + return context; + } + + async provideChatContextExplicit(_token: vscode.CancellationToken): Promise { + const contextItems: IssueChatContextItem[] = []; + const seenIssues: Set = new Set(); + for (const folderManager of this._reposManager.folderManagers) { + const issueData = this._stateManager.getIssueCollection(folderManager?.repository.rootUri); + + for (const issueQuery of issueData) { + const issuesOrMilestones: IssueQueryResult = await issueQuery[1]; + + if ((issuesOrMilestones.issues ?? []).length === 0) { + continue; + } + for (const issue of (issuesOrMilestones.issues ?? [])) { + const issueKey = getIssueNumberLabel(issue as IssueModel); + // Only add the issue if we haven't seen it before (first query wins) + if (seenIssues.has(issueKey)) { + continue; + } + seenIssues.add(issueKey); + contextItems.push(this._issueToUnresolvedContext(issue as IssueModel)); + + } + } + } + return contextItems; + } + + private _issueToUnresolvedContext(issue: IssueModel): IssueChatContextItem { + return { + icon: new vscode.ThemeIcon('issues'), + label: `#${issue.number} ${issue.title}`, + modelDescription: 'The GitHub issue the user is viewing.', + tooltip: new vscode.MarkdownString(`#${issue.number} ${issue.title}`), + issue, + command: { + command: 'issue.openDescription', + title: vscode.l10n.t('Open Issue') + } + }; + } + + private async _resolvedIssueValue(issue: IssueModel): Promise { + const timeline = issue.timelineEvents ?? await issue.getIssueTimelineEvents(); + return JSON.stringify({ + issueNumber: issue.number, + owner: issue.remote.owner, + repo: issue.remote.repositoryName, + title: issue.title, + body: issue.body, + comments: timeline.filter(e => e.event === EventType.Commented).map((e: CommentEvent) => ({ + author: e.user?.login, + body: e.body, + createdAt: e.createdAt + })) + }); + } +} \ No newline at end of file diff --git a/src/lm/participants.ts b/src/lm/participants.ts new file mode 100644 index 0000000000..94fe90f925 --- /dev/null +++ b/src/lm/participants.ts @@ -0,0 +1,210 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; +import { renderPrompt } from '@vscode/prompt-tsx'; +import * as vscode from 'vscode'; +import { ParticipantsPrompt } from './participantsPrompt'; +import { Disposable } from '../common/lifecycle'; +import { IToolCall, TOOL_COMMAND_RESULT, TOOL_MARKDOWN_RESULT } from './tools/toolsUtils'; + +export class ChatParticipantState { + private _messages: vscode.LanguageModelChatMessage[] = []; + + get lastToolResult(): (vscode.LanguageModelTextPart | vscode.LanguageModelToolResultPart | vscode.LanguageModelToolCallPart)[] { + for (let i = this._messages.length - 1; i >= 0; i--) { + const message = this._messages[i]; + for (const part of message.content) { + if (part instanceof vscode.LanguageModelToolResultPart) { + return message.content; + } + } + } + return []; + } + + get firstUserMessage(): vscode.LanguageModelTextPart | undefined { + for (let i = 0; i < this._messages.length; i++) { + const message = this._messages[i]; + if (message.role === vscode.LanguageModelChatMessageRole.User && message.content) { + for (const part of message.content) { + if (part instanceof vscode.LanguageModelTextPart) { + return part; + } + } + } + } + return undefined; + } + + get messages(): vscode.LanguageModelChatMessage[] { + return this._messages; + } + + addMessage(message: vscode.LanguageModelChatMessage): void { + this._messages.push(message); + } + + addMessages(messages: vscode.LanguageModelChatMessage[]): void { + this._messages.push(...messages); + } + + reset(): void { + this._messages = []; + } +} + +export class ChatParticipant extends Disposable { + + constructor(context: vscode.ExtensionContext, private readonly state: ChatParticipantState) { + super(); + const ghprChatParticipant = this._register(vscode.chat.createChatParticipant('githubpr', ( + request: vscode.ChatRequest, + context: vscode.ChatContext, + stream: vscode.ChatResponseStream, + token: vscode.CancellationToken + ) => this.handleParticipantRequest(request, context, stream, token))); + ghprChatParticipant.iconPath = vscode.Uri.joinPath(context.extensionUri, 'resources/icons/github_logo.png'); + } + + async handleParticipantRequest( + request: vscode.ChatRequest, + context: vscode.ChatContext, + stream: vscode.ChatResponseStream, + token: vscode.CancellationToken + ): Promise { + this.state.reset(); + + const models = await vscode.lm.selectChatModels({ + vendor: 'copilot', + family: 'gpt-4o' + }); + const model = models[0]; + + + const allTools: vscode.LanguageModelChatTool[] = []; + for (const tool of vscode.lm.tools) { + if (request.tools.has(tool) && request.tools.get(tool)) { + allTools.push(tool); + } else if (tool.name.startsWith('github-pull-request')) { + allTools.push(tool); + } + } + + const { messages } = await renderPrompt( + ParticipantsPrompt, + { userMessage: request.prompt }, + { modelMaxPromptTokens: model.maxInputTokens }, + model); + + this.state.addMessages(messages); + + const toolReferences = [...request.toolReferences]; + const options: vscode.LanguageModelChatRequestOptions = { + justification: 'Answering user questions pertaining to GitHub.' + }; + + const commands: vscode.Command[] = []; + const runWithFunctions = async (): Promise => { + + const requestedTool = toolReferences.shift(); + if (requestedTool) { + options.toolMode = vscode.LanguageModelChatToolMode.Required; + options.tools = allTools.filter(tool => tool.name === requestedTool.name); + } else { + options.toolMode = undefined; + options.tools = allTools; + } + + const toolCalls: IToolCall[] = []; + const response = await model.sendRequest(this.state.messages, options, token); + + for await (const part of response.stream) { + + if (part instanceof vscode.LanguageModelTextPart) { + stream.markdown(part.value); + } else if (part instanceof vscode.LanguageModelToolCallPart) { + + const tool = vscode.lm.tools.find(tool => tool.name === part.name); + if (!tool) { + throw new Error('Got invalid tool choice: ' + part.name); + } + + let input: any; + try { + input = part.input; + } catch (err) { + throw new Error(`Got invalid tool use parameters: "${JSON.stringify(part.input)}". (${(err as Error).message})`); + } + + const invocationOptions: vscode.LanguageModelToolInvocationOptions = { input, toolInvocationToken: request.toolInvocationToken }; + toolCalls.push({ + call: part, + result: vscode.lm.invokeTool(tool.name, invocationOptions, token), + tool + }); + } + } + + if (toolCalls.length) { + const assistantMsg = vscode.LanguageModelChatMessage.Assistant(''); + assistantMsg.content = toolCalls.map(toolCall => new vscode.LanguageModelToolCallPart(toolCall.call.callId, toolCall.tool.name, toolCall.call.input)); + this.state.addMessage(assistantMsg); + + let shownToUser = false; + for (const toolCall of toolCalls) { + let toolCallResult = (await toolCall.result); + + const additionalContent: vscode.LanguageModelTextPart[] = []; + let result: vscode.LanguageModelToolResultPart | undefined; + + for (let i = 0; i < toolCallResult.content.length; i++) { + const part = toolCallResult.content[i]; + if (!(part instanceof vscode.LanguageModelTextPart)) { + // We only support text results for now, will change when we finish adopting prompt-tsx + result = new vscode.LanguageModelToolResultPart(toolCall.call.callId, toolCallResult.content); + continue; + } + + if (part.value === TOOL_MARKDOWN_RESULT) { + const markdown = new vscode.MarkdownString((toolCallResult.content[++i] as vscode.LanguageModelTextPart).value); + markdown.supportHtml = true; + stream.markdown(markdown); + shownToUser = true; + } else if (part.value === TOOL_COMMAND_RESULT) { + commands.push(JSON.parse((toolCallResult.content[++i] as vscode.LanguageModelTextPart).value) as vscode.Command); + } else { + if (!result) { + result = new vscode.LanguageModelToolResultPart(toolCall.call.callId, [part]); + } else { + additionalContent.push(part); + } + } + } + const message = vscode.LanguageModelChatMessage.User(''); + message.content = [result!]; + this.state.addMessage(message); + if (additionalContent.length) { + const additionalMessage = vscode.LanguageModelChatMessage.User(''); + additionalMessage.content = additionalContent; + this.state.addMessage(additionalMessage); + } + } + + this.state.addMessage(vscode.LanguageModelChatMessage.User(`Above is the result of calling the functions ${toolCalls.map(call => call.tool.name).join(', ')}. ${shownToUser ? 'The user can see the result of the tool call.' : ''}`)); + return runWithFunctions(); + } + }; + await runWithFunctions(); + this.addButtons(stream, commands); + } + + private addButtons(stream: vscode.ChatResponseStream, commands: vscode.Command[]) { + for (const command of commands) { + stream.button(command); + } + } +} + 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/pullRequestContextProvider.ts b/src/lm/pullRequestContextProvider.ts new file mode 100644 index 0000000000..f8963f5229 --- /dev/null +++ b/src/lm/pullRequestContextProvider.ts @@ -0,0 +1,157 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { GitApiImpl } from '../api/api1'; +import { Disposable } from '../common/lifecycle'; +import { onceEvent } from '../common/utils'; +import { issueMarkdown } from '../github/markdownUtils'; +import { PullRequestModel } from '../github/pullRequestModel'; +import { PullRequestOverviewPanel } from '../github/pullRequestOverview'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { PrsTreeModel } from '../view/prsTreeModel'; + +export interface PRChatContextItem extends vscode.ChatContextItem { + pr?: PullRequestModel; +} + +export namespace PRChatContextItem { + export function is(item: unknown): item is PRChatContextItem { + return (item as PRChatContextItem).pr !== undefined; + } +} + +export class PullRequestContextProvider extends Disposable implements vscode.ChatContextProvider { + private readonly _onDidChangeWorkspaceChatContext = new vscode.EventEmitter(); + readonly onDidChangeWorkspaceChatContext = this._onDidChangeWorkspaceChatContext.event; + + constructor(private readonly _prsTreeModel: PrsTreeModel, + private readonly _reposManager: RepositoriesManager, + private readonly _git: GitApiImpl, + private readonly _context: vscode.ExtensionContext + ) { + super(); + } + + /** + * Do this setup in the initialize method so that it can be called after the provider is registered. + */ + async initialize() { + if (this._git.state === 'uninitialized') { + await new Promise(resolve => { + this._register(onceEvent(this._git.onDidChangeState)(() => resolve())); + }); + } + this._reposManager.folderManagers.forEach(folderManager => { + this._register(folderManager.onDidChangeActivePullRequest(() => { + this._onDidChangeWorkspaceChatContext.fire(); + })); + }); + this._register(this._reposManager.onDidChangeFolderRepositories(e => { + if (!e.added) { + return; + } + this._register(e.added.onDidChangeActivePullRequest(() => { + this._onDidChangeWorkspaceChatContext.fire(); + })); + this._onDidChangeWorkspaceChatContext.fire(); + })); + this._register(this._reposManager.onDidChangeAnyGitHubRepository(() => { + this._onDidChangeWorkspaceChatContext.fire(); + })); + this._onDidChangeWorkspaceChatContext.fire(); + } + + async provideWorkspaceChatContext(_token: vscode.CancellationToken): Promise { + const modelDescription = this._reposManager.folderManagers.length > 1 ? 'Information about one of the current repositories. You can use this information when you need to calculate diffs or compare changes with the default branch' : 'Information about the current repository. You can use this information when you need to calculate diffs or compare changes with the default branch'; + const contexts: vscode.ChatContextItem[] = []; + for (const folderManager of this._reposManager.folderManagers) { + if (folderManager.gitHubRepositories.length === 0) { + continue; + } + const defaults = await folderManager.getPullRequestDefaults(); + + let value = `Repository name: ${defaults.repo} +Owner: ${defaults.owner} +Current branch: ${folderManager.repository.state.HEAD?.name ?? 'unknown'} +Default branch: ${defaults.base}`; + if (folderManager.activePullRequest) { + value = `${value} +Active pull request (may not be the same as open pull request): ${folderManager.activePullRequest.title} ${folderManager.activePullRequest.html_url}`; + } + contexts.push({ + icon: new vscode.ThemeIcon('github-alt'), + label: `${defaults.owner}/${defaults.repo}`, + modelDescription, + value + }); + } + return contexts; + } + + async provideChatContextForResource(_options: { resource: vscode.Uri }, _token: vscode.CancellationToken): Promise { + const item = PullRequestOverviewPanel.currentPanel?.getCurrentItem(); + if (item) { + return this._prToUnresolvedContext(item); + } + } + + async resolveChatContext(context: PRChatContextItem, _token: vscode.CancellationToken): Promise { + if (!context.pr) { + return context; + } + context.value = await this._resolvedPrValue(context.pr); + context.modelDescription = 'All the information about the GitHub pull request the user is viewing, including comments, review threads, and changes.'; + context.tooltip = await issueMarkdown(context.pr, this._context, this._reposManager); + return context; + } + + async provideChatContextExplicit(_token: vscode.CancellationToken): Promise { + const prs = await this._prsTreeModel.getAllPullRequests(this._reposManager.folderManagers[0], false); + return prs.items.map(pr => { + return this._prToUnresolvedContext(pr); + }); + } + + private _prToUnresolvedContext(pr: PullRequestModel): PRChatContextItem { + return { + icon: new vscode.ThemeIcon('git-pull-request'), + label: `#${pr.number} ${pr.title}`, + modelDescription: 'The GitHub pull request the user is viewing.', + tooltip: new vscode.MarkdownString(`#${pr.number} ${pr.title}`), + pr, + command: { + command: 'pr.openDescription', + title: vscode.l10n.t('Open Pull Request') + } + }; + } + + private async _resolvedPrValue(pr: PullRequestModel): Promise { + return JSON.stringify({ + prNumber: pr.number, + owner: pr.remote.owner, + repo: pr.remote.repositoryName, + title: pr.title, + body: pr.body, + comments: pr.comments.map(comment => ({ + author: comment.user?.login, + body: comment.body, + createdAt: comment.createdAt + })), + threads: (pr.reviewThreadsCache ?? await pr.getReviewThreads()).map(thread => ({ + comments: thread.comments.map(comment => ({ + author: comment.user?.login, + body: comment.body, + createdAt: comment.createdAt + })), + isResolved: thread.isResolved + })), + changes: (pr.rawFileChanges ?? await pr.getRawFileChangesInfo()).map(change => { + return change.patch; + }) + }); + } +} \ No newline at end of file diff --git a/src/lm/tools/activePullRequestTool.ts b/src/lm/tools/activePullRequestTool.ts new file mode 100644 index 0000000000..fbcc258e67 --- /dev/null +++ b/src/lm/tools/activePullRequestTool.ts @@ -0,0 +1,117 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import * as vscode from 'vscode'; +import { FetchIssueResult } from './fetchIssueTool'; +import { GitChangeType, InMemFileChange } from '../../common/file'; +import { CommentEvent, EventType, ReviewEvent } from '../../common/timelineEvent'; +import { PullRequestModel } from '../../github/pullRequestModel'; +import { RepositoriesManager } from '../../github/repositoriesManager'; + +export abstract class PullRequestTool implements vscode.LanguageModelTool { + constructor( + protected readonly folderManagers: RepositoriesManager + ) { } + + protected abstract _findActivePullRequest(): PullRequestModel | undefined; + + protected abstract _confirmationTitle(): string; + + private _getPullRequestLabel(pullRequest: PullRequestModel): string { + return `${pullRequest.title} (#${pullRequest.number})`; + } + + async prepareInvocation(): Promise { + const pullRequest = this._findActivePullRequest(); + if (!pullRequest) { + return { + pastTenseMessage: vscode.l10n.t('No active pull request'), + invocationMessage: vscode.l10n.t('Reading active pull request'), + confirmationMessages: { title: this._confirmationTitle(), message: vscode.l10n.t('Allow reading the details of the active pull request?') }, + }; + } + + const label = this._getPullRequestLabel(pullRequest); + return { + pastTenseMessage: vscode.l10n.t('Read pull request "{0}"', label), + invocationMessage: vscode.l10n.t('Reading pull request "{0}"', label), + confirmationMessages: { title: this._confirmationTitle(), message: vscode.l10n.t('Allow reading the details of "{0}"?', label) }, + }; + } + + async invoke(_options: vscode.LanguageModelToolInvocationOptions, _token: vscode.CancellationToken): Promise { + let pullRequest = this._findActivePullRequest(); + + if (!pullRequest) { + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart('There is no active pull request')]); + } + + const status = await pullRequest.getStatusChecks(); + const timeline = (pullRequest.timelineEvents && pullRequest.timelineEvents.length > 0) ? pullRequest.timelineEvents : await pullRequest.getTimelineEvents(); + const pullRequestInfo = { + title: pullRequest.title, + body: pullRequest.body, + author: pullRequest.author, + assignees: pullRequest.assignees, + comments: pullRequest.comments.map(comment => { + return { + author: comment.user?.login, + body: comment.body, + commentState: comment.isResolved ? 'resolved' : 'unresolved', + file: comment.path + }; + }), + timelineComments: timeline.filter((event): event is ReviewEvent | CommentEvent => event.event === EventType.Reviewed || event.event === EventType.Commented).map(event => { + return { + author: event.user?.login, + body: event.body, + commentType: event.event === EventType.Reviewed ? event.state : 'COMMENTED', + }; + }), + state: pullRequest.state, + statusChecks: status[0]?.statuses.map((status) => { + return { + context: status.context, + description: status.description, + state: status.state, + name: status.workflowName, + targetUrl: status.targetUrl + }; + }), + reviewRequirements: { + approvalsNeeded: status[1]?.count ?? 0, + currentApprovals: status[1]?.approvals.length ?? 0, + areChangesRequested: (status[1]?.requestedChanges.length ?? 0) > 0, + }, + isDraft: pullRequest.isDraft ? 'is a draft and cannot be merged until marked as ready for review' : 'false', + changes: (await pullRequest.getFileChangesInfo()).map(change => { + if (change instanceof InMemFileChange) { + return change.diffHunks?.map(hunk => hunk.diffLines.map(line => line.raw).join('\n')).join('\n') || ''; + } else { + return `File: ${change.fileName} was ${change.status === GitChangeType.ADD ? 'added' : change.status === GitChangeType.DELETE ? 'deleted' : 'modified'}.`; + } + }) + }; + + const result = new vscode.ExtendedLanguageModelToolResult([new vscode.LanguageModelTextPart(JSON.stringify(pullRequestInfo))]); + result.toolResultDetails = [vscode.Uri.parse(pullRequest.html_url)]; + return result; + } + +} + +export class ActivePullRequestTool extends PullRequestTool { + public static readonly toolId = 'github-pull-request_activePullRequest'; + + protected _findActivePullRequest(): PullRequestModel | undefined { + const folderManager = this.folderManagers.folderManagers.find((manager) => manager.activePullRequest); + return folderManager?.activePullRequest; + } + + protected _confirmationTitle(): string { + return vscode.l10n.t('Active Pull Request'); + } +} \ No newline at end of file diff --git a/src/lm/tools/displayIssuesTool.ts b/src/lm/tools/displayIssuesTool.ts new file mode 100644 index 0000000000..8177718f87 --- /dev/null +++ b/src/lm/tools/displayIssuesTool.ts @@ -0,0 +1,173 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import * as vscode from 'vscode'; +import { ensureEmojis } from '../../common/emoji'; +import Logger from '../../common/logger'; +import { reviewerLabel } from '../../github/interface'; +import { makeLabel } from '../../github/utils'; +import { ChatParticipantState } from '../participants'; +import { IssueSearchResultAccount, IssueSearchResultItem, SearchToolResult } from './searchTools'; +import { concatAsyncIterable, TOOL_MARKDOWN_RESULT, ToolBase } from './toolsUtils'; + +export type DisplayIssuesParameters = SearchToolResult; + +type IssueColumn = keyof IssueSearchResultItem; + +const LLM_FIND_IMPORTANT_COLUMNS_INSTRUCTIONS = `Instructions: +You are an expert on GitHub issues. You can help the user identify the most important columns for rendering issues based on a query for issues: +- Include a column related to the sort value, if given. +- Output a newline separated list of columns only, max 4 columns. +- List the columns in the order they should be displayed. +- Don't change the casing. +- Don't include columns that will all have the same value for all the resulting issues. +Here are the possible columns: +`; + +export class DisplayIssuesTool extends ToolBase { + public static readonly toolId = 'github-pull-request_renderIssues'; + private static ID = 'DisplayIssuesTool'; + constructor(private readonly context: vscode.ExtensionContext, chatParticipantState: ChatParticipantState) { + super(chatParticipantState); + } + + private assistantPrompt(issues: IssueSearchResultItem[]): string { + const possibleColumns = Object.keys(issues[0]); + return `${LLM_FIND_IMPORTANT_COLUMNS_INSTRUCTIONS}\n${possibleColumns.map(column => `- ${column}`).join('\n')}\nHere's the data you have about the issues:\n`; + } + + private postProcess(proposedColumns: string, issues: IssueSearchResultItem[]): IssueColumn[] { + const lines = proposedColumns.split('\n'); + const possibleColumns = Object.keys(issues[0]); + const finalColumns: IssueColumn[] = []; + for (let line of lines) { + line = line.trim(); + if (line === '') { + continue; + } + if (!possibleColumns.includes(line)) { + // Check if the llm decided to use formatting, even though we asked it not to + const splitOnSpace = line.split(' '); + if (splitOnSpace.length > 1) { + const testColumn = splitOnSpace[splitOnSpace.length - 1]; + if (possibleColumns.includes(testColumn)) { + finalColumns.push(testColumn as IssueColumn); + } + } + } else { + finalColumns.push(line as IssueColumn); + } + } + return finalColumns; + } + + private async getImportantColumns(issueItemsInfo: vscode.LanguageModelTextPart | undefined, issues: IssueSearchResultItem[], token: vscode.CancellationToken): Promise { + if (!issueItemsInfo) { + return ['number', 'title', 'state']; + } + + // Try to get the llm to tell us which columns are important based on information it has about the issues + const models = await vscode.lm.selectChatModels({ + vendor: 'copilot', + family: 'gpt-4o' + }); + const model = models[0]; + const chatOptions: vscode.LanguageModelChatRequestOptions = { + justification: 'Answering user questions pertaining to GitHub.' + }; + const messages = [vscode.LanguageModelChatMessage.Assistant(this.assistantPrompt(issues))]; + messages.push(new vscode.LanguageModelChatMessage(vscode.LanguageModelChatMessageRole.User, issueItemsInfo?.value)); + const response = await model.sendRequest(messages, chatOptions, token); + const result = this.postProcess(await concatAsyncIterable(response.text), issues); + const indexOfUrl = result.indexOf('url'); + if (result.length === 0) { + return ['number', 'title', 'state']; + } else if (indexOfUrl >= 0) { + // Never include the url column + result.splice(indexOfUrl, 1); + } + + return result; + } + + private renderUser(account: IssueSearchResultAccount) { + return `[@${reviewerLabel(account)}](${account.url})`; + } + + private issueToRow(issue: IssueSearchResultItem, importantColumns: IssueColumn[]): string { + return `| ${importantColumns.map(column => { + switch (column) { + case 'number': + return `[${issue[column]}](${issue.url})`; + case 'labels': + return issue[column]?.map((label) => label?.name && label.color ? makeLabel({ name: label.name, color: label.color }) : '').join(', '); + case 'assignees': + return issue[column]?.map((assignee) => this.renderUser(assignee)).join(', '); + case 'author': + const account = issue[column]; + return account ? this.renderUser(account) : ''; + case 'createdAt': + case 'updatedAt': + const updatedAt = issue[column]; + return updatedAt ? new Date(updatedAt).toLocaleDateString() : ''; + case 'milestone': + return issue[column]; + default: + return issue[column]; + } + }).join(' | ')} |`; + } + + private foundIssuesCount(params: DisplayIssuesParameters): number { + return params.totalIssues !== undefined ? params.totalIssues : (params.arrayOfIssues?.length ?? 0); + } + + async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions): Promise { + const maxDisplay = 10; + const foundIssuesCount = this.foundIssuesCount(options.input); + const actualDisplay = Math.min(maxDisplay, foundIssuesCount); + if (actualDisplay === 0) { + return { + invocationMessage: vscode.l10n.t('No issues found') + }; + } else if (actualDisplay < foundIssuesCount) { + return { + invocationMessage: vscode.l10n.t('Found {0} issues. Generating a markdown table of the first {1}', foundIssuesCount, actualDisplay) + }; + } else { + return { + invocationMessage: vscode.l10n.t('Found {0} issues. Generating a markdown table', foundIssuesCount) + }; + } + } + + async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken): Promise { + await ensureEmojis(this.context); + const issueItemsInfo: vscode.LanguageModelTextPart | undefined = this.chatParticipantState.firstUserMessage; + const issueItems: IssueSearchResultItem[] | undefined = options.input.arrayOfIssues; + if (!issueItems || issueItems.length === 0) { + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(vscode.l10n.t('No issues found. Please try another query.'))]); + } + Logger.debug(`Displaying ${this.foundIssuesCount(options.input)} issues, first issue ${issueItems[0].number}`, DisplayIssuesTool.ID); + const importantColumns = await this.getImportantColumns(issueItemsInfo, issueItems, token); + + const titleRow = `| ${importantColumns.join(' | ')} |`; + Logger.debug(`Columns ${titleRow} issues`, DisplayIssuesTool.ID); + const separatorRow = `| ${importantColumns.map(() => '---').join(' | ')} |\n`; + const issues = new vscode.MarkdownString(titleRow); + issues.supportHtml = true; + issues.appendMarkdown('\n'); + issues.appendMarkdown(separatorRow); + issues.appendMarkdown(issueItems.slice(0, 10).map(issue => { + return this.issueToRow(issue, importantColumns); + }).join('\n')); + + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(TOOL_MARKDOWN_RESULT), + new vscode.LanguageModelTextPart(issues.value), + new vscode.LanguageModelTextPart(`The issues have been shown to the user. Simply say that you've already displayed the issue or first 10 issues.`)]); + } + +} \ No newline at end of file diff --git a/src/lm/tools/fetchIssueTool.ts b/src/lm/tools/fetchIssueTool.ts new file mode 100644 index 0000000000..e63b81b9b9 --- /dev/null +++ b/src/lm/tools/fetchIssueTool.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'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'; + +interface FetchIssueToolParameters { + issueNumber?: number; + repo?: { + owner?: string; + name?: string; + }; +} + +interface FileChange { + fileName?: string; + patch?: string; +} + +export interface FetchIssueResult { + title?: string; + body?: string; + comments?: { + author?: string; + body?: string; + }[]; + owner?: string; + repo?: string; + fileChanges?: FileChange[]; + author?: string; + assignees?: string[]; + reviewers?: string[]; +} + +export class FetchIssueTool extends RepoToolBase { + public static readonly toolId = 'github-pull-request_issue_fetch'; + + async invoke(options: vscode.LanguageModelToolInvocationOptions, _token: vscode.CancellationToken): Promise { + const issueNumber = options.input.issueNumber; + if (!issueNumber) { + throw new Error('No issue/pull-request number provided.'); + } + const { owner, name, folderManager } = await this.getRepoInfo({ owner: options.input.repo?.owner, name: options.input.repo?.name }); + const issueOrPullRequest = await folderManager.resolveIssueOrPullRequest(owner, name, issueNumber); + if (!issueOrPullRequest) { + throw new Error(`No issue or pull request found for ${owner}/${name}/${issueNumber}. Make sure the issue or pull request exists.`); + } + const result: FetchIssueResult = { + owner, + repo: name, + title: issueOrPullRequest.title, + body: issueOrPullRequest.body, + comments: issueOrPullRequest.item.comments?.map(c => ({ body: c.body, author: c.author.login })) ?? [], + author: issueOrPullRequest.author?.login, + assignees: issueOrPullRequest.assignees?.map(a => a.login), + reviewers: issueOrPullRequest instanceof PullRequestModel ? issueOrPullRequest.reviewers?.map(r => isITeam(r) ? r.name : r.login).filter((login): login is string => !!login) : undefined + }; + if (issueOrPullRequest instanceof PullRequestModel && issueOrPullRequest.isResolved()) { + const fileChanges = await issueOrPullRequest.getFileChangesInfo(); + const fetchedFileChanges: FileChange[] = []; + for (const fileChange of fileChanges) { + if (fileChange instanceof InMemFileChange) { + fetchedFileChanges.push({ + fileName: fileChange.fileName, + patch: fileChange.patch + }); + } + } + result.fileChanges = fetchedFileChanges; + } + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(JSON.stringify(result)), + new vscode.LanguageModelTextPart('Above is a stringified JSON representation of the issue or pull request. This can be passed to other tools for further processing.') + ]); + } + + async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions): Promise { + if (!options.input.issueNumber) { + return { + invocationMessage: vscode.l10n.t('Fetching item from GitHub') + }; + } + const { owner, name } = await this.getRepoInfo({ owner: options.input.repo?.owner, name: options.input.repo?.name }); + const url = (owner && name) ? `https://github.com/${owner}/${name}/issues/${options.input.issueNumber}` : undefined; + const message = url ? new vscode.MarkdownString(vscode.l10n.t('Fetching item [#{0}]({1}) from GitHub', options.input.issueNumber, url)) : vscode.l10n.t('Fetching item #{0} from GitHub', options.input.issueNumber); + return { + invocationMessage: message, + }; + } +} \ No newline at end of file diff --git a/src/lm/tools/fetchNotificationTool.ts b/src/lm/tools/fetchNotificationTool.ts new file mode 100644 index 0000000000..ef4eb2dd72 --- /dev/null +++ b/src/lm/tools/fetchNotificationTool.ts @@ -0,0 +1,120 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'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'; + +interface FetchNotificationToolParameters { + thread_id?: number; +} + +interface FileChange { + fileName?: string; + patch?: string; +} + +export interface FetchNotificationResult { + lastReadAt?: string; + lastUpdatedAt?: string; + unread?: boolean; + title?: string; + body?: string; + comments?: { + author?: string; + body?: string; + }[]; + owner?: string; + repo?: string; + itemNumber?: string; + itemType?: 'issue' | 'pr'; + fileChanges?: FileChange[]; + threadId?: number, + notificationKey?: string +} + +export class FetchNotificationTool extends RepoToolBase { + public static readonly toolId = 'github-pull-request_notification_fetch'; + + async prepareInvocation(_options: vscode.LanguageModelToolInvocationPrepareOptions): Promise { + return { + invocationMessage: vscode.l10n.t('Fetching notification from GitHub') + }; + } + + async invoke(options: vscode.LanguageModelToolInvocationOptions, _token: vscode.CancellationToken): Promise { + const github = this.getGitHub(); + if (!github) { + return undefined; + } + const threadId = options.input.thread_id; + if (threadId === undefined) { + return undefined; + } + const thread = await github.octokit.api.activity.getThread({ + thread_id: threadId + }); + const threadData = thread.data; + const itemNumber = threadData.subject.url.split('/').pop(); + if (itemNumber === undefined) { + return undefined; + } + const lastUpdatedAt = threadData.updated_at; + const lastReadAt = threadData.last_read_at ?? undefined; + const unread = threadData.unread; + const owner = threadData.repository.owner.login; + const name = threadData.repository.name; + const { folderManager } = await this.getRepoInfo({ owner, name }); + const issueOrPR = await folderManager.resolveIssueOrPullRequest(owner, name, Number(itemNumber)); + if (!issueOrPR) { + throw new Error(`No notification found with thread ID #${threadId}.`); + } + const itemType = issueOrPR instanceof PullRequestModel ? 'pr' : 'issue'; + const notificationKey = getNotificationKey(owner, name, String(issueOrPR.number)); + const itemComments = issueOrPR.item.comments ?? []; + let comments: { body: string; author: string }[]; + if (lastReadAt !== undefined && itemComments) { + comments = itemComments.filter(comment => { + return comment.createdAt > lastReadAt; + }).map(comment => { return { body: comment.body, author: comment.author.login }; }); + } else { + comments = itemComments.map(comment => { return { body: comment.body, author: comment.author.login }; }); + } + const result: FetchNotificationResult = { + lastReadAt, + lastUpdatedAt, + unread, + comments, + threadId, + notificationKey, + title: issueOrPR.title, + body: issueOrPR.body, + owner, + repo: name, + itemNumber, + itemType + }; + if (issueOrPR instanceof PullRequestModel) { + const fileChanges = await issueOrPR.getFileChangesInfo(); + const fetchedFileChanges: FileChange[] = []; + for (const fileChange of fileChanges) { + if (fileChange instanceof InMemFileChange) { + fetchedFileChanges.push({ + fileName: fileChange.fileName, + patch: fileChange.patch + }); + } + } + result.fileChanges = fetchedFileChanges; + } + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(JSON.stringify(result)), + new vscode.LanguageModelTextPart('Above is a stringified JSON representation of the notification. This can be passed to other tools for further processing or display.') + ]); + } + +} \ No newline at end of file diff --git a/src/lm/tools/openPullRequestTool.ts b/src/lm/tools/openPullRequestTool.ts new file mode 100644 index 0000000000..74b5d2f0bc --- /dev/null +++ b/src/lm/tools/openPullRequestTool.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { PullRequestTool } from './activePullRequestTool'; +import { fromPRUri, fromReviewUri, Schemes } from '../../common/uri'; +import { PullRequestModel } from '../../github/pullRequestModel'; +import { PullRequestOverviewPanel } from '../../github/pullRequestOverview'; + +export class OpenPullRequestTool extends PullRequestTool { + public static readonly toolId = 'github-pull-request_openPullRequest'; + + protected _findActivePullRequest(): PullRequestModel | undefined { + // First check if there's a PR overview panel open + const panelPR = PullRequestOverviewPanel.currentPanel?.getCurrentItem(); + if (panelPR) { + return panelPR; + } + + // Check if the active tab is a diff editor showing PR content + const activeTab = vscode.window.tabGroups.activeTabGroup.activeTab; + if (activeTab?.input instanceof vscode.TabInputTextDiff) { + const diffInput = activeTab.input; + const urisToCheck = [diffInput.original, diffInput.modified]; + + for (const uri of urisToCheck) { + if (uri.scheme === Schemes.Pr) { + // This is a PR diff from GitHub + const prParams = fromPRUri(uri); + if (prParams) { + return this._findPullRequestByNumber(prParams.prNumber, prParams.remoteName); + } + } else if (uri.scheme === Schemes.Review) { + // This is a review diff from a checked out PR + const reviewParams = fromReviewUri(uri.query); + if (reviewParams) { + // For review scheme, find the folder manager based on the root path + const rootUri = vscode.Uri.file(reviewParams.rootPath); + const folderManager = this.folderManagers.getManagerForFile(rootUri); + return folderManager?.activePullRequest; + } + } + } + } else if (activeTab?.input instanceof vscode.TabInputText) { + // Check if a single file with PR scheme is open (e.g., newly added files) + const textInput = activeTab.input; + if (textInput.uri.scheme === Schemes.Pr) { + const prParams = fromPRUri(textInput.uri); + if (prParams) { + return this._findPullRequestByNumber(prParams.prNumber, prParams.remoteName); + } + } else if (textInput.uri.scheme === Schemes.Review) { + const reviewParams = fromReviewUri(textInput.uri.query); + if (reviewParams) { + const rootUri = vscode.Uri.file(reviewParams.rootPath); + const folderManager = this.folderManagers.getManagerForFile(rootUri); + return folderManager?.activePullRequest; + } + } + } + + return undefined; + } + + private _findPullRequestByNumber(prNumber: number, remoteName: string): PullRequestModel | undefined { + for (const manager of this.folderManagers.folderManagers) { + for (const repo of manager.gitHubRepositories) { + if (repo.remote.remoteName === remoteName) { + // Look for the PR in the repository's PR cache + for (const pr of repo.pullRequestModels) { + if (pr.number === prNumber) { + return pr; + } + } + } + } + } + return undefined; + } + + protected _confirmationTitle(): string { + return vscode.l10n.t('Open Pull Request'); + } +} diff --git a/src/lm/tools/searchTools.ts b/src/lm/tools/searchTools.ts new file mode 100644 index 0000000000..4d5c99273a --- /dev/null +++ b/src/lm/tools/searchTools.ts @@ -0,0 +1,487 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'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'; + +interface ConvertToQuerySyntaxParameters { + naturalLanguageString?: string; + repo?: { + owner?: string; + name?: string; + }; +} + +interface ConvertToQuerySyntaxResult { + query: string; + repo?: { + owner?: string; + name?: string; + }; +} + +enum ValidatableProperty { + is = 'is', + type = 'type', + state = 'state', + in = 'in', + linked = 'linked', + status = 'status', + draft = 'draft', + review = 'review', + no = 'no', +} + +const githubSearchSyntax = { + is: { possibleValues: ['issue', 'pr', 'draft', 'public', 'private', 'locked', 'unlocked'] }, + assignee: { valueDescription: 'A GitHub user name or @me' }, + author: { valueDescription: 'A GitHub user name or @me' }, + mentions: { valueDescription: 'A GitHub user name or @me' }, + team: { valueDescription: 'A GitHub user name' }, + commenter: { valueDescription: 'A GitHub user name or @me' }, + involves: { valueDescription: 'A GitHub user name or @me' }, + label: { valueDescription: 'A GitHub issue/pr label' }, + type: { possibleValues: ['pr', 'issue'] }, + state: { possibleValues: ['open', 'closed', 'merged'] }, + in: { possibleValues: ['title', 'body', 'comments'] }, + user: { valueDescription: 'A GitHub user name or @me' }, + org: { valueDescription: 'A GitHub org, without the repo name' }, + repo: { valueDescription: 'A GitHub repo, without the org name' }, + linked: { possibleValues: ['pr', 'issue'] }, + milestone: { valueDescription: 'A GitHub milestone' }, + project: { valueDescription: 'A GitHub project' }, + status: { possibleValues: ['success', 'failure', 'pending'] }, + head: { valueDescription: 'A git commit sha or branch name' }, + base: { valueDescription: 'A git commit sha or branch name' }, + comments: { valueDescription: 'A number' }, + interactions: { valueDescription: 'A number' }, + reactions: { valueDescription: 'A number' }, + draft: { possibleValues: ['true', 'false'] }, + review: { possibleValues: ['none', 'required', 'approved', 'changes_requested'] }, + reviewedBy: { valueDescription: 'A GitHub user name or @me' }, + reviewRequested: { valueDescription: 'A GitHub user name or @me' }, + userReviewRequested: { valueDescription: 'A GitHub user name or @me' }, + teamReviewRequested: { valueDescription: 'A GitHub user name' }, + created: { valueDescription: 'A date, with an optional < >' }, + updated: { valueDescription: 'A date, with an optional < >' }, + closed: { valueDescription: 'A date, with an optional < >' }, + no: { possibleValues: ['label', 'milestone', 'assignee', 'project'] }, + sort: { possibleValues: ['updated', 'updated-asc', 'interactions', 'interactions-asc', 'author-date', 'author-date-asc', 'committer-date', 'committer-date-asc', 'reactions', 'reactions-asc', 'reactions-(+1, -1, smile, tada, heart)'] } +}; + +const MATCH_UNQUOTED_SPACES = /(?!\B"[^"]*)\s+(?![^"]*"\B)/; + +export class ConvertToSearchSyntaxTool extends RepoToolBase { + public static readonly toolId = 'github-pull-request_formSearchQuery'; + static ID = 'ConvertToSearchSyntaxTool'; + + private async fullQueryAssistantPrompt(folderRepoManager: FolderRepositoryManager): Promise { + const remote = folderRepoManager.activePullRequest?.remote ?? folderRepoManager.activeIssue?.remote ?? (await folderRepoManager.getPullRequestDefaultRepo()).remote; + + return `Instructions: +You are an expert on GitHub issue search syntax. GitHub issues are always software engineering related. You can help the user convert a natural language query to a query that can be used to search GitHub issues. Here are some rules to follow: +- Always try to include "repo:" or "org:" in your response. +- "repo" is often formated as "owner/name". If needed, the current repo is ${remote.owner}/${remote.repositoryName}. +- Ignore display information. +- Respond with only the query. +- Always include a "sort:" parameter. If multiple sorts are possible, choose the one that the user requested. +- Always include a property with the @me value if the query includes "me" or "my". +- Here are some examples of valid queries: + - repo:microsoft/vscode is:issue state:open sort:updated-asc + - mentions:@me org:microsoft is:issue state:open sort:updated + - assignee:@me milestone:"October 2024" is:open is:issue sort:reactions + - comments:>5 org:contoso is:issue state:closed mentions:@me label:bug + - interactions:>5 repo:contoso/cli is:issue state:open + - repo:microsoft/vscode-python is:issue sort:updated -assignee:@me + - repo:contoso/cli is:issue sort:updated no:milestone +- Go through each word of the natural language query and try to match it to a syntax component. +- Use a "-" in front of a syntax component to indicate that it should be "not-ed". +- Use the "no" syntax component to indicate that a property should be empty. +- As a reminder, here are the components of the query syntax: + ${JSON.stringify(githubSearchSyntax)} +`; + } + + private async labelsAssistantPrompt(folderRepoManager: FolderRepositoryManager, labels: ILabel[]): Promise { + // It seems that AND and OR aren't supported in GraphQL, so we can't use them in the query + // Here's the prompt in case we switch to REST: + // - Use as many labels as you think fit the query. If one label fits, then there are probably more that fit. + // - Respond with a list of labels in github search syntax, separated by AND or OR. Examples: "label:bug OR label:polish", "label:accessibility AND label:editor-accessibility" + return `Instructions: +You are an expert on choosing search keywords based on a natural language search query. Here are some rules to follow: +- Choose labels based on what the user wants to search for, not based on the actual words in the query. +- The user might include info on how they want their search results to be displayed. Ignore all of that. +- Labels will be and-ed together, so don't pick a bunch of super specific labels. +- Try to pick just one label. +- Respond with a space-separated list of labels: Examples: 'bug polish', 'accessibility "feature accessibility"' +- Only choose labels that you're sure are relevant. Having no labels is preferable than lables that aren't relevant. +- Don't choose labels that the user has explicitly excluded. +- Respond with label names chosen from this JSON array of options: +${JSON.stringify(labels.filter(label => !label.name.includes('required') && !label.name.includes('search') && !label.name.includes('question') && !label.name.includes('find') && !label.name.includes('issue')).map(label => ({ name: label.name, description: label.description })))} +`; + } + + private freeFormAssistantPrompt(): string { + return `Instructions: +You are getting ready to make a GitHub search query. Given a natural language query, you should find any key words that might be good for searching: +- Only include a max of 1 key word that is relevant to the search query. +- Don't refer to issue numbers. +- Don't refer to product names. +- Don't include any key words that might be related to display or rendering. +- Respond with only your chosen key word. +- It's better to return no keywords than to return irrelevant keywords. +- If an issue is provided, choose a keyword that names the feature or bug that the issue is about. +- Don't include key words or concepts that are already covered by labels. +`; + } + + private freeFormUserPrompt(labels: string[], originalUserPrompt: string): string { + return `I've already included the following labels: [${labels.join(', ')}]. The best search keywords in "${originalUserPrompt}" are:`; + } + + private labelsUserPrompt(originalUserPrompt: string): string { + return `The following labels are most appropriate for "${originalUserPrompt}":`; + } + + private fullQueryUserPrompt(originalUserPrompt: string): string { + originalUserPrompt = originalUserPrompt.replace(/\b(me|my)\b/, (value) => value.toUpperCase()); + const date = new Date(); + return `Pretend today's date is ${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}, but only include it if needed. How should this be converted to a GitHub issue search query? ${originalUserPrompt}`; + } + + private validateSpecificQueryPart(property: ValidatableProperty | string, value: string): boolean { + switch (property) { + case ValidatableProperty.is: + return value === 'issue' || value === 'pr' || value === 'draft' || value === 'public' || value === 'private' || value === 'locked' || value === 'unlocked'; + case ValidatableProperty.type: + return value === 'pr' || value === 'issue'; + case ValidatableProperty.state: + return value === 'open' || value === 'closed' || value === 'merged'; + case ValidatableProperty.in: + return value === 'title' || value === 'body' || value === 'comments'; + case ValidatableProperty.linked: + return value === 'pr' || value === 'issue'; + case ValidatableProperty.status: + return value === 'success' || value === 'failure' || value === 'pending'; + case ValidatableProperty.draft: + return value === 'true' || value === 'false'; + case ValidatableProperty.review: + return value === 'none' || value === 'required' || value === 'approved' || value === 'changes_requested'; + case ValidatableProperty.no: + return value === 'label' || value === 'milestone' || value === 'assignee' || value === 'project'; + default: + return true; + } + } + + private validateLabelsList(labelsList: string, allLabels: ILabel[]): string[] { + // I wrote everything for AND and OR, but it isn't supported with GraphQL. + // Leaving it in for now in case we switch to REST. + const isAndOrOr = (labelOrOperator: string) => { + return labelOrOperator === 'AND' || labelOrOperator === 'OR'; + }; + + const labelsAndOperators = labelsList.split(MATCH_UNQUOTED_SPACES).map(label => label.trim()); + let goodLabels: string[] = []; + for (let labelOrOperator of labelsAndOperators) { + if (isAndOrOr(labelOrOperator)) { + if (goodLabels.length === 0) { + continue; + } else if (goodLabels.length > 0 && isAndOrOr(goodLabels[goodLabels.length - 1])) { + goodLabels[goodLabels.length - 1] = labelOrOperator; + } else { + goodLabels.push(labelOrOperator); + } + continue; + } + // Make sure it does start with `label:` + const labelPrefixRegex = /^label:(?!\B"[^"]*)\s+(?![^"]*"\B)/; + const labelPrefixMatch = labelOrOperator.match(labelPrefixRegex); + let label = labelOrOperator; + if (labelPrefixMatch) { + label = labelPrefixMatch[1]; + } + if (allLabels.find(l => l.name === label)) { + goodLabels.push(label); + } + } + if (goodLabels.length > 0 && isAndOrOr(goodLabels[goodLabels.length - 1])) { + goodLabels = goodLabels.slice(0, goodLabels.length - 1); + } + return goodLabels; + } + + private validateFreeForm(baseQuery: string, labels: string[], freeForm: string) { + // Currently, we only allow the free form to return one keyword + freeForm = freeForm.trim(); + // useless strings to search for + if (freeForm.includes('issue') || freeForm.match(MATCH_UNQUOTED_SPACES) || freeForm.toLowerCase() === 'none') { + return ''; + } + if (baseQuery.includes(freeForm)) { + return ''; + } + if (labels.includes(freeForm)) { + return ''; + } + if (labels.some(label => freeForm.includes(label) || label.includes(freeForm))) { + return ''; + } + if (Object.keys(githubSearchSyntax).find(searchPart => freeForm.includes(searchPart) || searchPart.includes(freeForm))) { + return ''; + } + return freeForm; + } + + private validateQuery(query: string, labels: string[], freeForm: string) { + let reformedQuery = ''; + const queryParts = query.split(MATCH_UNQUOTED_SPACES); + // Only keep property:value pairs and '-', no reform allowed here. + for (const part of queryParts) { + if (part.startsWith('label:')) { + continue; + } + const propAndVal = part.split(':'); + if (propAndVal.length === 2) { + const hasMinus = propAndVal[0].startsWith('-'); + const label = hasMinus ? propAndVal[0].substring(1) : propAndVal[0]; + const value = propAndVal[1]; + if (!label.match(/^[a-zA-Z]+$/)) { + continue; + } + if (!this.validateSpecificQueryPart(label, value)) { + continue; + } + if (label === 'no' && value === 'label' && labels.length > 0) { + // special case for no:label as we shouldn't have both no:label and label:label + continue; + } + } + reformedQuery = `${reformedQuery} ${part}`; + } + + const validFreeForm = this.validateFreeForm(reformedQuery, labels, freeForm); + + reformedQuery = `${reformedQuery} ${labels.map(label => `label:${label}`).join(' ')} ${validFreeForm}`; + return reformedQuery.trim(); + } + + private postProcess(queryPart: string, freeForm: string, labels: string[]): ConvertToQuerySyntaxResult | undefined { + const query = this.findQuery(queryPart); + if (!query) { + return; + } + const fixedLabels = this.validateQuery(query, labels, freeForm); + const fixedRepo = this.fixRepo(fixedLabels); + return fixedRepo; + } + + private fixRepo(query: string): ConvertToQuerySyntaxResult { + const repoRegex = /repo:([^ ]+)/; + const orgRegex = /org:([^ ]+)/; + const repoMatch = query.match(repoRegex); + const orgMatch = query.match(orgRegex); + let newQuery = query.trim(); + let owner: string | undefined; + let name: string | undefined; + if (repoMatch) { + const originalRepo = repoMatch[1]; + if (originalRepo.includes('/')) { + const ownerAndRepo = originalRepo.split('/'); + owner = ownerAndRepo[0]; + name = ownerAndRepo[1]; + } + + if (orgMatch && originalRepo.includes('/')) { + // remove the org match + newQuery = query.replace(orgRegex, ''); + } else if (orgMatch) { + // We need to add the org into the repo + newQuery = query.replace(repoRegex, `repo:${orgMatch[1]}/${originalRepo}`); + owner = orgMatch[1]; + name = originalRepo; + } + } + return { + query: newQuery, + repo: owner && name ? { owner, name } : undefined + }; + } + + private findQuery(result: string): string | undefined { + // if there's a code block, then that's all we take + if (result.includes('```')) { + const start = result.indexOf('```'); + const end = result.indexOf('```', start + 3); + return result.substring(start + 3, end); + } + // if it's only one line, we take that + const lines = result.split('\n'); + if (lines.length <= 1) { + return lines.length === 0 ? result : lines[0]; + } + // if there are multiple lines, we take the first line that has a colon + for (const line of lines) { + if (line.includes(':')) { + return line; + } + } + } + + private async generateLabelQuery(folderManager: FolderRepositoryManager, labels: ILabel[], chatOptions: vscode.LanguageModelChatRequestOptions, model: vscode.LanguageModelChat, naturalLanguageString: string, token: vscode.CancellationToken): Promise { + const messages = [vscode.LanguageModelChatMessage.Assistant(await this.labelsAssistantPrompt(folderManager, labels))]; + messages.push(vscode.LanguageModelChatMessage.User(this.labelsUserPrompt(naturalLanguageString))); + const response = await model.sendRequest(messages, chatOptions, token); + return concatAsyncIterable(response.text); + } + + private async generateFreeFormQuery(folderManager: FolderRepositoryManager, chatOptions: vscode.LanguageModelChatRequestOptions, model: vscode.LanguageModelChat, naturalLanguageString: string, labels: string[], token: vscode.CancellationToken): Promise { + const messages = [vscode.LanguageModelChatMessage.Assistant(this.freeFormAssistantPrompt())]; + messages.push(vscode.LanguageModelChatMessage.User(this.freeFormUserPrompt(labels, naturalLanguageString))); + const response = await model.sendRequest(messages, chatOptions, token); + return concatAsyncIterable(response.text); + } + + private async generateQuery(folderManager: FolderRepositoryManager, chatOptions: vscode.LanguageModelChatRequestOptions, model: vscode.LanguageModelChat, naturalLanguageString: string, token: vscode.CancellationToken): Promise { + const messages = [vscode.LanguageModelChatMessage.Assistant(await this.fullQueryAssistantPrompt(folderManager))]; + messages.push(vscode.LanguageModelChatMessage.User(this.fullQueryUserPrompt(naturalLanguageString))); + const response = await model.sendRequest(messages, chatOptions, token); + return concatAsyncIterable(response.text); + } + + async prepareInvocation(_options: vscode.LanguageModelToolInvocationPrepareOptions): Promise { + return { + invocationMessage: vscode.l10n.t('Converting to search syntax') + }; + } + + async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken): Promise { + const { owner, name, folderManager } = await this.getRepoInfo({ owner: options.input.repo?.owner, name: options.input.repo?.name }); + const firstUserMessage = `${this.chatParticipantState.firstUserMessage?.value}, ${options.input.naturalLanguageString}`; + + const allLabels = await folderManager.getLabels(undefined, { owner, repo: name }); + + const models = await vscode.lm.selectChatModels({ + vendor: 'copilot', + family: 'gpt-4o' + }); + const model = models[0]; + const chatOptions: vscode.LanguageModelChatRequestOptions = { + justification: 'Answering user questions pertaining to GitHub.' + }; + const [query, labelsList] = await Promise.all([this.generateQuery(folderManager, chatOptions, model, firstUserMessage, token), this.generateLabelQuery(folderManager, allLabels, chatOptions, model, firstUserMessage, token)]); + const validatedLabels = this.validateLabelsList(labelsList, allLabels); + const freeForm = await this.generateFreeFormQuery(folderManager, chatOptions, model, firstUserMessage, validatedLabels, token); + const result = this.postProcess(query, freeForm, validatedLabels); + if (!result) { + throw new Error('Unable to form a query.'); + } + Logger.debug(`Query \`${result.query}\``, ConvertToSearchSyntaxTool.ID); + const json: ConvertToQuerySyntaxResult = { + query: result.query, + repo: { + owner, + name + } + }; + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(JSON.stringify(json)), + new vscode.LanguageModelTextPart('Above is the query in stringified json format. You can pass this VERBATIM to a tool that knows how to search.')]); + } +} + +type SearchToolParameters = ConvertToQuerySyntaxResult; + +export interface IssueSearchResultAccount { + login?: string; + url?: string; +} + +interface IssueSearchResultLabel { + name?: string; + color?: string; +} + +export interface IssueSearchResultItem { + title?: string; + url?: string; + number?: number; + labels?: IssueSearchResultLabel[]; + state?: string; + assignees?: IssueSearchResultAccount[] | undefined; + createdAt?: string; + updatedAt?: string; + author?: IssueSearchResultAccount; + milestone?: string | undefined; + commentCount?: number; + reactionCount?: number; +} + +export interface SearchToolResult { + arrayOfIssues?: IssueSearchResultItem[]; + totalIssues?: number; +} + +export class SearchTool extends RepoToolBase { + public static readonly toolId = 'github-pull-request_doSearch'; + static ID = 'SearchTool'; + + + private toGitHubUrl(query: string) { + return `https://github.com/issues/?q=${encodeURIComponent(query)}`; + } + + async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions): Promise { + const parameterQuery = options.input.query; + const message = new vscode.MarkdownString(); + message.appendText(vscode.l10n.t('Searching for issues with "{0}".', parameterQuery)); + message.appendMarkdown(vscode.l10n.t(' [Open on GitHub.com]({0})', escapeMarkdown(this.toGitHubUrl(parameterQuery)))); + + return { + invocationMessage: message + }; + } + + async invoke(options: vscode.LanguageModelToolInvocationOptions, _token: vscode.CancellationToken): Promise { + const { folderManager } = await this.getRepoInfo({ owner: options.input.repo?.owner, name: options.input.repo?.name }); + + const parameterQuery = options.input.query; + Logger.debug(`Searching with query \`${parameterQuery}\``, SearchTool.ID); + + const searchResult = await folderManager.getIssues(parameterQuery); + if (!searchResult) { + throw new Error(`No issues found for ${parameterQuery}. Make sure the query is valid.`); + } + const cutoff = 30; + const result: SearchToolResult = { + arrayOfIssues: searchResult.items.slice(0, cutoff).map(i => { + const item = i.item; + return { + title: item.title, + url: item.url, + number: item.number, + labels: item.labels.map(l => ({ name: l.name, color: l.color })), + state: item.state, + assignees: item.assignees?.map(a => ({ login: a.login, url: a.url })), + createdAt: item.createdAt, + updatedAt: item.updatedAt, + author: { login: item.user.login, url: item.user.url }, + milestone: item.milestone?.title, + commentCount: item.commentCount, + reactionCount: item.reactionCount + }; + }), + totalIssues: searchResult.totalCount ?? searchResult.items.length + }; + Logger.debug(`Found ${result.totalIssues} issues, first issue ${result.arrayOfIssues![0]?.number}.`, SearchTool.ID); + + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(JSON.stringify(result)), + new vscode.LanguageModelTextPart(`Above are the issues I found for the query ${parameterQuery} in json format. You can pass these to a tool that can display them, or you can reason over the issues to answer a question.`)]); + } +} \ No newline at end of file diff --git a/src/lm/tools/suggestFixTool.ts b/src/lm/tools/suggestFixTool.ts new file mode 100644 index 0000000000..d3dfdac364 --- /dev/null +++ b/src/lm/tools/suggestFixTool.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import * as vscode from 'vscode'; +import { CredentialStore } from '../../github/credentials'; +import { RepositoriesManager } from '../../github/repositoriesManager'; +import { ChatParticipantState } from '../participants'; +import { IssueResult, IssueToolParameters, RepoToolBase } from './toolsUtils'; + +export class SuggestFixTool extends RepoToolBase { + public static readonly toolId = 'github-pull-request_suggest-fix'; + + constructor(credentialStore: CredentialStore, repositoriesManager: RepositoriesManager, chatParticipantState: ChatParticipantState) { + super(credentialStore, repositoriesManager, chatParticipantState); + } + + async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions): Promise { + return { + invocationMessage: options.input.issueNumber ? vscode.l10n.t('Suggesting a fix for issue #{0}', options.input.issueNumber) : vscode.l10n.t('Suggesting a fix for the issue') + }; + } + + async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken): Promise { + const repo = options.input.repo; + const owner = repo?.owner; + const name = repo?.name; + const issueNumber = options.input.issueNumber; + if (!repo || !owner || !name || !issueNumber) { + return undefined; + } + const { folderManager } = await this.getRepoInfo(repo); + if (!folderManager) { + throw new Error(`No folder manager found for ${repo.owner}/${repo.name}. Make sure to have the repository open.`); + } + const issue = await folderManager.resolveIssue(owner, name, issueNumber, true); + if (!issue) { + throw new Error(`No issue found for ${repo.owner}/${repo.name}/${options.input.issueNumber}. Make sure the issue exists.`); + } + + const result: IssueResult = { + title: issue.title, + body: issue.body, + comments: issue.item.comments?.map(c => ({ body: c.body })) ?? [] + }; + + const messages: vscode.LanguageModelChatMessage[] = []; + messages.push(vscode.LanguageModelChatMessage.Assistant(`You are a world-class developer who is capable of solving very difficult bugs and issues.`)); + messages.push(vscode.LanguageModelChatMessage.Assistant(`The user will give you an issue title, body and a list of comments from GitHub. The user wants you to suggest a fix.`)); + messages.push(vscode.LanguageModelChatMessage.Assistant(`Analyze the issue content, the workspace context below and using all this information suggest a fix.`)); + messages.push(vscode.LanguageModelChatMessage.Assistant(`Where possible output code-blocks and reference real files in the workspace with the fix.`)); + messages.push(vscode.LanguageModelChatMessage.User(`The issue content is as follows: `)); + messages.push(vscode.LanguageModelChatMessage.User(`Issue Title: ${result.title}`)); + messages.push(vscode.LanguageModelChatMessage.User(`Issue Body: ${result.body}`)); + result.comments.forEach((comment, index) => { + messages.push(vscode.LanguageModelChatMessage.User(`Comment ${index}: ${comment.body}`)); + }); + + const codeSearchTool = vscode.lm.tools.find(value => value.tags.includes('vscode_codesearch')); + if (!codeSearchTool) { + throw new Error('Could not find the code search tool'); + } + + const copilotCodebaseResult = await vscode.lm.invokeTool(codeSearchTool.name, { + toolInvocationToken: undefined, + input: { + query: result.title + } + }, token); + + const plainTextResult = copilotCodebaseResult.content[0]; + if (plainTextResult instanceof vscode.LanguageModelTextPart) { + messages.push(vscode.LanguageModelChatMessage.User(`Below is some potential relevant workspace context to the issue. The user cannot see this result, so you should explain it to the user if referencing it in your answer.`)); + const toolMessage = vscode.LanguageModelChatMessage.User(''); + toolMessage.content = [plainTextResult]; + messages.push(toolMessage); + } + + const models = await vscode.lm.selectChatModels({ + vendor: 'copilot', + family: 'gpt-4o' + }); + const model = models[0]; + const response = await model.sendRequest(messages, {}, token); + + let responseResult = ''; + for await (const chunk of response.text) { + responseResult += chunk; + } + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(responseResult)]); + } + + +} \ No newline at end of file diff --git a/src/lm/tools/summarizeIssueTool.ts b/src/lm/tools/summarizeIssueTool.ts new file mode 100644 index 0000000000..182dba4a5f --- /dev/null +++ b/src/lm/tools/summarizeIssueTool.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import * as vscode from 'vscode'; +import { FetchIssueResult } from './fetchIssueTool'; +import { concatAsyncIterable } from './toolsUtils'; + +export class IssueSummarizationTool implements vscode.LanguageModelTool { + public static readonly toolId = 'github-pull-request_issue_summarize'; + + async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions): Promise { + if (!options.input.title) { + return { + invocationMessage: vscode.l10n.t('Summarizing issue') + }; + } + const shortenedTitle = options.input.title.length > 40; + const maxLengthTitle = shortenedTitle ? options.input.title.substring(0, 40) : options.input.title; + return { + invocationMessage: vscode.l10n.t('Summarizing "{0}', maxLengthTitle) + }; + } + + async invoke(options: vscode.LanguageModelToolInvocationOptions, _token: vscode.CancellationToken): Promise { + let issueOrPullRequestInfo: string = ` +Title : ${options.input.title} +Body : ${options.input.body} +`; + const fileChanges = options.input.fileChanges; + if (fileChanges) { + issueOrPullRequestInfo += ` +The following are the files changed: +`; + for (const fileChange of fileChanges.values()) { + issueOrPullRequestInfo += ` +File : ${fileChange.fileName} +Patch: ${fileChange.patch} +`; + } + } + const comments = options.input.comments; + if (comments) { + for (const [index, comment] of comments.entries()) { + issueOrPullRequestInfo += ` +Comment ${index} : +Author: ${comment.author} +Body: ${comment.body} +`; + } + } + const models = await vscode.lm.selectChatModels({ + vendor: 'copilot', + family: 'gpt-4o' + }); + const model = models[0]; + const repo = options.input.repo; + const owner = options.input.owner; + + if (model && repo && owner) { + const messages = [vscode.LanguageModelChatMessage.User(this.summarizeInstructions(repo, owner))]; + messages.push(vscode.LanguageModelChatMessage.User(`The issue or pull request information is as follows:`)); + messages.push(vscode.LanguageModelChatMessage.User(issueOrPullRequestInfo)); + const response = await model.sendRequest(messages, {}); + const responseText = await concatAsyncIterable(response.text); + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(responseText)]); + } else { + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(issueOrPullRequestInfo)]); + } + } + + private summarizeInstructions(repo: string, owner: string): string { + return ` +You are an AI assistant who is very proficient in summarizing issues and pull requests (PRs). +You will be given information relative to an issue or PR : the title, the body and the comments. In the case of a PR you will also be given patches of the PR changes. +Your task is to output a summary of all this information. +Do not output code. When you try to summarize PR changes, summarize in a textual format. +Output references to other issues and PRs as Markdown links. The current issue has owner ${owner} and is in the repo ${repo}. +If a comment references for example issue or PR #123, then output either of the following in the summary depending on if it is an issue or a PR: + +[#123](https://github.com/${owner}/${repo}/issues/123) +[#123](https://github.com/${owner}/${repo}/pull/123) + +When you summarize comments, always give a summary of each comment and always mention the author clearly before the comment. If the author is called 'joe' and the comment is 'this is a comment', then the output should be: + +joe: this is a comment + +If the content contains images in Markdown format (e.g., ![alt text](image-url)), always preserve them in the output exactly as they appear. Images are important visual content and should not be removed or summarized. + +Make sure the summary is at least as short or shorter than the issue or PR with the comments and the patches if there are. +`; + } + +} \ No newline at end of file diff --git a/src/lm/tools/summarizeNotificationsTool.ts b/src/lm/tools/summarizeNotificationsTool.ts new file mode 100644 index 0000000000..736b3ebeff --- /dev/null +++ b/src/lm/tools/summarizeNotificationsTool.ts @@ -0,0 +1,146 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import * as vscode from 'vscode'; +import { FetchNotificationResult } from './fetchNotificationTool'; +import { concatAsyncIterable, TOOL_COMMAND_RESULT } from './toolsUtils'; + +export class NotificationSummarizationTool implements vscode.LanguageModelTool { + public static readonly toolId = 'github-pull-request_notification_summarize'; + + async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions): Promise { + const parameters = options.input; + if (!parameters.itemType || !parameters.itemNumber) { + return { + invocationMessage: vscode.l10n.t('Summarizing notification') + }; + } + const type = parameters.itemType === 'issue' ? 'issues' : 'pull'; + const url = `https://github.com/${parameters.owner}/${parameters.repo}/${type}/${parameters.itemNumber}`; + return { + invocationMessage: new vscode.MarkdownString(vscode.l10n.t('Summarizing item [#{0}]({1})', parameters.itemNumber, url)) + }; + } + + async invoke(options: vscode.LanguageModelToolInvocationOptions, _token: vscode.CancellationToken): Promise { + let notificationInfo: string = ''; + const lastReadAt = options.input.lastReadAt; + if (!lastReadAt) { + // First time the thread is viewed, so no lastReadAt field + notificationInfo += `This thread is viewed for the first time. Here is the main item information of the thread:`; + } + notificationInfo += ` +Title : ${options.input.title} +Body : ${options.input.body} +`; + const fileChanges = options.input.fileChanges; + if (fileChanges) { + notificationInfo += ` +The following are the files changed: +`; + for (const fileChange of fileChanges.values()) { + notificationInfo += ` +File : ${fileChange.fileName} +Patch: ${fileChange.patch} +`; + } + } + + const unreadComments = options.input.comments; + if (unreadComments && unreadComments.length > 0) { + notificationInfo += ` +The following are the unread comments of the thread: +`; + for (const [index, comment] of unreadComments.entries()) { + notificationInfo += ` +Comment ${index} : +Author: ${comment.author} +Body: ${comment.body} +`; + } + } + const models = await vscode.lm.selectChatModels({ + vendor: 'copilot', + family: 'gpt-4o' + }); + const model = models[0]; + const content: vscode.LanguageModelTextPart[] = []; + const threadId = options.input.threadId; + const notificationKey = options.input.notificationKey; + if (threadId && notificationKey) { + const markAsReadCommand = { + title: 'Mark As Read', + command: 'notification.markAsRead', + arguments: [{ threadId, notificationKey }] + }; + const markAsDoneCommand: vscode.Command = { + title: 'Mark As Done', + command: 'notification.markAsDone', + arguments: [{ threadId, notificationKey }] + }; + content.push(new vscode.LanguageModelTextPart(TOOL_COMMAND_RESULT)); + content.push(new vscode.LanguageModelTextPart(JSON.stringify(markAsReadCommand))); + content.push(new vscode.LanguageModelTextPart(TOOL_COMMAND_RESULT)); + content.push(new vscode.LanguageModelTextPart(JSON.stringify(markAsDoneCommand))); + } + const owner = options.input.owner; + const repo = options.input.repo; + if (model && owner && repo) { + const messages = [vscode.LanguageModelChatMessage.User(this.summarizeInstructions(owner, repo))]; + messages.push(vscode.LanguageModelChatMessage.User(`The notification information is as follows:`)); + messages.push(vscode.LanguageModelChatMessage.User(notificationInfo)); + const response = await model.sendRequest(messages, {}); + const responseText = await concatAsyncIterable(response.text); + content.push(new vscode.LanguageModelTextPart(responseText)); + } else { + content.push(new vscode.LanguageModelTextPart(notificationInfo)); + } + content.push(new vscode.LanguageModelTextPart('Above is a summary of the notification. Extract and output this notification summary directly as is to the user. Do not output the result from the call to the fetch notification tool.')); + return new vscode.LanguageModelToolResult(content); + } + + private summarizeInstructions(owner: string, repo: string): string { + return ` +You are an AI assistant who is very proficient in summarizing notification threads. +You will be given information relative to a notification thread : the title, the body and the comments. In the case of a PR you will also be given patches of the PR changes. +Since you are reviewing a notification thread, part of the content is by definition unread. You will be told what part of the content is yet unread. This can be the comments or it can be both the thread issue/PR as well as the comments. +Your task is to output a summary of all this notification thread information and give an update to the user concerning the unread part of the thread. +Output references to issues and PRs as Markdown links. The current notification is for a thread that has owner ${owner} and is in the repo ${repo}. +If a comment references for example issue or PR #123, then output either of the following in the summary depending on if it is an issue or a PR: + +[#123](https://github.com/${owner}/${repo}/issues/123) +[#123](https://github.com/${owner}/${repo}/pull/123) + +When you summarize comments, always give a summary of each comment and always mention the author clearly before the comment. If the author is called 'joe' and the comment is 'this is a comment', then the output should be: + +joe: this is a comment + +If the content contains images in Markdown format (e.g., ![alt text](image-url)), always preserve them in the output exactly as they appear. Images are important visual content and should not be removed or summarized. + +Always include in your output, which part of the thread is unread by prefixing that part with the markdown heading of level 1 with text "Unread Thread" or "Unread Comments". +Make sure the summary is at least as short or shorter than the issue or PR with the comments and the patches if there are. +Example output: + +# Unread Thread + + + +or: + + +# Unread Comments + + +Both 'Unread Thread' and 'Unread Comments' should not appear at the same time as markdown titles. The following is incorrect: + +# Unread Thread + +# Unread Comments + +`; + } + +} \ No newline at end of file diff --git a/src/lm/tools/tools.ts b/src/lm/tools/tools.ts new file mode 100644 index 0000000000..fef9f2ac20 --- /dev/null +++ b/src/lm/tools/tools.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import * as vscode from 'vscode'; +import { CredentialStore } from '../../github/credentials'; +import { RepositoriesManager } from '../../github/repositoriesManager'; +import { ChatParticipantState } from '../participants'; +import { ActivePullRequestTool } from './activePullRequestTool'; +import { DisplayIssuesTool } from './displayIssuesTool'; +import { FetchIssueTool } from './fetchIssueTool'; +import { FetchNotificationTool } from './fetchNotificationTool'; +import { OpenPullRequestTool } from './openPullRequestTool'; +import { ConvertToSearchSyntaxTool, SearchTool } from './searchTools'; +import { SuggestFixTool } from './suggestFixTool'; +import { IssueSummarizationTool } from './summarizeIssueTool'; +import { NotificationSummarizationTool } from './summarizeNotificationsTool'; + +export function registerTools(context: vscode.ExtensionContext, credentialStore: CredentialStore, repositoriesManager: RepositoriesManager, chatParticipantState: ChatParticipantState) { + registerFetchingTools(context, credentialStore, repositoriesManager, chatParticipantState); + registerSummarizationTools(context); + registerSuggestFixTool(context, credentialStore, repositoriesManager, chatParticipantState); + registerSearchTools(context, credentialStore, repositoriesManager, chatParticipantState); + context.subscriptions.push(vscode.lm.registerTool(ActivePullRequestTool.toolId, new ActivePullRequestTool(repositoriesManager))); + context.subscriptions.push(vscode.lm.registerTool(OpenPullRequestTool.toolId, new OpenPullRequestTool(repositoriesManager))); +} + +function registerFetchingTools(context: vscode.ExtensionContext, credentialStore: CredentialStore, repositoriesManager: RepositoriesManager, chatParticipantState: ChatParticipantState) { + context.subscriptions.push(vscode.lm.registerTool(FetchIssueTool.toolId, new FetchIssueTool(credentialStore, repositoriesManager, chatParticipantState))); + context.subscriptions.push(vscode.lm.registerTool(FetchNotificationTool.toolId, new FetchNotificationTool(credentialStore, repositoriesManager, chatParticipantState))); +} + +function registerSummarizationTools(context: vscode.ExtensionContext) { + context.subscriptions.push(vscode.lm.registerTool(IssueSummarizationTool.toolId, new IssueSummarizationTool())); + context.subscriptions.push(vscode.lm.registerTool(NotificationSummarizationTool.toolId, new NotificationSummarizationTool())); +} + +function registerSuggestFixTool(context: vscode.ExtensionContext, credentialStore: CredentialStore, repositoriesManager: RepositoriesManager, chatParticipantState: ChatParticipantState) { + context.subscriptions.push(vscode.lm.registerTool(SuggestFixTool.toolId, new SuggestFixTool(credentialStore, repositoriesManager, chatParticipantState))); +} + +function registerSearchTools(context: vscode.ExtensionContext, credentialStore: CredentialStore, repositoriesManager: RepositoriesManager, chatParticipantState: ChatParticipantState) { + context.subscriptions.push(vscode.lm.registerTool(ConvertToSearchSyntaxTool.toolId, new ConvertToSearchSyntaxTool(credentialStore, repositoriesManager, chatParticipantState))); + context.subscriptions.push(vscode.lm.registerTool(SearchTool.toolId, new SearchTool(credentialStore, repositoriesManager, chatParticipantState))); + context.subscriptions.push(vscode.lm.registerTool(DisplayIssuesTool.toolId, new DisplayIssuesTool(context, chatParticipantState))); +} \ No newline at end of file diff --git a/src/lm/tools/toolsUtils.ts b/src/lm/tools/toolsUtils.ts new file mode 100644 index 0000000000..9b855bbe84 --- /dev/null +++ b/src/lm/tools/toolsUtils.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { AuthenticationError, AuthProvider } from '../../common/authentication'; +import { CredentialStore, GitHub } from '../../github/credentials'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; +import { RepositoriesManager } from '../../github/repositoriesManager'; +import { hasEnterpriseUri } from '../../github/utils'; +import { ChatParticipantState } from '../participants'; + +export interface IToolCall { + tool: vscode.LanguageModelToolInformation; + call: vscode.LanguageModelToolCallPart; + result: Thenable; +} + +export const TOOL_MARKDOWN_RESULT = 'TOOL_MARKDOWN_RESULT'; +export const TOOL_COMMAND_RESULT = 'TOOL_COMMAND_RESULT'; + +export interface IssueToolParameters { + issueNumber?: number; + repo?: { + owner?: string; + name?: string; + }; +} + +export interface IssueResult { + title: string; + body: string; + comments: { + body: string; + }[]; +} + +export abstract class ToolBase implements vscode.LanguageModelTool { + constructor(protected readonly chatParticipantState: ChatParticipantState) { } + abstract invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken): vscode.ProviderResult; +} + +export async function concatAsyncIterable(asyncIterable: AsyncIterable): Promise { + let result = ''; + for await (const chunk of asyncIterable) { + result += chunk; + } + return result; +} + +export abstract class RepoToolBase extends ToolBase { + constructor(private readonly credentialStore: CredentialStore, private readonly repositoriesManager: RepositoriesManager, chatParticipantState: ChatParticipantState) { + super(chatParticipantState); + } + + protected async getRepoInfo(options: { owner?: string, name?: string }): Promise<{ owner: string; name: string; folderManager: FolderRepositoryManager }> { + if (!this.credentialStore.isAnyAuthenticated()) { + throw new AuthenticationError(); + } + + let owner: string | undefined; + let name: string | undefined; + let folderManager: FolderRepositoryManager | undefined; + // The llm likes to make up an owner and name if it isn't provided one, and they tend to include 'owner' and 'name' respectively + if (options.owner && options.name && !options.owner.includes('owner') && !options.name.includes('name')) { + owner = options.owner; + name = options.name; + folderManager = this.repositoriesManager.getManagerForRepository(options.owner, options.name); + } + + if (!folderManager && this.repositoriesManager.folderManagers.length > 0) { + folderManager = this.repositoriesManager.folderManagers[0]; + if (owner && name) { + await folderManager.createGitHubRepositoryFromOwnerName(owner, name); + } else { + const defaults = await folderManager.getPullRequestDefaults(); + if (defaults) { + owner = defaults.owner; + name = defaults.repo; + } else { + owner = folderManager.gitHubRepositories[0].remote.owner; + name = folderManager.gitHubRepositories[0].remote.repositoryName; + } + } + } + + if (!folderManager || !owner || !name) { + throw new Error(`No repository found for ${owner}/${name}. Make sure to have the repository open.`); + } + return { owner, name, folderManager }; + } + + protected getGitHub(): GitHub | undefined { + let authProvider: AuthProvider | undefined; + if (this.credentialStore.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { + authProvider = AuthProvider.githubEnterprise; + } else if (this.credentialStore.isAuthenticated(AuthProvider.github)) { + authProvider = AuthProvider.github; + } + return (authProvider !== undefined) ? this.credentialStore.getHub(authProvider) : undefined; + } +} \ No newline at end of file diff --git a/src/lm/utils.ts b/src/lm/utils.ts new file mode 100644 index 0000000000..8a28a3bc33 --- /dev/null +++ b/src/lm/utils.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { commands } from '../common/executeCommands'; +import { EXPERIMENTAL_USE_QUICK_CHAT, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; + +export function chatCommand(): typeof commands.QUICK_CHAT_OPEN | typeof commands.OPEN_CHAT { + const useQuickChat = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(EXPERIMENTAL_USE_QUICK_CHAT, false); + if (useQuickChat) { + return commands.QUICK_CHAT_OPEN; + } + return commands.OPEN_CHAT; +} \ No newline at end of file diff --git a/src/migrations.ts b/src/migrations.ts new file mode 100644 index 0000000000..9c7eaf2d5e --- /dev/null +++ b/src/migrations.ts @@ -0,0 +1,105 @@ +/*--------------------------------------------------------------------------------------------- + * 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 * as PersistentState from './common/persistentState'; +import { BRANCH_PUBLISH, PR_SETTINGS_NAMESPACE, QUERIES } from './common/settingKeys'; +import { DefaultQueries, isAllQuery, isLocalQuery } from './view/treeNodes/categoryNode'; +import { IQueryInfo } from './view/treeNodes/workspaceFolderNode'; + +const PROMPTS_SCOPE = 'prompts'; +const PROMPT_TO_CREATE_PR_ON_PUBLISH_KEY = 'createPROnPublish'; + +export async function migrate(context: vscode.ExtensionContext) { + await createOnPublish(); + await makeQueriesScopedToRepo(context); + await addDefaultQueries(context); +} + +async function createOnPublish() { + // Migrate from state to setting + if (PersistentState.fetch(PROMPTS_SCOPE, PROMPT_TO_CREATE_PR_ON_PUBLISH_KEY) === false) { + await vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(BRANCH_PUBLISH, 'never', vscode.ConfigurationTarget.Global); + PersistentState.store(PROMPTS_SCOPE, PROMPT_TO_CREATE_PR_ON_PUBLISH_KEY, true); + } +} + +const HAS_MIGRATED_QUERIES = 'hasMigratedQueries'; +async function makeQueriesScopedToRepo(context: vscode.ExtensionContext) { + const hasMigratedUserQueries = context.globalState.get(HAS_MIGRATED_QUERIES, false); + const hasMigratedWorkspaceQueries = context.workspaceState.get(HAS_MIGRATED_QUERIES, false); + if (hasMigratedUserQueries && hasMigratedWorkspaceQueries) { + return; + } + + const configuration = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE); + const settingValue = configuration.inspect(QUERIES); + + const addRepoScope = (queries: IQueryInfo[]) => { + return queries.map(query => { + if (isLocalQuery(query) || isAllQuery(query)) { + return query; + } + return { + label: query.label, + query: query.query.includes('repo:') ? query.query : `repo:\${owner}/\${repository} ${query.query}`, + }; + }); + }; + + // User setting + if (!hasMigratedUserQueries && settingValue?.globalValue) { + await configuration.update(QUERIES, addRepoScope(settingValue.globalValue as IQueryInfo[]), vscode.ConfigurationTarget.Global); + } + context.globalState.update(HAS_MIGRATED_QUERIES, true); + + // Workspace setting + if (!hasMigratedWorkspaceQueries && settingValue?.workspaceValue) { + await configuration.update(QUERIES, addRepoScope(settingValue.workspaceValue as IQueryInfo[]), vscode.ConfigurationTarget.Workspace); + } + context.workspaceState.update(HAS_MIGRATED_QUERIES, true); +} + +const HAS_MIGRATED_DEFAULT_QUERIES = 'hasMigratedDefaultQueries4'; +async function addDefaultQueries(context: vscode.ExtensionContext) { + const hasMigratedUserQueries = context.globalState.get(HAS_MIGRATED_DEFAULT_QUERIES, false); + const hasMigratedWorkspaceQueries = context.workspaceState.get(HAS_MIGRATED_DEFAULT_QUERIES, false); + if (hasMigratedUserQueries && hasMigratedWorkspaceQueries) { + return; + } + + const configuration = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE); + const settingValue = configuration.inspect(QUERIES); + + const addNewDefaultQueries = (queries: IQueryInfo[]) => { + const hasLocalQuery = queries.some(query => isLocalQuery(query)); + const hasAllQuery = queries.some(query => isAllQuery(query)); + if (!hasLocalQuery) { + queries.unshift({ + label: DefaultQueries.Queries.LOCAL, + query: DefaultQueries.Values.DEFAULT, + }); + } + if (!hasAllQuery) { + queries.push({ + label: DefaultQueries.Queries.ALL, + query: DefaultQueries.Values.DEFAULT, + }); + } + return queries; + }; + + // User setting + if (!hasMigratedUserQueries && settingValue?.globalValue) { + await configuration.update(QUERIES, addNewDefaultQueries(settingValue.globalValue as IQueryInfo[]), vscode.ConfigurationTarget.Global); + } + context.globalState.update(HAS_MIGRATED_DEFAULT_QUERIES, true); + + // Workspace setting + if (!hasMigratedWorkspaceQueries && settingValue?.workspaceValue) { + await configuration.update(QUERIES, addNewDefaultQueries(settingValue.workspaceValue as IQueryInfo[]), vscode.ConfigurationTarget.Workspace); + } + context.workspaceState.update(HAS_MIGRATED_DEFAULT_QUERIES, true); +} \ No newline at end of file diff --git a/src/notifications/notificationDecorationProvider.ts b/src/notifications/notificationDecorationProvider.ts new file mode 100644 index 0000000000..486a29e9ca --- /dev/null +++ b/src/notifications/notificationDecorationProvider.ts @@ -0,0 +1,56 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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'; + +export class NotificationsDecorationProvider extends Disposable implements vscode.FileDecorationProvider { + private _readonlyOnDidChangeFileDecorations: vscode.EventEmitter = this._register(new vscode.EventEmitter()); + public readonly onDidChangeFileDecorations = this._readonlyOnDidChangeFileDecorations.event; + + constructor(private readonly _notificationsManager: NotificationsManager) { + super(); + this._register(_notificationsManager.onDidChangeNotifications(updates => { + const uris = updates.map(update => toNotificationUri({ key: update.notification.key })); + this._readonlyOnDidChangeFileDecorations.fire(uris); + })); + this._register(vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${EXPERIMENTAL_NOTIFICATIONS_SCORE}`)) { + this._readonlyOnDidChangeFileDecorations.fire(_notificationsManager.getAllNotifications().map(notification => toNotificationUri({ key: notification.notification.key }))); + } + })); + } + + private settingValue(): boolean { + return vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(EXPERIMENTAL_NOTIFICATIONS_SCORE, false); + } + + provideFileDecoration( + uri: vscode.Uri, + _token: vscode.CancellationToken, + ): vscode.ProviderResult { + if (!this.settingValue()) { + return undefined; + } + + if (this._notificationsManager.sortingMethod !== NotificationsSortMethod.Priority) { + return undefined; + } + + const notificationUriParams = fromNotificationUri(uri); + if (!notificationUriParams) { + return undefined; + } + + // Limit the length of the priority badge to two characters + const notification = this._notificationsManager.getNotification(notificationUriParams.key); + const priority = notification?.priority === '100' ? '99' : notification?.priority ?? '0'; + + return { badge: priority, tooltip: vscode.l10n.t('Priority score is {0}. {1}', priority, notification?.priorityReason ?? '') }; + } +} diff --git a/src/notifications/notificationItem.ts b/src/notifications/notificationItem.ts new file mode 100644 index 0000000000..7f312c118a --- /dev/null +++ b/src/notifications/notificationItem.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Notification } from '../github/interface'; +import { IssueModel } from '../github/issueModel'; + +export type NotificationTreeDataItem = NotificationTreeItem | LoadMoreNotificationsTreeItem; + +export interface LoadMoreNotificationsTreeItem { + readonly kind: 'loadMoreNotifications'; +} + +export interface NotificationTreeItem { + readonly notification: Notification; + readonly model: IssueModel; + priority?: string; + priorityReason?: string; + readonly kind: 'notification'; +} + +export function isNotificationTreeItem(item: unknown): item is NotificationTreeItem { + return !!item && (item as Partial).kind === 'notification'; +} + +export interface NotificationID { + threadId: string; + notificationKey: string; +} diff --git a/src/notifications/notificationsFeatureRegistar.ts b/src/notifications/notificationsFeatureRegistar.ts new file mode 100644 index 0000000000..708233f040 --- /dev/null +++ b/src/notifications/notificationsFeatureRegistar.ts @@ -0,0 +1,173 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ITelemetry } from '../common/telemetry'; +import { onceEvent } from '../common/utils'; +import { EXTENSION_ID } from '../constants'; +import { NotificationsDecorationProvider } from './notificationDecorationProvider'; +import { isNotificationTreeItem, NotificationID, NotificationTreeDataItem } from './notificationItem'; +import { NotificationsManager, NotificationsSortMethod } from './notificationsManager'; +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 { + + + constructor( + readonly credentialStore: CredentialStore, + private readonly _repositoriesManager: RepositoriesManager, + private readonly _telemetry: ITelemetry, + notificationsManager: NotificationsManager + ) { + super(); + // Decorations + const decorationsProvider = new NotificationsDecorationProvider(notificationsManager); + this._register(vscode.window.registerFileDecorationProvider(decorationsProvider)); + + // View + this._register(vscode.window.createTreeView('notifications:github', { + treeDataProvider: notificationsManager + })); + notificationsManager.refresh(); + + // Commands + this._register( + vscode.commands.registerCommand( + 'notifications.sortByTimestamp', + async () => { + /* __GDPR__ + "notifications.sortByTimestamp" : {} + */ + this._telemetry.sendTelemetryEvent('notifications.sortByTimestamp'); + notificationsManager.sortNotifications(NotificationsSortMethod.Timestamp); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'notifications.sortByPriority', + async () => { + /* __GDPR__ + "notifications.sortByTimestamp" : {} + */ + this._telemetry.sendTelemetryEvent('notifications.sortByTimestamp'); + notificationsManager.sortNotifications(NotificationsSortMethod.Priority); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand( + 'notifications.refresh', + () => { + /* __GDPR__ + "notifications.refresh" : {} + */ + this._telemetry.sendTelemetryEvent('notifications.refresh'); + notificationsManager.refresh(); + }, + this, + ), + ); + this._register( + vscode.commands.registerCommand('notifications.loadMore', () => { + /* __GDPR__ + "notifications.loadMore" : {} + */ + this._telemetry.sendTelemetryEvent('notifications.loadMore'); + notificationsManager.loadMore(); + }) + ); + this._register( + vscode.commands.registerCommand('notification.chatSummarizeNotification', (notification: NotificationTreeDataItem) => { + if (!isNotificationTreeItem(notification)) { + return; + } + /* __GDPR__ + "notification.chatSummarizeNotification" : {} + */ + this._telemetry.sendTelemetryEvent('notification.chatSummarizeNotification'); + vscode.commands.executeCommand(chatCommand(), vscode.l10n.t('@githubpr Summarize notification with thread ID #{0}', notification.notification.id)); + }) + ); + this._register( + vscode.commands.registerCommand('notification.markAsRead', (options: NotificationTreeDataItem) => { + const { threadId, notificationKey } = this._extractMarkAsCommandOptions(options); + /* __GDPR__ + "notification.markAsRead" : {} + */ + this._telemetry.sendTelemetryEvent('notification.markAsRead'); + notificationsManager.markAsRead({ threadId, notificationKey }); + }) + ); + this._register( + vscode.commands.registerCommand('notification.markAsDone', (options: NotificationTreeDataItem) => { + const { threadId, notificationKey } = this._extractMarkAsCommandOptions(options); + /* __GDPR__ + "notification.markAsDone" : {} + */ + this._telemetry.sendTelemetryEvent('notification.markAsDone'); + notificationsManager.markAsDone({ threadId, notificationKey }); + }) + ); + + this._register( + vscode.commands.registerCommand('notifications.markPullRequestsAsRead', () => { + /* __GDPR__ + "notifications.markPullRequestsAsRead" : {} + */ + this._telemetry.sendTelemetryEvent('notifications.markPullRequestsAsRead'); + return notificationsManager.markPullRequests(); + }) + ); + + this._register( + vscode.commands.registerCommand('notifications.markPullRequestsAsDone', () => { + /* __GDPR__ + "notifications.markPullRequestsAsDone" : {} + */ + this._telemetry.sendTelemetryEvent('notifications.markPullRequestsAsDone'); + return notificationsManager.markPullRequests(true); + }) + ); + this._register( + vscode.commands.registerCommand('notifications.configureNotificationsViewlet', () => { + /* __GDPR__ + "notifications.configureNotificationsViewlet" : {} + */ + this._telemetry.sendTelemetryEvent('notifications.configureNotificationsViewlet'); + return vscode.commands.executeCommand( + 'workbench.action.openSettings', + `@ext:${EXTENSION_ID} notifications`, + ); + }) + ); + + // Events + this._register(onceEvent(this._repositoriesManager.onDidLoadAnyRepositories)(() => { + notificationsManager.refresh(); + })); + } + + private _extractMarkAsCommandOptions(options: NotificationTreeDataItem | NotificationID | unknown): { threadId: string, notificationKey: string } { + let threadId: string; + let notificationKey: string; + const asID = options as Partial; + if (isNotificationTreeItem(options)) { + threadId = options.notification.id; + notificationKey = options.notification.key; + } else if (asID.threadId !== undefined && asID.notificationKey !== undefined) { + threadId = asID.threadId; + notificationKey = asID.notificationKey; + } else { + throw new Error(`Invalid arguments for command notification.markAsRead : ${JSON.stringify(options)}`); + } + return { threadId, notificationKey }; + } +} diff --git a/src/notifications/notificationsManager.ts b/src/notifications/notificationsManager.ts new file mode 100644 index 0000000000..049e23460f --- /dev/null +++ b/src/notifications/notificationsManager.ts @@ -0,0 +1,504 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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'; +import { AccountType, 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'; + +export interface INotificationTreeItems { + readonly notifications: NotificationTreeItem[]; + readonly hasNextPage: boolean + readonly pollInterval: number; + readonly lastModified: string; +} + +export enum NotificationsSortMethod { + Timestamp = 'Timestamp', + Priority = 'Priority' +} + +export class NotificationsManager extends Disposable implements vscode.TreeDataProvider { + private static ID = 'NotificationsManager'; + + // List of automated users that should be ignored when determining meaningful events + private static readonly AUTOMATED_USERS = ['vs-code-engineering']; + + private _onDidChangeTreeData: vscode.EventEmitter = this._register(new vscode.EventEmitter()); + readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; + + private readonly _onDidChangeNotifications = this._register(new vscode.EventEmitter()); + readonly onDidChangeNotifications = this._onDidChangeNotifications.event; + + private _pageCount: number = 1; + private _hasNextPage: boolean = false; + private _dateTime: Date = new Date(); + 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; } + + constructor( + private readonly _notificationProvider: NotificationsProvider, + private readonly _credentialStore: CredentialStore, + private readonly _repositoriesManager: RepositoriesManager, + private readonly _context: vscode.ExtensionContext + ) { + 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 + + async getChildren(element?: unknown): Promise { + if (element !== undefined) { + return undefined; + } + + const notificationsData = await this.getNotifications(); + if (notificationsData === undefined) { + return undefined; + } + + if (notificationsData.hasNextPage) { + return [...notificationsData.notifications, { kind: 'loadMoreNotifications' }]; + } + + return notificationsData.notifications; + } + + async getTreeItem(element: NotificationTreeDataItem): Promise { + if (isNotificationTreeItem(element)) { + return this._resolveNotificationTreeItem(element); + } + return this._resolveLoadMoreNotificationsTreeItem(); + } + + async resolveTreeItem( + item: vscode.TreeItem, + element: NotificationTreeDataItem, + ): Promise { + if (isNotificationTreeItem(element)) { + item.tooltip = await this._notificationMarkdownHover(element); + } + return item; + } + + private _resolveNotificationTreeItem(element: NotificationTreeItem): vscode.TreeItem { + const label = element.notification.subject.title; + const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.None); + const notification = element.notification; + const model = element.model; + + if (notification.subject.type === NotificationSubjectType.Issue && model instanceof IssueModel) { + item.iconPath = element.model.isOpen + ? new vscode.ThemeIcon('issues', new vscode.ThemeColor('issues.open')) + : new vscode.ThemeIcon('issue-closed', new vscode.ThemeColor('github.issues.closed')); + } + if (notification.subject.type === NotificationSubjectType.PullRequest && model instanceof PullRequestModel) { + item.iconPath = model.isOpen + ? new vscode.ThemeIcon('git-pull-request', new vscode.ThemeColor('pullRequests.open')) + : (model.isMerged + ? new vscode.ThemeIcon('git-pull-request', new vscode.ThemeColor('pullRequests.merged')) + : new vscode.ThemeIcon('git-pull-request-closed', new vscode.ThemeColor('pullRequests.closed'))); + } + item.description = `${notification.owner}/${notification.name}`; + item.contextValue = notification.subject.type; + item.resourceUri = toNotificationUri({ key: element.notification.key }); + if (element.model instanceof PullRequestModel) { + item.command = { + command: 'pr.openDescription', + title: vscode.l10n.t('Open Pull Request Description'), + arguments: [element.model] + }; + } else { + item.command = { + command: 'issue.openDescription', + title: vscode.l10n.t('Open Issue Description'), + arguments: [element.model] + }; + } + return item; + } + + private _resolveLoadMoreNotificationsTreeItem(): vscode.TreeItem { + const item = new vscode.TreeItem(vscode.l10n.t('Load More Notifications...'), vscode.TreeItemCollapsibleState.None); + item.command = { + title: 'Load More Notifications', + command: 'notifications.loadMore' + }; + item.contextValue = 'loadMoreNotifications'; + return item; + } + + private async _notificationMarkdownHover(element: NotificationTreeItem): Promise { + const markdown = new vscode.MarkdownString(undefined, true); + markdown.supportHtml = true; + + const notification = element.notification; + const model = element.model; + + // Add notification-specific information + if (notification.subject.type === NotificationSubjectType.Issue) { + const issueModel = model as IssueModel; + const issueMarkdownContent = await issueMarkdown(issueModel, this._context, this._repositoriesManager); + return issueMarkdownContent; + } else if (notification.subject.type === NotificationSubjectType.PullRequest) { + const prModel = model as PullRequestModel; + const prMarkdownContent = await issueMarkdown(prModel, this._context, this._repositoriesManager); + return prMarkdownContent; + } + + // Fallback for other types + const ownerName = `${notification.owner}/${notification.name}`; + 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.updatedAt.toLocaleString()} \n`); + markdown.appendMarkdown(`Reason: ${notification.reason} \n`); + + return markdown; + } + + //#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(); + await Promise.all(notificationsData.notifications.map(async notification => { + const model = await this._notificationProvider.getNotificationModel(notification); + if (!model) { + return; + } + + notificationTreeItems.set(notification.key, { + notification, model, kind: 'notification' + }); + })); + + for (const [key, value] of notificationTreeItems.entries()) { + this._notifications.set(key, value); + } + this._hasNextPage = notificationsData.hasNextPage; + + this._fetchNotifications = false; + } + + // Calculate notification priority + if (this._sortingMethod === NotificationsSortMethod.Priority) { + const notificationsWithoutPriority = Array.from(this._notifications.values()) + .filter(notification => notification.priority === undefined); + + const notificationPriorities = await this._notificationProvider + .getNotificationsPriority(notificationsWithoutPriority); + + for (const { key, priority, priorityReasoning } of notificationPriorities) { + const notification = this._notifications.get(key); + if (!notification) { + continue; + } + + notification.priority = priority; + notification.priorityReason = priorityReasoning; + + this._notifications.set(key, notification); + } + } + + const notifications = Array.from(this._notifications.values()); + this._updateContext(); + this._onDidChangeNotifications.fire(notifications); + + return { + notifications: this._sortNotifications(notifications), + hasNextPage: this._hasNextPage, + pollInterval, + lastModified + }; + } + + private _updateContext(): void { + const notificationCount = this._notifications.size; + commands.setContext(contexts.NOTIFICATION_COUNT, notificationCount === 0 ? -1 : notificationCount); + } + + + public getNotification(key: string): NotificationTreeItem | undefined { + return this._notifications.get(key); + } + + public getAllNotifications(): NotificationTreeItem[] { + return Array.from(this._notifications.values()); + } + + public refresh(): void { + if (this._notifications.size !== 0) { + const updates = Array.from(this._notifications.values()); + this._onDidChangeNotifications.fire(updates); + } + + this._pageCount = 1; + this._dateTime = new Date(); + this._notifications.clear(); + + this._refresh(true); + } + + public loadMore(): void { + this._pageCount++; + this._refresh(true); + } + + public _refresh(fetch: boolean): void { + this._fetchNotifications = fetch; + this._onDidChangeTreeData.fire(); + } + + public async markAsRead(notificationIdentifier: { threadId: string, notificationKey: string }): Promise { + const notification = this._notifications.get(notificationIdentifier.notificationKey); + if (notification) { + await this._notificationProvider.markAsRead(notificationIdentifier); + + this._onDidChangeNotifications.fire([notification]); + this._notifications.delete(notificationIdentifier.notificationKey); + this._updateContext(); + + this._refresh(false); + } + } + + public async markAsDone(notificationIdentifier: { threadId: string, notificationKey: string }): Promise { + const notification = this._notifications.get(notificationIdentifier.notificationKey); + if (notification) { + await this._notificationProvider.markAsDone(notificationIdentifier); + + this._onDidChangeNotifications.fire([notification]); + this._notifications.delete(notificationIdentifier.notificationKey); + this._updateContext(); + + this._refresh(false); + } + } + + private _isBot(user: { login: string, accountType?: AccountType }): boolean { + // Check if accountType indicates this is a bot + if (user.accountType === AccountType.Bot) { + return true; + } + // Check for common bot naming patterns + if (user.login.endsWith('[bot]')) { + return true; + } + // Check for specific automated users + if (NotificationsManager.AUTOMATED_USERS.includes(user.login)) { + return true; + } + return false; + } + + private _getMeaningfulEventTime(event: TimelineEvent, currentUser: string, isCurrentUser: boolean): Date | undefined { + const userCheck = (testUser?: string) => { + if (isCurrentUser) { + return testUser === currentUser; + } else if (!isCurrentUser) { + return testUser !== currentUser; + } + }; + + if (event.event === EventType.Committed) { + if (!this._isBot(event.author) && userCheck(event.author.login)) { + return new Date(event.committedDate); + } + } else if (event.event === EventType.Commented) { + if (event.user && !this._isBot(event.user) && userCheck(event.user.login)) { + return new Date(event.createdAt); + } + } else if (event.event === EventType.Reviewed) { + // We only count empty reviews as meaningful if the user is the current user + if (isCurrentUser || (event.comments.length > 0 || event.body.length > 0)) { + if (event.user && !this._isBot(event.user) && userCheck(event.user.login)) { + return new Date(event.submittedAt); + } + } + } + } + + public async markPullRequests(markAsDone: boolean = false): Promise { + const filteredNotifications = Array.from(this._notifications.values()).filter(notification => notification.notification.subject.type === NotificationSubjectType.PullRequest); + const timelines = await Promise.all(filteredNotifications.map(notification => (notification.model as PullRequestModel).getActivityTimelineEvents())); + + const markPromises: Promise[] = []; + + for (const [index, notification] of filteredNotifications.entries()) { + const currentUser = await this._credentialStore.getCurrentUser(notification.model.remote.authProviderId); + + // Check that there have been no comments, reviews, or commits, since last read + const timeline = timelines[index]; + let userLastEvent: Date | undefined = undefined; + let nonUserLastEvent: Date | undefined = undefined; + for (let i = timeline.length - 1; i >= 0; i--) { + const event = timeline[i]; + if (!userLastEvent) { + userLastEvent = this._getMeaningfulEventTime(event, currentUser.login, true); + } + if (!nonUserLastEvent) { + nonUserLastEvent = this._getMeaningfulEventTime(event, currentUser.login, false); + } + if (userLastEvent && nonUserLastEvent) { + break; + } + } + + if (!nonUserLastEvent || (userLastEvent && (userLastEvent.getTime() > nonUserLastEvent.getTime()))) { + if (markAsDone) { + markPromises.push(this._notificationProvider.markAsDone({ threadId: notification.notification.id, notificationKey: notification.notification.key })); + } else { + markPromises.push(this._notificationProvider.markAsRead({ threadId: notification.notification.id, notificationKey: notification.notification.key })); + } + this._notifications.delete(notification.notification.key); + } + } + await Promise.all(markPromises); + this.refresh(); + } + + public sortNotifications(method: NotificationsSortMethod): void { + if (this._sortingMethod === method) { + return; + } + + this._sortingMethod = method; + this._refresh(false); + } + + private _sortNotifications(notifications: NotificationTreeItem[]): NotificationTreeItem[] { + if (this._sortingMethod === NotificationsSortMethod.Timestamp) { + 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 new file mode 100644 index 0000000000..ba101c353b --- /dev/null +++ b/src/notifications/notificationsProvider.ts @@ -0,0 +1,320 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { NotificationTreeItem } from './notificationItem'; +import { AuthProvider } from '../common/authentication'; +import { Disposable } from '../common/lifecycle'; +import Logger from '../common/logger'; +import { CHAT_SETTINGS_NAMESPACE, DISABLE_AI_FEATURES, EXPERIMENTAL_NOTIFICATIONS_PAGE_SIZE, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { OctokitCommon } from '../github/common'; +import { CredentialStore, GitHub } from '../github/credentials'; +import { Issue, Notification, NotificationSubjectType } from '../github/interface'; +import { IssueModel } from '../github/issueModel'; +import { PullRequestModel } from '../github/pullRequestModel'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { hasEnterpriseUri, parseNotification } from '../github/utils'; +import { concatAsyncIterable } from '../lm/tools/toolsUtils'; + +export interface INotifications { + readonly notifications: Notification[]; + readonly hasNextPage: boolean; + readonly pollInterval: number; + readonly lastModified: string; +} + +export interface INotificationPriority { + readonly key: string; + readonly priority: string | undefined; + readonly priorityReasoning: string | undefined; +} + +export class NotificationsProvider extends Disposable { + private static readonly ID = 'NotificationsProvider'; + private _authProvider: AuthProvider | undefined; + + constructor( + private readonly _credentialStore: CredentialStore, + private readonly _repositoriesManager: RepositoriesManager + ) { + super(); + 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(_ => { + setAuthProvider(); + }) + ); + } + + private _getGitHub(): GitHub | undefined { + return (this._authProvider !== undefined) ? + this._credentialStore.getHub(this._authProvider) : + undefined; + } + + public async markAsRead(notificationIdentifier: { threadId: string, notificationKey: string }): Promise { + const gitHub = this._getGitHub(); + if (gitHub === undefined) { + return undefined; + } + await gitHub.octokit.call(gitHub.octokit.api.activity.markThreadAsRead, { + thread_id: Number(notificationIdentifier.threadId) + }); + } + + public async markAsDone(notificationIdentifier: { threadId: string, notificationKey: string }): Promise { + const gitHub = this._getGitHub(); + if (gitHub === undefined) { + return undefined; + } + await gitHub.octokit.call(gitHub.octokit.api.activity.markThreadAsDone, { + thread_id: Number(notificationIdentifier.threadId) + }); + } + + public async getNotifications(before: string, page: number): Promise { + const gitHub = this._getGitHub(); + if (gitHub === undefined) { + return undefined; + } + if (this._repositoriesManager.folderManagers.length === 0) { + return undefined; + } + + const pageSize = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(EXPERIMENTAL_NOTIFICATIONS_PAGE_SIZE, 50); + const { data, headers } = await gitHub.octokit.call(gitHub.octokit.api.activity.listNotificationsForAuthenticatedUser, { + all: false, + before: before, + page: page, + per_page: pageSize + }); + + const notifications = data + .map((notification: OctokitCommon.Notification) => parseNotification(notification)) + .filter(notification => !!notification) as Notification[]; + + 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> { + const url = notification.subject.url; + if (!(typeof url === 'string')) { + return undefined; + } + const issueOrPrNumber = url.split('/').pop(); + if (issueOrPrNumber === undefined) { + return undefined; + } + const folderManager = this._repositoriesManager.getManagerForRepository(notification.owner, notification.name) ?? this._repositoriesManager.folderManagers[0]; + 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; + } + + async getNotificationsPriority(notifications: NotificationTreeItem[]): Promise { + if (vscode.workspace.getConfiguration(CHAT_SETTINGS_NAMESPACE).get(DISABLE_AI_FEATURES, false)) { + return []; + } + const notificationBatchSize = 5; + const notificationBatches: NotificationTreeItem[][] = []; + for (let i = 0; i < notifications.length; i += notificationBatchSize) { + notificationBatches.push(notifications.slice(i, i + notificationBatchSize)); + } + + const models = await vscode.lm.selectChatModels({ vendor: 'copilot', family: 'gpt-4o' }); + const prioritizedBatches = await Promise.all(notificationBatches.map(batch => this._prioritizeNotificationBatch(batch, models[0]))); + return prioritizedBatches.flat(); + } + + private async _prioritizeNotificationBatch(notifications: NotificationTreeItem[], model: vscode.LanguageModelChat): Promise { + try { + const userLogin = (await this._credentialStore.getCurrentUser(AuthProvider.github)).login; + const messages = [vscode.LanguageModelChatMessage.User(getPrioritizeNotificationsInstructions(userLogin))]; + for (const [notificationIndex, notification] of notifications.entries()) { + const issueModel = notification.model; + if (!issueModel) { + continue; + } + let notificationMessage = this._getBasePrompt(issueModel, notificationIndex); + notificationMessage += await this._getLabelsPrompt(issueModel); + notificationMessage += await this._getCommentsPrompt(issueModel); + messages.push(vscode.LanguageModelChatMessage.User(notificationMessage)); + } + messages.push(vscode.LanguageModelChatMessage.User('Please provide the priority for each notification in a separate text code block. Remember to place the title and the reasoning outside of the text code block.')); + const response = await model.sendRequest(messages, {}); + const responseText = await concatAsyncIterable(response.text); + + return this._updateNotificationsWithPriorityFromLLM(notifications, responseText); + } catch (e) { + console.log(e); + return []; + } + } + + private _getBasePrompt(model: IssueModel | PullRequestModel, notificationIndex: number): string { + const assignees = model.assignees; + return ` +The following is the data for notification ${notificationIndex + 1}: +• Title: ${model.title} +• Author: ${model.author.login} +• Assignees: ${assignees?.map(assignee => assignee.login).join(', ') || 'none'} +• Body: + +${model.body} + +• Reaction Count: ${model.item.reactionCount ?? 0} +• isOpen: ${model.isOpen} +• isMerged: ${model.isMerged} +• Created At: ${model.createdAt} +• Updated At: ${model.updatedAt}`; + } + + private async _getLabelsPrompt(model: IssueModel | PullRequestModel): Promise { + const labels = model.item.labels; + if (!labels) { + return ''; + } + let labelsMessage = ''; + if (labels.length > 0) { + const labelListAsString = labels.map(label => label.name).join(', '); + labelsMessage = ` +• Labels: ${labelListAsString}`; + } + return labelsMessage; + } + + private async _getCommentsPrompt(model: IssueModel | PullRequestModel): Promise { + const issueComments = model.item.comments; + if (!issueComments || issueComments.length === 0) { + return ''; + } + let commentsMessage = ` + +The following is the data concerning the at most last 5 comments for the notification:`; + + let index = 1; + const lowerCommentIndexBound = Math.max(0, issueComments.length - 5); + for (let i = lowerCommentIndexBound; i < issueComments.length; i++) { + const comment = issueComments.at(i)!; + commentsMessage += ` + +Comment ${index} for notification: +• Body: +${comment.body} +• Reaction Count: ${comment.reactionCount}`; + index += 1; + } + return commentsMessage; + } + + private _updateNotificationsWithPriorityFromLLM(notifications: NotificationTreeItem[], text: string): INotificationPriority[] { + const regexReasoning = /```text\s*[\s\S]+?\s*=\s*([\S]+?)\s*```/gm; + const regexPriorityReasoning = /```(?!text)([\s\S]+?)(###|$)/g; + + const updates: INotificationPriority[] = []; + for (let i = 0; i < notifications.length; i++) { + const execResultForPriority = regexReasoning.exec(text); + + if (execResultForPriority) { + const execResultForPriorityReasoning = regexPriorityReasoning.exec(text); + + updates.push({ + key: notifications[i].notification.key, + priority: execResultForPriority[1], + priorityReasoning: execResultForPriorityReasoning ? + execResultForPriorityReasoning[1].trim() : undefined + }); + } + } + + return updates; + } +} + +function getPrioritizeNotificationsInstructions(githubHandle: string) { + return ` +You are an intelligent assistant tasked with prioritizing GitHub notifications. +You are given a list of notifications for the current user ${githubHandle}, each related to an issue, pull request or discussion. In the case of an issue/PR, if there are comments, you are given the last 5 comments under it. +Use the following scoring mechanism to prioritize the notifications and assign them a score from 0 to 100: + + 1. Assign points from 0 to 30 for the relevance of the notification. Below when we talk about the current user, it is always the user with the GitHub login handle ${githubHandle}. First consider if the corresponding thread is open or closed: + - If the thread is closed, assign points as follows: + - 0 points: If the current user is neither assigned, nor requested for a review, nor mentioned in the issue/PR/discussion. + - 5 points: If the current user is mentioned or is the author of the issue/PR. In the case of an issue/PR, the current user should not be assigned to it. + - 10 points: If the current user is assigned to the issue/PR or is requested for a review. + - If the thread is open, assign points as follows: + - 20 points: If the current user is neither assigned, nor requested for a review, nor mentioned in the issue/PR/discussion. + - 25 points: If the current user is mentioned or is the author of the issue/PR. In the case of an issue/PR, the current user should not be assigned to it. + - 30 points: If the current user is assigned to the issue/PR or is requested for a review. + 2. Assign points from 0 to 40 to the importance of the notification. Consider the following points: + - In case of an issue, does the content/title suggest this is a critical issue? In the case of a PR, does the content/title suggest it fixes a critical issue? In the case of a discussion, do the comments suggest a critical discussion? A critical issue/pr/discussion has a higher priority. + - To evaluate the importance/criticality of a notification evaluate whether it references the following. Such notifications should be assigned a higher priority. + - security vulnerabilities + - major regressions + - data loss + - crashes + - performance issues + - memory leaks + - breaking changes + - Do the labels assigned to the issue/PR/discussion indicate it is critical? Labels that include the following: 'critical', 'urgent', 'important', 'high priority' should be assigned a higher priority. + - Is the issue/PR suggesting it is blocking for other work and must be addressed immediately? If so, the notification should be assigned a higher priority. + - Is the issue/PR user facing? User facing issues/PRs that have a clear negative impact on the user should be assigned a higher priority. + - Is the tone of voice urgent or neutral? An urgent tone of voice has a higher priority. + - For issues, do the comments mention that the issue is a duplicate of another issue or is already fixed? If so assign a lower priority. + - Issues should generally be assigned a higher score than PRs and discussions. + - Issues about bugs/regressions should be assigned a higher priority than issues about feature requests which are less critical. + 3. Assign points from 0 to 30 for the community engagement. Consider the following points: + - Reactions: Consider the number of reactions under an issue/PR/discussion that correspond to real users. A higher number of reactions should be assigned a higher priority. + - Comments: Evaluate the community engagement on the issue/PR through the last 5 comments. If you detect a comment coming from a bot, do not include it in the following evaluation. Consider the following: + - Does the issue/PR/discussion have a lot of comments indicating widespread interest? + - Does the issue/PR/discussion have comments from many different users which would indicate widespread interest? + - Evaluate the comments content. Do they indicate that the issue/PR is critical and touches many people? A critical issue/PR should be assigned a higher priority. + - Evaluate the effort/detail put into the comments, are the users invested in the issue/PR/discussion? A higher effort should be assigned a higher priority. + - Evaluate the tone of voice in the comments, an urgent tone of voice should be assigned a higher priority. + - Evaluate the reactions under the comments, a higher number of reactions indicate widespread interest and issue/PR/discussion following. A higher number of reactions should be assigned a higher priority. + - Generally evaluate the issue/PR/discussion content quality. Consider the following points: + - Description: In the case of an issue, are there clear steps to reproduce the issue? In the case of a PR, is there a clear description of the change? A clearer, more complete description should be assigned a higher priority. + - Effort: Evaluate the general effort put into writing this issue/PR. Does the user provide a lengthy clear explanation? A higher effort should be assigned a higher priority. + +Use the above guidelines to assign points to each notification. Provide the sum of the individual points in a SEPARATE text code block for each notification. The points sum to 100 as a maximum. +After the text code block containing the priority, add a detailed summary of the notification and generally explain why it is important or not, do NOT reference the scoring mechanism above. This summary and reasoning will be displayed to the user. +The output should look as follow. Here corresponds to your summary and reasoning and corresponds to the notification title. The title should be placed after three hashtags: + +### <title> +\`\`\`text +20 + 30 + 20 = 70 +\`\`\`text +<summary + reasoning> + +The following is INCORRECT: + +<title> +20 + 30 + 20 = 70 +<summary + reasoning> +`; +} \ No newline at end of file diff --git a/src/test/browser/index.ts b/src/test/browser/index.ts index 4a34fbef75..ead80a2baf 100644 --- a/src/test/browser/index.ts +++ b/src/test/browser/index.ts @@ -1,3 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// @ts-nocheck // This file is providing the test runner to use when running extension tests. import * as vscode from 'vscode'; require('mocha/mocha'); diff --git a/src/test/builders/graphql/latestReviewCommitBuilder.ts b/src/test/builders/graphql/latestReviewCommitBuilder.ts index a51807ae01..49c9a42f6f 100644 --- a/src/test/builders/graphql/latestReviewCommitBuilder.ts +++ b/src/test/builders/graphql/latestReviewCommitBuilder.ts @@ -8,18 +8,15 @@ import { LatestReviewCommitResponse } from '../../../github/graphql'; import { RateLimitBuilder } from './rateLimitBuilder'; -type Repository = LatestReviewCommitResponse['repository']; +type Repository = NonNullable<LatestReviewCommitResponse['repository']>; type PullRequest = Repository['pullRequest']; -type ViewerLatestReview = PullRequest['viewerLatestReview']; -type Commit = ViewerLatestReview['commit']; +type Reviews = PullRequest['reviews']; export const LatestReviewCommitBuilder = createBuilderClass<LatestReviewCommitResponse>()({ repository: createLink<Repository>()({ pullRequest: createLink<PullRequest>()({ - viewerLatestReview: createLink<ViewerLatestReview>()({ - commit: createLink<Commit>()({ - oid: { default: 'abc' }, - }), + reviews: createLink<Reviews>()({ + nodes: { default: [] }, }), }), }), diff --git a/src/test/builders/graphql/pullRequestBuilder.ts b/src/test/builders/graphql/pullRequestBuilder.ts index 46830523dc..cd8b73682e 100644 --- a/src/test/builders/graphql/pullRequestBuilder.ts +++ b/src/test/builders/graphql/pullRequestBuilder.ts @@ -4,17 +4,31 @@ *--------------------------------------------------------------------------------------------*/ import { createBuilderClass, createLink } from '../base'; -import { PullRequestResponse, Ref, RefRepository } from '../../../github/graphql'; +import { BaseRefRepository, DefaultCommitMessage, DefaultCommitTitle, PullRequestResponse, Ref, RefRepository } from '../../../github/graphql'; import { RateLimitBuilder } from './rateLimitBuilder'; +import { AccountType } from '../../../github/interface'; const RefRepositoryBuilder = createBuilderClass<RefRepository>()({ + isInOrganization: { default: false }, owner: createLink<RefRepository['owner']>()({ login: { default: 'me' }, }), url: { default: 'https://github.com/owner/repo' }, }); +const BaseRefRepositoryBuilder = createBuilderClass<BaseRefRepository>()({ + isInOrganization: { default: false }, + owner: createLink<RefRepository['owner']>()({ + login: { default: 'me' }, + }), + url: { default: 'https://github.com/owner/repo' }, + mergeCommitMessage: { default: DefaultCommitMessage.commitMessages }, + mergeCommitTitle: { default: DefaultCommitTitle.mergeMessage }, + squashMergeCommitMessage: { default: DefaultCommitMessage.prBody }, + squashMergeCommitTitle: { default: DefaultCommitTitle.prTitle }, +}); + const RefBuilder = createBuilderClass<Ref>()({ name: { default: 'main' }, repository: { linked: RefRepositoryBuilder }, @@ -23,10 +37,11 @@ const RefBuilder = createBuilderClass<Ref>()({ }), }); -type Repository = PullRequestResponse['repository']; +type Repository = NonNullable<PullRequestResponse['repository']>; type PullRequest = Repository['pullRequest']; type Author = PullRequest['author']; type AssigneesConn = PullRequest['assignees']; +type CommitsConn = PullRequest['commits']; type LabelConn = PullRequest['labels']; export const PullRequestBuilder = createBuilderClass<PullRequestResponse>()({ @@ -45,10 +60,13 @@ export const PullRequestBuilder = createBuilderClass<PullRequestResponse>()({ nodes: { default: [ { - avatarUrl: undefined, - email: undefined, + avatarUrl: '', + email: '', login: 'me', url: 'https://github.com/me', + id: '123', + __typename: 'User', + name: 'Me' }, ], }, @@ -57,6 +75,10 @@ export const PullRequestBuilder = createBuilderClass<PullRequestResponse>()({ login: { default: 'me' }, url: { default: 'https://github.com/me' }, avatarUrl: { default: 'https://avatars3.githubusercontent.com/u/17565?v=4' }, + id: { default: '123' }, + name: { default: 'Me' }, + email: { default: 'me@me.com' }, + __typename: { default: AccountType.User }, }), createdAt: { default: '2019-01-01T10:00:00Z' }, updatedAt: { default: '2019-01-01T11:00:00Z' }, @@ -67,18 +89,30 @@ export const PullRequestBuilder = createBuilderClass<PullRequestResponse>()({ baseRef: { linked: RefBuilder }, baseRefName: { default: 'main' }, baseRefOid: { default: '0000000000000000000000000000000000000000' }, - baseRepository: { linked: RefRepositoryBuilder }, + baseRepository: { linked: BaseRefRepositoryBuilder }, labels: createLink<LabelConn>()({ nodes: { default: [] }, }), merged: { default: false }, mergeable: { default: 'MERGEABLE' }, mergeStateStatus: { default: 'CLEAN' }, + reviewThreads: { default: { totalCount: 0 } }, isDraft: { default: false }, suggestedReviewers: { default: [] }, viewerCanEnableAutoMerge: { default: false }, - viewerCanDisableAutoMerge: { default: false } - }), + viewerCanDisableAutoMerge: { default: false }, + viewerCanUpdate: { default: false }, + commits: createLink<CommitsConn>()({ + nodes: { + default: [ + { commit: { message: 'commit 1' } }, + ] + } + }), + reactions: { default: { totalCount: 0 } }, + comments: { default: { totalCount: 0 } }, + reactionGroups: { default: [] }, + }) }), rateLimit: { linked: RateLimitBuilder }, }); diff --git a/src/test/builders/graphql/timelineEventsBuilder.ts b/src/test/builders/graphql/timelineEventsBuilder.ts index 48eeb16e79..ec8a12b955 100644 --- a/src/test/builders/graphql/timelineEventsBuilder.ts +++ b/src/test/builders/graphql/timelineEventsBuilder.ts @@ -1,9 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { createBuilderClass, createLink } from '../base'; import { TimelineEventsResponse } from '../../../github/graphql'; import { RateLimitBuilder } from './rateLimitBuilder'; -type Repository = TimelineEventsResponse['repository']; +type Repository = NonNullable<TimelineEventsResponse['repository']>; type PullRequest = Repository['pullRequest']; type TimelineConn = PullRequest['timelineItems']; diff --git a/src/test/builders/rest/pullRequestBuilder.ts b/src/test/builders/rest/pullRequestBuilder.ts index 70af54d5f8..d65f476c16 100644 --- a/src/test/builders/rest/pullRequestBuilder.ts +++ b/src/test/builders/rest/pullRequestBuilder.ts @@ -1,5 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { UserBuilder } from './userBuilder'; -import { RefBuilder } from './refBuilder'; +import { NonNullUserRefBuilder, RefBuilder } from './refBuilder'; import { createLink, createBuilderClass } from '../base'; import { OctokitCommon } from '../../../github/common'; @@ -40,8 +45,8 @@ export const PullRequestBuilder = createBuilderClass<PullRequestUnion>()({ closed_at: { default: '' }, merged_at: { default: '' }, merge_commit_sha: { default: '' }, - head: { linked: RefBuilder }, - base: { linked: RefBuilder }, + head: { linked: NonNullUserRefBuilder }, + base: { linked: NonNullUserRefBuilder }, draft: { default: false }, merged: { default: false }, mergeable: { default: true }, diff --git a/src/test/builders/rest/refBuilder.ts b/src/test/builders/rest/refBuilder.ts index 6796e9a19f..8e4f070616 100644 --- a/src/test/builders/rest/refBuilder.ts +++ b/src/test/builders/rest/refBuilder.ts @@ -1,3 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { UserBuilder } from './userBuilder'; import { RepositoryBuilder } from './repoBuilder'; import { createBuilderClass } from '../base'; @@ -5,7 +10,7 @@ import { OctokitCommon } from '../../../github/common'; type RefUnion = OctokitCommon.PullsListResponseItemHead & OctokitCommon.PullsListResponseItemBase; -export const RefBuilder = createBuilderClass<RefUnion>()({ +export const RefBuilder = createBuilderClass<NonNullable<RefUnion>>()({ label: { default: 'octocat:new-feature' }, ref: { default: 'new-feature' }, user: { linked: UserBuilder }, @@ -14,4 +19,15 @@ export const RefBuilder = createBuilderClass<RefUnion>()({ repo: { linked: <any>RepositoryBuilder }, }); +// Variant where user is guaranteed non-null. +type NonNullUserRef = Omit<RefUnion, 'user'> & { user: NonNullable<RefUnion['user']> }; + +export const NonNullUserRefBuilder = createBuilderClass<NonNullUserRef>()({ + label: { default: 'octocat:new-feature' }, + ref: { default: 'new-feature' }, + user: { linked: UserBuilder }, // non-null guarantee + sha: { default: '0000000000000000000000000000000000000000' }, + repo: { linked: <any>RepositoryBuilder }, +}); + export type RefBuilder = InstanceType<typeof RefBuilder>; diff --git a/src/test/builders/rest/repoBuilder.ts b/src/test/builders/rest/repoBuilder.ts index 60174c4c96..3f05aaf494 100644 --- a/src/test/builders/rest/repoBuilder.ts +++ b/src/test/builders/rest/repoBuilder.ts @@ -1,8 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { UserBuilder } from './userBuilder'; import { OrganizationBuilder } from './organizationBuilder'; import { createBuilderClass, createLink } from '../base'; import { OctokitCommon } from '../../../github/common'; -import { ForkDetails } from '../../../github/githubRepository'; export type RepoUnion = OctokitCommon.ReposGetResponseData & OctokitCommon.PullsListResponseItemHeadRepo & @@ -12,7 +16,7 @@ type License = RepoUnion['license']; type Permissions = RepoUnion['permissions']; type CodeOfConduct = RepoUnion['code_of_conduct']; -export const RepositoryBuilder = createBuilderClass<RepoUnion>()({ +export const RepositoryBuilder = createBuilderClass<NonNullable<RepoUnion>>()({ id: { default: 0 }, node_id: { default: 'node0' }, name: { default: 'reponame' }, @@ -91,6 +95,7 @@ export const RepositoryBuilder = createBuilderClass<RepoUnion>()({ has_wiki: { default: true }, has_pages: { default: false }, has_downloads: { default: true }, + has_discussions: { default: false }, archived: { default: false }, pushed_at: { default: '2011-01-26T19:06:43Z' }, created_at: { default: '2011-01-26T19:01:12Z' }, @@ -118,9 +123,9 @@ export const RepositoryBuilder = createBuilderClass<RepoUnion>()({ name: { default: 'name' }, url: { default: 'https://github.com/octocat/reponame' }, }), - forks: { default: null }, - open_issues: { default: null }, - watchers: { default: null }, + forks: { default: 0 }, + open_issues: { default: 0 }, + watchers: { default: 0 }, }); export type RepositoryBuilder = InstanceType<typeof RepositoryBuilder>; diff --git a/src/test/builders/rest/teamBuilder.ts b/src/test/builders/rest/teamBuilder.ts index 2d17cd0447..877f921162 100644 --- a/src/test/builders/rest/teamBuilder.ts +++ b/src/test/builders/rest/teamBuilder.ts @@ -1,3 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { createBuilderClass } from '../base'; import { OctokitCommon } from '../../../github/common'; @@ -15,7 +20,7 @@ export const TeamBuilder = createBuilderClass<TeamUnion>()({ members_url: { default: 'https://api.github.com/teams/1/members{/member}' }, repositories_url: { default: 'https://api.github.com/teams/1/repos' }, html_url: { default: 'https://api.github.com/teams/1' }, - ldap_dn: { default: '' }, + parent: { default: null } }); export type TeamBuilder = InstanceType<typeof TeamBuilder>; diff --git a/src/test/builders/rest/userBuilder.ts b/src/test/builders/rest/userBuilder.ts index 377be7ec78..412b21548d 100644 --- a/src/test/builders/rest/userBuilder.ts +++ b/src/test/builders/rest/userBuilder.ts @@ -1,3 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { createBuilderClass } from '../base'; import { OctokitCommon } from '../../../github/common'; @@ -12,7 +17,9 @@ type UserUnion = | OctokitCommon.PullsListResponseItemHeadRepoOwner | OctokitCommon.IssuesListEventsForTimelineResponseItemActor; -export const UserBuilder = createBuilderClass<Required<UserUnion>>()({ +type NonNullUser = NonNullable<UserUnion>; + +export const UserBuilder = createBuilderClass<NonNullUser>()({ id: { default: 0 }, node_id: { default: 'node0' }, login: { default: 'octocat' }, @@ -32,6 +39,9 @@ export const UserBuilder = createBuilderClass<Required<UserUnion>>()({ type: { default: 'User' }, site_admin: { default: false }, starred_at: { default: '' }, + email: { default: 'email' }, + name: { default: 'Name' }, + user_view_type: { default: 'User' } }); export type UserBuilder = InstanceType<typeof UserBuilder>; diff --git a/src/test/common/fixtures/gitdiff/01-basic b/src/test/common/fixtures/gitdiff/01-basic new file mode 100644 index 0000000000..30c5fc9cb4 --- /dev/null +++ b/src/test/common/fixtures/gitdiff/01-basic @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation and GitHub. All rights reserved. + *--------------------------------------------------------------------------------------------*/ +import { spawn } from 'child_process'; + +export function f(args_: string[], flags: any, child: any) { + if (flags.verbose) { + child.stdout?.on('data', (data) => console.log(data.toString('utf8'))); + child.stderr?.on('data', (data) => console.error(data.toString('utf8'))); + } + if (flags.verbose) { + return new Promise((c) => child.once('exit', () => c(null))); + } + child.unref(); + return Promise.resolve(); +} \ No newline at end of file diff --git a/src/test/common/fixtures/gitdiff/01-basic-add-2-lines b/src/test/common/fixtures/gitdiff/01-basic-add-2-lines new file mode 100644 index 0000000000..cc5d326c71 --- /dev/null +++ b/src/test/common/fixtures/gitdiff/01-basic-add-2-lines @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation and GitHub. All rights reserved. + *--------------------------------------------------------------------------------------------*/ +import { spawn } from 'child_process'; +// a change + +export function f(args_: string[], flags: any, child: any) { + if (flags.verbose) { + child.stdout?.on('data', (data) => console.log(data.toString('utf8'))); + child.stderr?.on('data', (data) => console.error(data.toString('utf8'))); + } + if (flags.verbose) { + return new Promise((c) => child.once('exit', () => c(null))); + } + child.unref(); + // a second change + return Promise.resolve(); +} \ No newline at end of file diff --git a/src/test/common/fixtures/gitdiff/01-basic-add-2-lines.diff b/src/test/common/fixtures/gitdiff/01-basic-add-2-lines.diff new file mode 100644 index 0000000000..b77c42ecce --- /dev/null +++ b/src/test/common/fixtures/gitdiff/01-basic-add-2-lines.diff @@ -0,0 +1,20 @@ +diff --git a/01-basic b/01-basic-add-2-lines +index 30c5fc9cb..cc5d326c7 100644 +--- a/01-basic ++++ b/01-basic-add-2-lines +@@ -2,6 +2,7 @@ + * Copyright (c) Microsoft Corporation and GitHub. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + import { spawn } from 'child_process'; ++// a change + + export function f(args_: string[], flags: any, child: any) { + if (flags.verbose) { +@@ -12,5 +13,6 @@ export function f(args_: string[], flags: any, child: any) { + return new Promise((c) => child.once('exit', () => c(null))); + } + child.unref(); ++ // a second change + return Promise.resolve(); + } +\ No newline at end of file diff --git a/src/test/common/fixtures/gitdiff/01-basic-add-first-line b/src/test/common/fixtures/gitdiff/01-basic-add-first-line new file mode 100644 index 0000000000..d6eaa31c00 --- /dev/null +++ b/src/test/common/fixtures/gitdiff/01-basic-add-first-line @@ -0,0 +1,17 @@ +// hello +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation and GitHub. All rights reserved. + *--------------------------------------------------------------------------------------------*/ +import { spawn } from 'child_process'; + +export function f(args_: string[], flags: any, child: any) { + if (flags.verbose) { + child.stdout?.on('data', (data) => console.log(data.toString('utf8'))); + child.stderr?.on('data', (data) => console.error(data.toString('utf8'))); + } + if (flags.verbose) { + return new Promise((c) => child.once('exit', () => c(null))); + } + child.unref(); + return Promise.resolve(); +} \ No newline at end of file diff --git a/src/test/common/fixtures/gitdiff/01-basic-add-first-line.diff b/src/test/common/fixtures/gitdiff/01-basic-add-first-line.diff new file mode 100644 index 0000000000..badb086fb5 --- /dev/null +++ b/src/test/common/fixtures/gitdiff/01-basic-add-first-line.diff @@ -0,0 +1,9 @@ +diff --git a/01-basic b/01-basic-add-first-line +index 30c5fc9cb..d6eaa31c0 100644 +--- a/01-basic ++++ b/01-basic-add-first-line +@@ -1,3 +1,4 @@ ++// hello + /*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation and GitHub. All rights reserved. + *--------------------------------------------------------------------------------------------*/ diff --git a/src/test/common/fixtures/gitdiff/01-basic-add-last-line b/src/test/common/fixtures/gitdiff/01-basic-add-last-line new file mode 100644 index 0000000000..5fdf601c29 --- /dev/null +++ b/src/test/common/fixtures/gitdiff/01-basic-add-last-line @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation and GitHub. All rights reserved. + *--------------------------------------------------------------------------------------------*/ +import { spawn } from 'child_process'; + +export function f(args_: string[], flags: any, child: any) { + if (flags.verbose) { + child.stdout?.on('data', (data) => console.log(data.toString('utf8'))); + child.stderr?.on('data', (data) => console.error(data.toString('utf8'))); + } + if (flags.verbose) { + return new Promise((c) => child.once('exit', () => c(null))); + } + child.unref(); + return Promise.resolve(); +} +// hello diff --git a/src/test/common/fixtures/gitdiff/01-basic-add-last-line-with-eol b/src/test/common/fixtures/gitdiff/01-basic-add-last-line-with-eol new file mode 100644 index 0000000000..5fdf601c29 --- /dev/null +++ b/src/test/common/fixtures/gitdiff/01-basic-add-last-line-with-eol @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation and GitHub. All rights reserved. + *--------------------------------------------------------------------------------------------*/ +import { spawn } from 'child_process'; + +export function f(args_: string[], flags: any, child: any) { + if (flags.verbose) { + child.stdout?.on('data', (data) => console.log(data.toString('utf8'))); + child.stderr?.on('data', (data) => console.error(data.toString('utf8'))); + } + if (flags.verbose) { + return new Promise((c) => child.once('exit', () => c(null))); + } + child.unref(); + return Promise.resolve(); +} +// hello diff --git a/src/test/common/fixtures/gitdiff/01-basic-add-last-line-with-eol.diff b/src/test/common/fixtures/gitdiff/01-basic-add-last-line-with-eol.diff new file mode 100644 index 0000000000..55da64c860 --- /dev/null +++ b/src/test/common/fixtures/gitdiff/01-basic-add-last-line-with-eol.diff @@ -0,0 +1,12 @@ +diff --git a/01-basic b/01-basic-add-last-line-with-eol +index 30c5fc9cb..5fdf601c2 100644 +--- a/01-basic ++++ b/01-basic-add-last-line-with-eol +@@ -13,4 +13,5 @@ export function f(args_: string[], flags: any, child: any) { + } + child.unref(); + return Promise.resolve(); +-} +\ No newline at end of file ++} ++// hello diff --git a/src/test/common/fixtures/gitdiff/01-basic-add-last-line.diff b/src/test/common/fixtures/gitdiff/01-basic-add-last-line.diff new file mode 100644 index 0000000000..a9210b62c3 --- /dev/null +++ b/src/test/common/fixtures/gitdiff/01-basic-add-last-line.diff @@ -0,0 +1,12 @@ +diff --git a/01-basic b/01-basic-add-last-line +index 30c5fc9cb..5fdf601c2 100644 +--- a/01-basic ++++ b/01-basic-add-last-line +@@ -13,4 +13,5 @@ export function f(args_: string[], flags: any, child: any) { + } + child.unref(); + return Promise.resolve(); +-} +\ No newline at end of file ++} ++// hello diff --git a/src/test/common/fixtures/gitdiff/01-basic-add-line b/src/test/common/fixtures/gitdiff/01-basic-add-line new file mode 100644 index 0000000000..dccb43516f --- /dev/null +++ b/src/test/common/fixtures/gitdiff/01-basic-add-line @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation and GitHub. All rights reserved. + *--------------------------------------------------------------------------------------------*/ +import { spawn } from 'child_process'; + +export function f(args_: string[], flags: any, child: any) { + if (flags.verbose) { + child.stdout?.on('data', (data) => console.log(data.toString('utf8'))); + child.stderr?.on('data', (data) => console.error(data.toString('utf8'))); + } + // this is new line + if (flags.verbose) { + return new Promise((c) => child.once('exit', () => c(null))); + } + child.unref(); + return Promise.resolve(); +} \ No newline at end of file diff --git a/src/test/common/fixtures/gitdiff/01-basic-add-line.diff b/src/test/common/fixtures/gitdiff/01-basic-add-line.diff new file mode 100644 index 0000000000..75866ae4ee --- /dev/null +++ b/src/test/common/fixtures/gitdiff/01-basic-add-line.diff @@ -0,0 +1,12 @@ +diff --git a/01-basic b/01-basic-add-line +index 30c5fc9cb..dccb43516 100644 +--- a/01-basic ++++ b/01-basic-add-line +@@ -8,6 +8,7 @@ export function f(args_: string[], flags: any, child: any) { + child.stdout?.on('data', (data) => console.log(data.toString('utf8'))); + child.stderr?.on('data', (data) => console.error(data.toString('utf8'))); + } ++ // this is new line + if (flags.verbose) { + return new Promise((c) => child.once('exit', () => c(null))); + } diff --git a/src/test/common/fixtures/gitdiff/01-basic-move-lines b/src/test/common/fixtures/gitdiff/01-basic-move-lines new file mode 100644 index 0000000000..516748dfa5 --- /dev/null +++ b/src/test/common/fixtures/gitdiff/01-basic-move-lines @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation and GitHub. All rights reserved. + *--------------------------------------------------------------------------------------------*/ +import { spawn } from 'child_process'; + +export function f(args_: string[], flags: any, child: any) { + if (flags.verbose) { + return new Promise((c) => child.once('exit', () => c(null))); + } + if (flags.verbose) { + child.stdout?.on('data', (data) => console.log(data.toString('utf8'))); + child.stderr?.on('data', (data) => console.error(data.toString('utf8'))); + } + child.unref(); + return Promise.resolve(); +} \ No newline at end of file diff --git a/src/test/common/fixtures/gitdiff/01-basic-move-lines.diff b/src/test/common/fixtures/gitdiff/01-basic-move-lines.diff new file mode 100644 index 0000000000..f2d618490c --- /dev/null +++ b/src/test/common/fixtures/gitdiff/01-basic-move-lines.diff @@ -0,0 +1,19 @@ +diff --git a/01-basic b/01-basic-move-lines +index 30c5fc9cb..516748dfa 100644 +--- a/01-basic ++++ b/01-basic-move-lines +@@ -5,11 +5,11 @@ import { spawn } from 'child_process'; + + export function f(args_: string[], flags: any, child: any) { + if (flags.verbose) { +- child.stdout?.on('data', (data) => console.log(data.toString('utf8'))); +- child.stderr?.on('data', (data) => console.error(data.toString('utf8'))); ++ return new Promise((c) => child.once('exit', () => c(null))); + } + if (flags.verbose) { +- return new Promise((c) => child.once('exit', () => c(null))); ++ child.stdout?.on('data', (data) => console.log(data.toString('utf8'))); ++ child.stderr?.on('data', (data) => console.error(data.toString('utf8'))); + } + child.unref(); + return Promise.resolve(); diff --git a/src/test/common/fixtures/gitdiff/01-basic-remove-first-line b/src/test/common/fixtures/gitdiff/01-basic-remove-first-line new file mode 100644 index 0000000000..4f0b01321a --- /dev/null +++ b/src/test/common/fixtures/gitdiff/01-basic-remove-first-line @@ -0,0 +1,15 @@ +* Copyright (c) Microsoft Corporation and GitHub. All rights reserved. + *--------------------------------------------------------------------------------------------*/ +import { spawn } from 'child_process'; + +export function f(args_: string[], flags: any, child: any) { + if (flags.verbose) { + child.stdout?.on('data', (data) => console.log(data.toString('utf8'))); + child.stderr?.on('data', (data) => console.error(data.toString('utf8'))); + } + if (flags.verbose) { + return new Promise((c) => child.once('exit', () => c(null))); + } + child.unref(); + return Promise.resolve(); +} \ No newline at end of file diff --git a/src/test/common/fixtures/gitdiff/01-basic-remove-first-line.diff b/src/test/common/fixtures/gitdiff/01-basic-remove-first-line.diff new file mode 100644 index 0000000000..51d6f6fda7 --- /dev/null +++ b/src/test/common/fixtures/gitdiff/01-basic-remove-first-line.diff @@ -0,0 +1,11 @@ +diff --git a/01-basic b/01-basic-remove-first-line +index 30c5fc9cb..4f0b01321 100644 +--- a/01-basic ++++ b/01-basic-remove-first-line +@@ -1,5 +1,4 @@ +-/*--------------------------------------------------------------------------------------------- +- * Copyright (c) Microsoft Corporation and GitHub. All rights reserved. ++* Copyright (c) Microsoft Corporation and GitHub. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + import { spawn } from 'child_process'; + diff --git a/src/test/common/fixtures/gitdiff/01-basic-remove-last-line b/src/test/common/fixtures/gitdiff/01-basic-remove-last-line new file mode 100644 index 0000000000..fce0f87c3f --- /dev/null +++ b/src/test/common/fixtures/gitdiff/01-basic-remove-last-line @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation and GitHub. All rights reserved. + *--------------------------------------------------------------------------------------------*/ +import { spawn } from 'child_process'; + +export function f(args_: string[], flags: any, child: any) { + if (flags.verbose) { + child.stdout?.on('data', (data) => console.log(data.toString('utf8'))); + child.stderr?.on('data', (data) => console.error(data.toString('utf8'))); + } + if (flags.verbose) { + return new Promise((c) => child.once('exit', () => c(null))); + } + child.unref(); + return Promise.resolve(); \ No newline at end of file diff --git a/src/test/common/fixtures/gitdiff/01-basic-remove-last-line-with-eol b/src/test/common/fixtures/gitdiff/01-basic-remove-last-line-with-eol new file mode 100644 index 0000000000..b86219bf33 --- /dev/null +++ b/src/test/common/fixtures/gitdiff/01-basic-remove-last-line-with-eol @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation and GitHub. All rights reserved. + *--------------------------------------------------------------------------------------------*/ +import { spawn } from 'child_process'; + +export function f(args_: string[], flags: any, child: any) { + if (flags.verbose) { + child.stdout?.on('data', (data) => console.log(data.toString('utf8'))); + child.stderr?.on('data', (data) => console.error(data.toString('utf8'))); + } + if (flags.verbose) { + return new Promise((c) => child.once('exit', () => c(null))); + } + child.unref(); + return Promise.resolve(); diff --git a/src/test/common/fixtures/gitdiff/01-basic-remove-last-line-with-eol.diff b/src/test/common/fixtures/gitdiff/01-basic-remove-last-line-with-eol.diff new file mode 100644 index 0000000000..6afc37a891 --- /dev/null +++ b/src/test/common/fixtures/gitdiff/01-basic-remove-last-line-with-eol.diff @@ -0,0 +1,10 @@ +diff --git a/01-basic b/01-basic-remove-last-line-with-eol +index 30c5fc9cb..b86219bf3 100644 +--- a/01-basic ++++ b/01-basic-remove-last-line-with-eol +@@ -13,4 +13,3 @@ export function f(args_: string[], flags: any, child: any) { + } + child.unref(); + return Promise.resolve(); +-} +\ No newline at end of file diff --git a/src/test/common/fixtures/gitdiff/01-basic-remove-last-line.diff b/src/test/common/fixtures/gitdiff/01-basic-remove-last-line.diff new file mode 100644 index 0000000000..094ac4e04f --- /dev/null +++ b/src/test/common/fixtures/gitdiff/01-basic-remove-last-line.diff @@ -0,0 +1,13 @@ +diff --git a/01-basic b/01-basic-remove-last-line +index 30c5fc9cb..fce0f87c3 100644 +--- a/01-basic ++++ b/01-basic-remove-last-line +@@ -12,5 +12,4 @@ export function f(args_: string[], flags: any, child: any) { + return new Promise((c) => child.once('exit', () => c(null))); + } + child.unref(); +- return Promise.resolve(); +-} +\ No newline at end of file ++ return Promise.resolve(); +\ No newline at end of file diff --git a/src/test/common/fixtures/gitdiff/01-basic-remove-line b/src/test/common/fixtures/gitdiff/01-basic-remove-line new file mode 100644 index 0000000000..85fb5c91c7 --- /dev/null +++ b/src/test/common/fixtures/gitdiff/01-basic-remove-line @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation and GitHub. All rights reserved. + *--------------------------------------------------------------------------------------------*/ +import { spawn } from 'child_process'; + +export function f(args_: string[], flags: any, child: any) { + if (flags.verbose) { + child.stdout?.on('data', (data) => console.log(data.toString('utf8'))); + child.stderr?.on('data', (data) => console.error(data.toString('utf8'))); + } + if (flags.verbose) { + } + child.unref(); + return Promise.resolve(); +} \ No newline at end of file diff --git a/src/test/common/fixtures/gitdiff/01-basic-remove-line.diff b/src/test/common/fixtures/gitdiff/01-basic-remove-line.diff new file mode 100644 index 0000000000..1715fa4018 --- /dev/null +++ b/src/test/common/fixtures/gitdiff/01-basic-remove-line.diff @@ -0,0 +1,12 @@ +diff --git a/01-basic b/01-basic-remove-line +index 30c5fc9cb..85fb5c91c 100644 +--- a/01-basic ++++ b/01-basic-remove-line +@@ -9,7 +9,6 @@ export function f(args_: string[], flags: any, child: any) { + child.stderr?.on('data', (data) => console.error(data.toString('utf8'))); + } + if (flags.verbose) { +- return new Promise((c) => child.once('exit', () => c(null))); + } + child.unref(); + return Promise.resolve(); diff --git a/src/test/common/fixtures/gitdiff/01-basic-replace-line b/src/test/common/fixtures/gitdiff/01-basic-replace-line new file mode 100644 index 0000000000..4ab1236d24 --- /dev/null +++ b/src/test/common/fixtures/gitdiff/01-basic-replace-line @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation and GitHub. All rights reserved. + *--------------------------------------------------------------------------------------------*/ +import { spawn } from 'child_process'; + +export function f(args_: string[], flags: any, child: any) { + if (flags.verbose) { + child.stdout?.on('data', (data) => console.log(data.toString('utf8'))); + child.stderr?.on('data', (data) => console.error(data.toString('utf8'))); + } + if (flags.verbose && flags.test) { + return new Promise((c) => child.once('exit', () => c(null))); + } + child.unref(); + return Promise.resolve(); +} \ No newline at end of file diff --git a/src/test/common/fixtures/gitdiff/01-basic-replace-line.diff b/src/test/common/fixtures/gitdiff/01-basic-replace-line.diff new file mode 100644 index 0000000000..37c1e9d939 --- /dev/null +++ b/src/test/common/fixtures/gitdiff/01-basic-replace-line.diff @@ -0,0 +1,13 @@ +diff --git a/01-basic b/01-basic-replace-line +index 30c5fc9cb..4ab1236d2 100644 +--- a/01-basic ++++ b/01-basic-replace-line +@@ -8,7 +8,7 @@ export function f(args_: string[], flags: any, child: any) { + child.stdout?.on('data', (data) => console.log(data.toString('utf8'))); + child.stderr?.on('data', (data) => console.error(data.toString('utf8'))); + } +- if (flags.verbose) { ++ if (flags.verbose && flags.test) { + return new Promise((c) => child.once('exit', () => c(null))); + } + child.unref(); diff --git a/src/test/common/fixtures/gitdiff/02-basicWithEol b/src/test/common/fixtures/gitdiff/02-basicWithEol new file mode 100644 index 0000000000..807b9ea8fe --- /dev/null +++ b/src/test/common/fixtures/gitdiff/02-basicWithEol @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation and GitHub. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +export function f(args_: string[], flags: any, child: any) { + if (flags.verbose) { + child.stdout?.on('data', (data) => console.log(data.toString('utf8'))); + child.stderr?.on('data', (data) => console.error(data.toString('utf8'))); + } + if (flags.verbose) { + return new Promise((c) => child.once('exit', () => c(null))); + } + child.unref(); + return Promise.resolve(); +} diff --git a/src/test/common/fixtures/gitdiff/02-basicWithEol-add-line b/src/test/common/fixtures/gitdiff/02-basicWithEol-add-line new file mode 100644 index 0000000000..a686137e6b --- /dev/null +++ b/src/test/common/fixtures/gitdiff/02-basicWithEol-add-line @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation and GitHub. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +export function f(args_: string[], flags: any, child: any) { + if (flags.verbose) { + child.stdout?.on('data', (data) => console.log(data.toString('utf8'))); + child.stderr?.on('data', (data) => console.error(data.toString('utf8'))); + } + if (flags.verbose) { + return new Promise((c) => child.once('exit', () => c(null))); + } + child.unref(); + return Promise.resolve(); +} + diff --git a/src/test/common/fixtures/gitdiff/02-basicWithEol-add-line.diff b/src/test/common/fixtures/gitdiff/02-basicWithEol-add-line.diff new file mode 100644 index 0000000000..1cdf655bec --- /dev/null +++ b/src/test/common/fixtures/gitdiff/02-basicWithEol-add-line.diff @@ -0,0 +1,9 @@ +diff --git a/02-basicWithEol b/02-basicWithEol-add-line +index 807b9ea8f..a686137e6 100644 +--- a/02-basicWithEol ++++ b/02-basicWithEol-add-line +@@ -13,3 +13,4 @@ export function f(args_: string[], flags: any, child: any) { + child.unref(); + return Promise.resolve(); + } ++ diff --git a/src/test/common/fixtures/gitdiff/02-basicWithEol-remove-eol b/src/test/common/fixtures/gitdiff/02-basicWithEol-remove-eol new file mode 100644 index 0000000000..42c4f50225 --- /dev/null +++ b/src/test/common/fixtures/gitdiff/02-basicWithEol-remove-eol @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation and GitHub. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +export function f(args_: string[], flags: any, child: any) { + if (flags.verbose) { + child.stdout?.on('data', (data) => console.log(data.toString('utf8'))); + child.stderr?.on('data', (data) => console.error(data.toString('utf8'))); + } + if (flags.verbose) { + return new Promise((c) => child.once('exit', () => c(null))); + } + child.unref(); + return Promise.resolve(); +} \ No newline at end of file diff --git a/src/test/common/fixtures/gitdiff/02-basicWithEol-remove-eol.diff b/src/test/common/fixtures/gitdiff/02-basicWithEol-remove-eol.diff new file mode 100644 index 0000000000..7e0d486e9d --- /dev/null +++ b/src/test/common/fixtures/gitdiff/02-basicWithEol-remove-eol.diff @@ -0,0 +1,11 @@ +diff --git a/02-basicWithEol b/02-basicWithEol-remove-eol +index 807b9ea8f..42c4f5022 100644 +--- a/02-basicWithEol ++++ b/02-basicWithEol-remove-eol +@@ -12,4 +12,4 @@ export function f(args_: string[], flags: any, child: any) { + } + child.unref(); + return Promise.resolve(); +-} ++} +\ No newline at end of file diff --git a/src/test/common/fixtures/gitdiff/02-basicWithEol-remove-last-line b/src/test/common/fixtures/gitdiff/02-basicWithEol-remove-last-line new file mode 100644 index 0000000000..0b12d9ab81 --- /dev/null +++ b/src/test/common/fixtures/gitdiff/02-basicWithEol-remove-last-line @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation and GitHub. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +export function f(args_: string[], flags: any, child: any) { + if (flags.verbose) { + child.stdout?.on('data', (data) => console.log(data.toString('utf8'))); + child.stderr?.on('data', (data) => console.error(data.toString('utf8'))); + } + if (flags.verbose) { + return new Promise((c) => child.once('exit', () => c(null))); + } + child.unref(); + return Promise.resolve(); diff --git a/src/test/common/fixtures/gitdiff/02-basicWithEol-remove-last-line.diff b/src/test/common/fixtures/gitdiff/02-basicWithEol-remove-last-line.diff new file mode 100644 index 0000000000..126e0125bf --- /dev/null +++ b/src/test/common/fixtures/gitdiff/02-basicWithEol-remove-last-line.diff @@ -0,0 +1,9 @@ +diff --git a/02-basicWithEol b/02-basicWithEol-remove-last-line +index 807b9ea8f..0b12d9ab8 100644 +--- a/02-basicWithEol ++++ b/02-basicWithEol-remove-last-line +@@ -12,4 +12,3 @@ export function f(args_: string[], flags: any, child: any) { + } + child.unref(); + return Promise.resolve(); +-} diff --git a/src/test/common/fixtures/gitdiff/02-basicWithEol-shorten-file b/src/test/common/fixtures/gitdiff/02-basicWithEol-shorten-file new file mode 100644 index 0000000000..ae33b0f932 --- /dev/null +++ b/src/test/common/fixtures/gitdiff/02-basicWithEol-shorten-file @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation and GitHub. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +export function f(args_: string[], flags: any, child: any) { + if (flags.verbose) { + child.stdout?.on('data', (data) => console.log(data.toString('utf8'))); + child.stderr?.on('data', (data) => console.error(data.toString('utf8'))); + } diff --git a/src/test/common/fixtures/gitdiff/02-basicWithEol-shorten-file.diff b/src/test/common/fixtures/gitdiff/02-basicWithEol-shorten-file.diff new file mode 100644 index 0000000000..40ec250cbb --- /dev/null +++ b/src/test/common/fixtures/gitdiff/02-basicWithEol-shorten-file.diff @@ -0,0 +1,14 @@ +diff --git a/02-basicWithEol b/02-basicWithEol-shorten-file +index 807b9ea8..ae33b0f9 100644 +--- a/02-basicWithEol ++++ b/02-basicWithEol-shorten-file +@@ -7,9 +7,3 @@ export function f(args_: string[], flags: any, child: any) { + child.stdout?.on('data', (data) => console.log(data.toString('utf8'))); + child.stderr?.on('data', (data) => console.error(data.toString('utf8'))); + } +- if (flags.verbose) { +- return new Promise((c) => child.once('exit', () => c(null))); +- } +- child.unref(); +- return Promise.resolve(); +-} diff --git a/src/test/common/fixtures/gitdiff/03-large b/src/test/common/fixtures/gitdiff/03-large new file mode 100644 index 0000000000..51efe28ac7 --- /dev/null +++ b/src/test/common/fixtures/gitdiff/03-large @@ -0,0 +1,2927 @@ +{ + "name": "vscode-pull-request-github", + "displayName": "%displayName%", + "description": "%description%", + "icon": "resources/icons/github_logo.png", + "repository": { + "type": "git", + "url": "https://github.com/Microsoft/vscode-pull-request-github" + }, + "bugs": { + "url": "https://github.com/Microsoft/vscode-pull-request-github/issues" + }, + "enabledApiProposals": [ + "activeComment", + "commentingRangeHint", + "commentThreadApplicability", + "contribCommentsViewThreadMenus", + "tokenInformation", + "contribShareMenu", + "fileComments", + "codeActionRanges", + "commentReactor", + "contribCommentPeekContext", + "contribCommentThreadAdditionalMenu", + "codiconDecoration", + "diffCommand", + "contribCommentEditorActionsMenu", + "shareProvider", + "quickDiffProvider", + "tabInputTextMerge", + "treeViewMarkdownMessage" + ], + "version": "0.84.0", + "publisher": "GitHub", + "engines": { + "vscode": "^1.88.0" + }, + "categories": [ + "Other" + ], + "extensionDependencies": [ + "vscode.github-authentication" + ], + "activationEvents": [ + "onStartupFinished", + "onFileSystem:newIssue", + "onFileSystem:pr", + "onFileSystem:githubpr", + "onFileSystem:review" + ], + "browser": "./dist/browser/extension", + "l10n": "./dist/browser/extension", + "main": "./dist/extension", + "capabilities": { + "untrustedWorkspaces": { + "supported": true + }, + "virtualWorkspaces": true + }, + "contributes": { + "configuration": { + "type": "object", + "title": "GitHub Pull Requests", + "properties": { + "githubPullRequests.pullRequestTitle": { + "deprecationMessage": "The pull request title now uses the same defaults as GitHub, and can be edited before create.", + "type": "string", + "enum": [ + "commit", + "branch", + "custom", + "ask" + ], + "enumDescriptions": [ + "Use the latest commit message", + "Use the branch name", + "Specify a custom title", + "Ask which of the above methods to use" + ], + "default": "ask", + "description": "The title used when creating pull requests." + }, + "githubPullRequests.pullRequestDescription": { + "type": "string", + "enum": [ + "template", + "commit", + "none", + "Copilot" + ], + "enumDescriptions": [ + "%githubPullRequests.pullRequestDescription.template%", + "%githubPullRequests.pullRequestDescription.commit%", + "%githubPullRequests.pullRequestDescription.none%", + "%githubPullRequests.pullRequestDescription.copilot%" + ], + "default": "template", + "description": "%githubPullRequests.pullRequestDescription.description%" + }, + "githubPullRequests.defaultCreateOption": { + "type":"string", + "enum": [ + "lastUsed", + "create", + "createDraft", + "createAutoMerge" + ], + "markdownEnumDescriptions": [ + "%githubPullRequests.defaultCreateOption.lastUsed%", + "%githubPullRequests.defaultCreateOption.create%", + "%githubPullRequests.defaultCreateOption.createDraft%", + "%githubPullRequests.defaultCreateOption.createAutoMerge%" + ], + "default": "lastUsed", + "description": "%githubPullRequests.defaultCreateOption.description%" + }, + "githubPullRequests.createDraft": { + "type": "boolean", + "default": false, + "deprecationMessage": "Use the setting 'githubPullRequests.defaultCreateOption' instead.", + "description": "%githubPullRequests.createDraft%" + }, + "githubPullRequests.logLevel": { + "type": "string", + "enum": [ + "info", + "debug", + "off" + ], + "default": "info", + "description": "%githubPullRequests.logLevel.description%", + "markdownDeprecationMessage": "%githubPullRequests.logLevel.markdownDeprecationMessage%" + }, + "githubPullRequests.remotes": { + "type": "array", + "default": [ + "origin", + "upstream" + ], + "items": { + "type": "string" + }, + "markdownDescription": "%githubPullRequests.remotes.markdownDescription%" + }, + "githubPullRequests.includeRemotes": { + "type": "string", + "enum": [ + "default", + "all" + ], + "default": "default", + "deprecationMessage": "The setting `githubPullRequests.includeRemotes` has been deprecated. Use `githubPullRequests.remotes` to configure what remotes are shown.", + "description": "By default we only support remotes created by users. If you want to see pull requests from remotes this extension created for pull requests, change this setting to 'all'." + }, + "githubPullRequests.queries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "%githubPullRequests.queries.label.description%" + }, + "query": { + "type": "string", + "description": "%githubPullRequests.queries.query.description%" + } + } + }, + "scope": "resource", + "markdownDescription": "%githubPullRequests.queries.markdownDescription%", + "default": [ + { + "label": "%githubPullRequests.queries.waitingForMyReview%", + "query": "is:open review-requested:${user}" + }, + { + "label": "%githubPullRequests.queries.assignedToMe%", + "query": "is:open assignee:${user}" + }, + { + "label": "%githubPullRequests.queries.createdByMe%", + "query": "is:open author:${user}" + } + ] + }, + "githubPullRequests.labelCreated": { + "type": "array", + "items": { + "type": "string", + "description": "%githubPullRequests.labelCreated.label.description%" + }, + "default": [], + "description": "%githubPullRequests.labelCreated.description%" + }, + "githubPullRequests.defaultMergeMethod": { + "type": "string", + "enum": [ + "merge", + "squash", + "rebase" + ], + "default": "merge", + "description": "%githubPullRequests.defaultMergeMethod.description%" + }, + "githubPullRequests.showInSCM": { + "type": "boolean", + "default": false, + "deprecationMessage": "This setting is deprecated. Views can now be dragged to any location.", + "description": "When true, show GitHub Pull Requests within the SCM viewlet. Otherwise show a separate view container for them." + }, + "githubPullRequests.notifications": { + "type": "string", + "enum": [ + "pullRequests", + "off" + ], + "default": "off", + "description": "%githubPullRequests.notifications.description%" + }, + "githubPullRequests.fileListLayout": { + "type": "string", + "enum": [ + "flat", + "tree" + ], + "default": "tree", + "description": "%githubPullRequests.fileListLayout.description%" + }, + "githubPullRequests.defaultDeletionMethod.selectLocalBranch": { + "type": "boolean", + "default": true, + "description": "%githubPullRequests.defaultDeletionMethod.selectLocalBranch.description%" + }, + "githubPullRequests.defaultDeletionMethod.selectRemote": { + "type": "boolean", + "default": true, + "description": "%githubPullRequests.defaultDeletionMethod.selectRemote.description%" + }, + "githubPullRequests.terminalLinksHandler": { + "type": "string", + "enum": [ + "github", + "vscode", + "ask" + ], + "enumDescriptions": [ + "%githubPullRequests.terminalLinksHandler.github%", + "%githubPullRequests.terminalLinksHandler.vscode%", + "%githubPullRequests.terminalLinksHandler.ask%" + ], + "default": "ask", + "description": "%githubPullRequests.terminalLinksHandler.description%" + }, + "githubPullRequests.createOnPublishBranch": { + "type": "string", + "enum": [ + "never", + "ask" + ], + "enumDescriptions": [ + "%githubPullRequests.createOnPublishBranch.never%", + "%githubPullRequests.createOnPublishBranch.ask%" + ], + "default": "ask", + "description": "%githubPullRequests.createOnPublishBranch.description%" + }, + "githubPullRequests.commentExpandState": { + "type": "string", + "enum": [ + "expandUnresolved", + "collapseAll" + ], + "enumDescriptions": [ + "%githubPullRequests.commentExpandState.expandUnresolved%", + "%githubPullRequests.commentExpandState.collapseAll%" + ], + "default": "expandUnresolved", + "description": "%githubPullRequests.commentExpandState.description%" + }, + "githubPullRequests.useReviewMode": { + "description": "%githubPullRequests.useReviewMode.description%", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "merged": { + "type": "boolean", + "description": "%githubPullRequests.useReviewMode.merged%", + "default": false + }, + "closed": { + "type": "boolean", + "description": "%githubPullRequests.useReviewMode.closed%", + "default": false + } + }, + "required": [ + "merged", + "closed" + ] + }, + { + "type": "string", + "enum": [ + "auto" + ] + } + ], + "default": "auto" + }, + "githubPullRequests.assignCreated": { + "type": "string", + "description": "%githubPullRequests.assignCreated.description%" + }, + "githubPullRequests.pushBranch": { + "type": "string", + "enum": [ + "prompt", + "always" + ], + "default": "prompt", + "enumDescriptions": [ + "%githubPullRequests.pushBranch.prompt%", + "%githubPullRequests.pushBranch.always%" + ], + "description": "%githubPullRequests.pushBranch.description%" + }, + "githubPullRequests.pullBranch": { + "type": "string", + "enum": [ + "prompt", + "never", + "always" + ], + "default": "prompt", + "markdownEnumDescriptions": [ + "%githubPullRequests.pullBranch.prompt%", + "%githubPullRequests.pullBranch.never%", + "%githubPullRequests.pullBranch.always%" + ], + "description": "%githubPullRequests.pullBranch.description%" + }, + "githubPullRequests.allowFetch": { + "type": "boolean", + "default": true, + "description": "%githubPullRequests.allowFetch.description%" + }, + "githubPullRequests.ignoredPullRequestBranches": { + "type": "array", + "default": [], + "items": { + "type": "string", + "description": "%githubPullRequests.ignoredPullRequestBranches.items%" + }, + "description": "%githubPullRequests.ignoredPullRequestBranches.description%" + }, + "githubPullRequests.neverIgnoreDefaultBranch": { + "type": "boolean", + "description": "%githubPullRequests.neverIgnoreDefaultBranch.description%" + }, + "githubPullRequests.overrideDefaultBranch": { + "type": "string", + "description": "%githubPullRequests.overrideDefaultBranch.description%" + }, + "githubPullRequests.postCreate": { + "type": "string", + "enum": [ + "none", + "openOverview", + "checkoutDefaultBranch", + "checkoutDefaultBranchAndShow", + "checkoutDefaultBranchAndCopy" + ], + "description": "%githubPullRequests.postCreate.description%", + "default": "openOverview", + "enumDescriptions": [ + "%githubPullRequests.postCreate.none%", + "%githubPullRequests.postCreate.openOverview%", + "%githubPullRequests.postCreate.checkoutDefaultBranch%", + "%githubPullRequests.postCreate.checkoutDefaultBranchAndShow%", + "%githubPullRequests.postCreate.checkoutDefaultBranchAndCopy%" + ] + }, + "githubPullRequests.defaultCommentType": { + "type": "string", + "enum": [ + "single", + "review" + ], + "default": "single", + "description": "%githubPullRequests.defaultCommentType.description%", + "enumDescriptions": [ + "%githubPullRequests.defaultCommentType.single%", + "%githubPullRequests.defaultCommentType.review%" + ] + }, + "githubPullRequests.quickDiff": { + "type": "boolean", + "description": "Enables quick diff in the editor gutter for checked-out pull requests. Requires a reload to take effect", + "default": false + }, + "githubPullRequests.setAutoMerge": { + "type": "boolean", + "description": "%githubPullRequests.setAutoMerge.description%", + "deprecationMessage": "Use the setting 'githubPullRequests.defaultCreateOption' instead.", + "default": false + }, + "githubPullRequests.pullPullRequestBranchBeforeCheckout": { + "type": "string", + "description": "%githubPullRequests.pullPullRequestBranchBeforeCheckout.description%", + "enum": [ + "never", + "pull", + "pullAndMergeBase", + "pullAndUpdateBase" + ], + "default": "pull", + "enumDescriptions": [ + "%githubPullRequests.pullPullRequestBranchBeforeCheckout.never%", + "%githubPullRequests.pullPullRequestBranchBeforeCheckout.pull%", + "%githubPullRequests.pullPullRequestBranchBeforeCheckout.pullAndMergeBase%", + "%githubPullRequests.pullPullRequestBranchBeforeCheckout.pullAndUpdateBase%" + ] + }, + "githubPullRequests.upstreamRemote": { + "type": "string", + "enum": [ + "add", + "never" + ], + "markdownDescription": "%githubPullRequests.upstreamRemote.description%", + "markdownEnumDescriptions": [ + "%githubPullRequests.upstreamRemote.add%", + "%githubPullRequests.upstreamRemote.never%" + ], + "default": "add" + }, + "githubPullRequests.createDefaultBaseBranch": { + "type": "string", + "enum": ["repositoryDefault", "createdFromBranch"], + "markdownEnumDescriptions": [ + "%githubPullRequests.createDefaultBaseBranch.repositoryDefault%", + "%githubPullRequests.createDefaultBaseBranch.createdFromBranch%" + ], + "default": "createdFromBranch", + "markdownDescription": "%githubPullRequests.createDefaultBaseBranch.description%" + }, + "githubIssues.ignoreMilestones": { + "type": "array", + "default": [], + "description": "%githubIssues.ignoreMilestones.description%" + }, + "githubIssues.createIssueTriggers": { + "type": "array", + "items": { + "type": "string", + "description": "%githubIssues.createIssueTriggers.items%" + }, + "default": [ + "TODO", + "todo", + "BUG", + "FIXME", + "ISSUE", + "HACK" + ], + "description": "%githubIssues.createIssueTriggers.description%" + }, + "githubIssues.createInsertFormat": { + "type": "string", + "enum": [ + "number", + "url" + ], + "default": "number", + "description": "%githubIssues.createInsertFormat.description%" + }, + "githubIssues.issueCompletions.enabled": { + "type": "boolean", + "default": true, + "description": "%githubIssues.issueCompletions.enabled.description%" + }, + "githubIssues.userCompletions.enabled": { + "type": "boolean", + "default": true, + "description": "%githubIssues.userCompletions.enabled.description%" + }, + "githubIssues.ignoreCompletionTrigger": { + "type": "array", + "items": { + "type": "string", + "description": "%githubIssues.ignoreCompletionTrigger.items%" + }, + "default": [ + "coffeescript", + "diff", + "dockerfile", + "dockercompose", + "ignore", + "ini", + "julia", + "makefile", + "perl", + "powershell", + "python", + "r", + "ruby", + "shellscript", + "yaml" + ], + "description": "%githubIssues.ignoreCompletionTrigger.description%" + }, + "githubIssues.ignoreUserCompletionTrigger": { + "type": "array", + "items": { + "type": "string", + "description": "%githubIssues.ignoreUserCompletionTrigger.items%" + }, + "default": [ + "python" + ], + "description": "%githubIssues.ignoreUserCompletionTrigger.description%" + }, + "githubIssues.issueBranchTitle": { + "type": "string", + "default": "${user}/issue${issueNumber}", + "markdownDescription": "%githubIssues.issueBranchTitle.markdownDescription%" + }, + "githubIssues.useBranchForIssues": { + "type": "string", + "enum": [ + "on", + "off", + "prompt" + ], + "enumDescriptions": [ + "%githubIssues.useBranchForIssues.on%", + "%githubIssues.useBranchForIssues.off%", + "%githubIssues.useBranchForIssues.prompt%" + ], + "default": "on", + "markdownDescription": "%githubIssues.useBranchForIssues.markdownDescription%" + }, + "githubIssues.issueCompletionFormatScm": { + "type": "string", + "default": "${issueTitle} ${issueNumberLabel}", + "markdownDescription": "%githubIssues.issueCompletionFormatScm.markdownDescription%" + }, + "githubIssues.workingIssueFormatScm": { + "type": "string", + "default": "${issueTitle} \nFixes ${issueNumberLabel}", + "markdownDescription": "%githubIssues.workingIssueFormatScm.markdownDescription%", + "editPresentation": "multilineText" + }, + "githubIssues.queries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "%githubIssues.queries.label%" + }, + "query": { + "type": "string", + "markdownDescription": "%githubIssues.queries.query%" + }, + "groupBy": { + "type": "array", + "markdownDescription": "%githubIssues.queries.groupBy%", + "items": { + "type": "string", + "enum": [ + "repository", + "milestone" + ], + "enumDescriptions": [ + "%githubIssues.queries.groupBy.milestone%", + "%githubIssues.queries.groupBy.repository%" + ] + } + } + } + }, + "scope": "resource", + "markdownDescription": "%githubIssues.queries.markdownDescription%", + "default": [ + { + "label": "%githubIssues.queries.default.myIssues%", + "query": "is:open assignee:${user} repo:${owner}/${repository}", + "groupBy": ["milestone"] + }, + { + "label": "%githubIssues.queries.default.createdIssues%", + "query": "author:${user} state:open repo:${owner}/${repository} sort:created-desc" + }, + { + "label": "%githubIssues.queries.default.recentIssues%", + "query": "state:open repo:${owner}/${repository} sort:updated-desc" + } + ] + }, + "githubIssues.assignWhenWorking": { + "type": "boolean", + "default": true, + "description": "%githubIssues.assignWhenWorking.description%" + }, + "githubPullRequests.focusedMode": { + "properties": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + }, + "enum": [ + "firstDiff", + "overview", + "multiDiff", + false + ], + "default": "firstDiff", + "description": "%githubPullRequests.focusedMode.description%" + }, + "githubPullRequests.showPullRequestNumberInTree": { + "type": "boolean", + "default": false, + "description": "%githubPullRequests.showPullRequestNumberInTree.description%" + } + } + }, + "viewsContainers": { + "activitybar": [ + { + "id": "github-pull-requests", + "title": "%view.github.pull.requests.name%", + "icon": "$(github)" + }, + { + "id": "github-pull-request", + "title": "%view.github.pull.request.name%", + "icon": "$(git-pull-request)" + } + ] + }, + "views": { + "github-pull-requests": [ + { + "id": "github:login", + "name": "%view.github.login.name%", + "when": "ReposManagerStateContext == NeedsAuthentication", + "icon": "$(git-pull-request)" + }, + { + "id": "pr:github", + "name": "%view.pr.github.name%", + "when": "ReposManagerStateContext != NeedsAuthentication", + "icon": "$(git-pull-request)" + }, + { + "id": "issues:github", + "name": "%view.issues.github.name%", + "when": "ReposManagerStateContext != NeedsAuthentication", + "icon": "$(issues)" + } + ], + "github-pull-request": [ + { + "id": "github:createPullRequestWebview", + "type": "webview", + "name": "%view.github.create.pull.request.name%", + "when": "github:createPullRequest", + "visibility": "visible", + "initialSize": 2 + }, + { + "id": "github:compareChangesFiles", + "name": "%view.github.compare.changes.name%", + "when": "github:createPullRequest", + "visibility": "visible", + "initialSize": 1 + }, + { + "id": "github:compareChangesCommits", + "name": "%view.github.compare.changesCommits.name%", + "when": "github:createPullRequest", + "visibility": "visible", + "initialSize": 1 + }, + { + "id": "prStatus:github", + "name": "%view.pr.status.github.name%", + "when": "github:inReviewMode && !github:createPullRequest", + "icon": "$(git-pull-request)", + "visibility": "visible", + "initialSize": 3 + }, + { + "id": "github:activePullRequest", + "type": "webview", + "name": "%view.github.active.pull.request.name%", + "when": "github:inReviewMode && github:focusedReview && !github:createPullRequest && github:activePRCount <= 1", + "initialSize": 2 + }, + { + "id": "github:activePullRequest:welcome", + "name": "%view.github.active.pull.request.welcome.name%", + "when": "!github:stateValidated && github:focusedReview" + } + ] + }, + "commands": [ + { + "command": "github.api.preloadPullRequest", + "title": "Preload Pull Request", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.create", + "title": "%command.pr.create.title%", + "icon": "$(git-pull-request-create)", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.pushAndCreate", + "title": "%command.pr.create.title%", + "icon": "$(git-pull-request-create)", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.pick", + "title": "%command.pr.pick.title%", + "category": "%command.pull.request.category%", + "icon": "$(arrow-right)" + }, + { + "command": "pr.openChanges", + "title": "%command.pr.openChanges.title%", + "category": "%command.pull.request.category%", + "icon": "$(diff-multiple)" + }, + { + "command": "pr.pickOnVscodeDev", + "title": "%command.pr.pickOnVscodeDev.title%", + "category": "%command.pull.request.category%", + "icon": "$(globe)" + }, + { + "command": "pr.exit", + "title": "%command.pr.exit.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.dismissNotification", + "title": "%command.pr.dismissNotification.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.merge", + "title": "%command.pr.merge.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.readyForReview", + "title": "%command.pr.readyForReview.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.close", + "title": "%command.pr.close.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.openPullRequestOnGitHub", + "title": "%command.pr.openPullRequestOnGitHub.title%", + "category": "%command.pull.request.category%", + "icon": "$(globe)" + }, + { + "command": "pr.openAllDiffs", + "title": "%command.pr.openAllDiffs.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.refreshPullRequest", + "title": "%command.pr.refreshPullRequest.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.openFileOnGitHub", + "title": "%command.pr.openFileOnGitHub.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.copyCommitHash", + "title": "%command.pr.copyCommitHash.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.openOriginalFile", + "title": "%command.pr.openOriginalFile.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.openModifiedFile", + "title": "%command.pr.openModifiedFile.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.openDiffView", + "title": "%command.pr.openDiffView.title%", + "category": "%command.pull.request.category%", + "icon": "$(compare-changes)" + }, + { + "command": "pr.openDiffViewFromEditor", + "title": "%command.pr.openDiffViewFromEditor.title%", + "category": "%command.pull.request.category%", + "icon": "$(git-pull-request)" + }, + { + "command": "pr.openDescription", + "title": "%command.pr.openDescription.title%", + "category": "%command.pull.request.category%", + "when": "github:inReviewMode", + "icon": "$(note)" + }, + { + "command": "pr.openDescriptionToTheSide", + "title": "%command.pr.openDescriptionToTheSide.title%", + "icon": "$(split-horizontal)" + }, + { + "command": "pr.refreshDescription", + "title": "%command.pr.refreshDescription.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.showDiffSinceLastReview", + "title": "%command.pr.showDiffSinceLastReview.title%", + "icon": "$(git-pull-request-new-changes)" + }, + { + "command": "pr.showDiffAll", + "title": "%command.pr.showDiffAll.title%", + "icon": "$(git-pull-request-go-to-changes)" + }, + { + "command": "pr.checkoutByNumber", + "title": "%command.pr.checkoutByNumber.title%", + "category": "%command.pull.request.category%", + "icon": "$(symbol-numeric)" + }, + { + "command": "review.openFile", + "title": "%command.review.openFile.title%", + "icon": "$(go-to-file)" + }, + { + "command": "review.openLocalFile", + "title": "%command.review.openLocalFile.title%", + "icon": "$(go-to-file)" + }, + { + "command": "review.suggestDiff", + "title": "%command.review.suggestDiff.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.refreshList", + "title": "%command.pr.refreshList.title%", + "icon": "$(refresh)", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.setFileListLayoutAsTree", + "title": "%command.pr.setFileListLayoutAsTree.title%", + "icon": "$(list-tree)", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.setFileListLayoutAsFlat", + "title": "%command.pr.setFileListLayoutAsFlat.title%", + "icon": "$(list-flat)", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.refreshChanges", + "title": "%command.pr.refreshChanges.title%", + "icon": "$(refresh)", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.configurePRViewlet", + "title": "%command.pr.configurePRViewlet.title%", + "category": "%command.pull.request.category%", + "icon": "$(gear)" + }, + { + "command": "pr.deleteLocalBranch", + "title": "%command.pr.deleteLocalBranch.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.signin", + "title": "%command.pr.signin.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.signinNoEnterprise", + "title": "%command.pr.signin.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.signinenterprise", + "title": "%command.pr.signinenterprise.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.deleteLocalBranchesNRemotes", + "title": "%command.pr.deleteLocalBranchesNRemotes.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createComment", + "title": "%command.pr.createComment.title%", + "category": "%command.pull.request.category%", + "enablement": "!commentIsEmpty" + }, + { + "command": "pr.createSingleComment", + "title": "%command.pr.createSingleComment.title%", + "category": "%command.pull.request.category%", + "enablement": "!commentIsEmpty" + }, + { + "command": "pr.makeSuggestion", + "title": "%command.pr.makeSuggestion.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.startReview", + "title": "%command.pr.startReview.title%", + "category": "%command.pull.request.category%", + "enablement": "!commentIsEmpty" + }, + { + "command": "pr.editComment", + "title": "%command.pr.editComment.title%", + "category": "%command.pull.request.category%", + "icon": "$(edit)", + "enablement": "!(comment =~ /temporary/)" + }, + { + "command": "pr.cancelEditComment", + "title": "%command.pr.cancelEditComment.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.saveComment", + "title": "%command.pr.saveComment.title%", + "category": "%command.pull.request.category%", + "enablement": "!commentIsEmpty" + }, + { + "command": "pr.deleteComment", + "title": "%command.pr.deleteComment.title%", + "category": "%command.pull.request.category%", + "icon": "$(trash)", + "enablement": "!(comment =~ /temporary/)" + }, + { + "command": "pr.resolveReviewThread", + "title": "%command.pr.resolveReviewThread.title%", + "category": "%command.pull.request.category%", + "icon": "$(check)" + }, + { + "command": "pr.unresolveReviewThread", + "title": "%command.pr.unresolveReviewThread.title%", + "category": "%command.pull.request.category%", + "icon": "$(discard)" + }, + { + "command": "pr.diffOutdatedCommentWithHead", + "title": "%command.pr.diffOutdatedCommentWithHead.title%", + "category": "%command.pull.request.category%", + "icon": "$(git-compare)" + }, + { + "command": "pr.signinAndRefreshList", + "title": "%command.pr.signinAndRefreshList.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.configureRemotes", + "title": "%command.pr.configureRemotes.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.refreshActivePullRequest", + "title": "%command.pr.refreshActivePullRequest.title%", + "category": "%command.pull.request.category%", + "icon": "$(refresh)" + }, + { + "command": "pr.markFileAsViewed", + "title": "%command.pr.markFileAsViewed.title%", + "category": "%command.pull.request.category%", + "icon": "$(pass)" + }, + { + "command": "pr.unmarkFileAsViewed", + "title": "%command.pr.unmarkFileAsViewed.title%", + "category": "%command.pull.request.category%", + "icon": "$(pass-filled)" + }, + { + "command": "pr.openReview", + "title": "%command.pr.openReview.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.collapseAllComments", + "title": "%command.pr.collapseAllComments.title%", + "category": "%command.comments.category%", + "icon": "$(collapse-all)" + }, + { + "command": "pr.editQuery", + "title": "%command.pr.editQuery.title%", + "category": "%command.pull.request.category%", + "icon": "$(edit)" + }, + { + "command": "pr.openPullsWebsite", + "title": "%command.pr.openPullsWebsite.title%", + "category": "%command.pull.request.category%", + "icon": "$(globe)" + }, + { + "command": "pr.resetViewedFiles", + "title": "%command.pr.resetViewedFiles.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.goToNextDiffInPr", + "title": "%command.pr.goToNextDiffInPr.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.goToPreviousDiffInPr", + "title": "%command.pr.goToPreviousDiffInPr.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.copyCommentLink", + "title": "%command.pr.copyCommentLink.title%", + "category": "%command.pull.request.category%", + "icon": "$(copy)", + "enablement": "!(comment =~ /temporary/)" + }, + { + "command": "pr.applySuggestion", + "title": "%command.pr.applySuggestion.title%", + "category": "%command.pull.request.category%", + "icon": "$(gift)" + }, + { + "command": "pr.addAssigneesToNewPr", + "title": "%command.pr.addAssigneesToNewPr.title%", + "category": "%command.pull.request.category%", + "icon": "$(account)" + }, + { + "command": "pr.addReviewersToNewPr", + "title": "%command.pr.addReviewersToNewPr.title%", + "category": "%command.pull.request.category%", + "icon": "$(feedback)" + }, + { + "command": "pr.addLabelsToNewPr", + "title": "%command.pr.addLabelsToNewPr.title%", + "category": "%command.pull.request.category%", + "icon": "$(tag)" + }, + { + "command": "pr.addMilestoneToNewPr", + "title": "%command.pr.addMilestoneToNewPr.title%", + "category": "%command.pull.request.category%", + "icon": "$(milestone)" + }, + { + "command": "pr.addProjectsToNewPr", + "title": "%command.pr.addProjectsToNewPr.title%", + "category": "%command.pull.request.category%", + "icon": "$(github-project)" + }, + { + "command": "pr.addFileComment", + "title": "%command.pr.addFileComment.title%", + "category": "%command.pull.request.category%", + "icon": "$(comment)" + }, + { + "command": "pr.checkoutFromReadonlyFile", + "title": "%command.pr.pick.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.diffWithPrHead", + "title": "%command.review.diffWithPrHead.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.diffLocalWithPrHead", + "title": "%command.review.diffLocalWithPrHead.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.approve", + "title": "%command.review.approve.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.comment", + "title": "%command.review.comment.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.requestChanges", + "title": "%command.review.requestChanges.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.approveOnDotCom", + "title": "%command.review.approveOnDotCom.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.requestChangesOnDotCom", + "title": "%command.review.requestChangesOnDotCom.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.approveDescription", + "title": "%command.review.approve.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.commentDescription", + "title": "%command.review.comment.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.requestChangesDescription", + "title": "%command.review.requestChanges.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.approveOnDotComDescription", + "title": "%command.review.approveOnDotCom.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.requestChangesOnDotComDescription", + "title": "%command.review.requestChangesOnDotCom.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuCreate", + "title": "%command.pr.createPrMenuCreate.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuDraft", + "title": "%command.pr.createPrMenuDraft.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuMergeWhenReady", + "title": "%command.pr.createPrMenuMergeWhenReady.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuMerge", + "title": "%command.pr.createPrMenuMerge.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuSquash", + "title": "%command.pr.createPrMenuSquash.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuRebase", + "title": "%command.pr.createPrMenuRebase.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "issue.createIssueFromSelection", + "title": "%command.issue.createIssueFromSelection.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.createIssueFromClipboard", + "title": "%command.issue.createIssueFromClipboard.title%", + "category": "%command.issues.category%" + }, + { + "command": "pr.copyVscodeDevPrLink", + "title": "%command.pr.copyVscodeDevPrLink.title%", + "category": "%command.issues.category%" + }, + { + "command": "pr.refreshComments", + "title": "%command.pr.refreshComments.title%", + "category": "%command.pull.request.category%", + "icon": "$(refresh)" + }, + { + "command": "issue.copyGithubDevLinkWithoutRange", + "title": "%command.issue.copyGithubDevLink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyGithubDevLinkFile", + "title": "%command.issue.copyGithubDevLink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyGithubDevLink", + "title": "%command.issue.copyGithubDevLink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyGithubPermalink", + "title": "%command.issue.copyGithubPermalink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyGithubHeadLink", + "title": "%command.issue.copyGithubHeadLink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyGithubPermalinkWithoutRange", + "title": "%command.issue.copyGithubPermalink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyGithubHeadLinkWithoutRange", + "title": "%command.issue.copyGithubHeadLink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyMarkdownGithubPermalink", + "title": "%command.issue.copyMarkdownGithubPermalink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyMarkdownGithubPermalinkWithoutRange", + "title": "%command.issue.copyMarkdownGithubPermalink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.openGithubPermalink", + "title": "%command.issue.openGithubPermalink.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.openIssue", + "title": "%command.issue.openIssue.title%", + "category": "%command.issues.category%", + "icon": "$(globe)" + }, + { + "command": "issue.copyIssueNumber", + "title": "%command.issue.copyIssueNumber.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.copyIssueUrl", + "title": "%command.issue.copyIssueUrl.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.refresh", + "title": "%command.issue.refresh.title%", + "category": "%command.issues.category%", + "icon": "$(refresh)" + }, + { + "command": "issue.suggestRefresh", + "title": "%command.issue.suggestRefresh.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.startWorking", + "title": "%command.issue.startWorking.title%", + "category": "%command.issues.category%", + "icon": "$(arrow-right)" + }, + { + "command": "issue.startWorkingBranchDescriptiveTitle", + "title": "%command.issue.startWorkingBranchDescriptiveTitle.title%", + "category": "%command.issues.category%", + "icon": "$(arrow-right)" + }, + { + "command": "issue.continueWorking", + "title": "%command.issue.continueWorking.title%", + "category": "%command.issues.category%", + "icon": "$(arrow-right)" + }, + { + "command": "issue.startWorkingBranchPrompt", + "title": "%command.issue.startWorkingBranchPrompt.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.stopWorking", + "title": "%command.issue.stopWorking.title%", + "category": "%command.issues.category%", + "icon": "$(primitive-square)" + }, + { + "command": "issue.stopWorkingBranchDescriptiveTitle", + "title": "%command.issue.stopWorkingBranchDescriptiveTitle.title%", + "category": "%command.issues.category%", + "icon": "$(primitive-square)" + }, + { + "command": "issue.statusBar", + "title": "%command.issue.statusBar.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.getCurrent", + "title": "%command.issue.getCurrent.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.editQuery", + "title": "%command.issue.editQuery.title%", + "category": "%command.issues.category%", + "icon": "$(edit)" + }, + { + "command": "issue.createIssue", + "title": "%command.issue.createIssue.title%", + "category": "%command.issues.category%", + "icon": "$(plus)" + }, + { + "command": "issue.createIssueFromFile", + "title": "%command.issue.createIssueFromFile.title%", + "icon": "$(check)", + "enablement": "!issues.creatingFromFile" + }, + { + "command": "issue.issueCompletion", + "title": "%command.issue.issueCompletion.title%" + }, + { + "command": "issue.userCompletion", + "title": "%command.issue.userCompletion.title%" + }, + { + "command": "issue.signinAndRefreshList", + "title": "%command.issue.signinAndRefreshList.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.goToLinkedCode", + "title": "%command.issue.goToLinkedCode.title%", + "category": "%command.issues.category%" + }, + { + "command": "issues.openIssuesWebsite", + "title": "%command.issues.openIssuesWebsite.title%", + "category": "%command.pull.request.category%", + "icon": "$(globe)" + } + ], + "viewsWelcome": [ + { + "view": "github:login", + "when": "ReposManagerStateContext == NeedsAuthentication && github:hasGitHubRemotes", + "contents": "%welcome.github.login.contents%" + }, + { + "view": "pr:github", + "when": "gitNotInstalled", + "contents": "%welcome.github.noGit.contents%" + }, + { + "view": "github:login", + "when": "ReposManagerStateContext == NeedsAuthentication && !github:hasGitHubRemotes && gitOpenRepositoryCount", + "contents": "%welcome.github.loginNoEnterprise.contents%" + }, + { + "view": "github:login", + "when": "ReposManagerStateContext == NeedsAuthentication && !github:hasGitHubRemotes && gitOpenRepositoryCount", + "contents": "%welcome.github.loginWithEnterprise.contents%" + }, + { + "view": "pr:github", + "when": "git.state != initialized && !github:initialized && workspaceFolderCount > 0", + "contents": "%welcome.pr.github.uninitialized.contents%" + }, + { + "view": "pr:github", + "when": "workspaceFolderCount > 0 && github:loadingPrsTree", + "contents": "%welcome.pr.github.uninitialized.contents%" + }, + { + "view": "pr:github", + "when": "workspaceFolderCount == 0", + "contents": "%welcome.pr.github.noFolder.contents%" + }, + { + "view": "pr:github", + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount == 0", + "contents": "%welcome.pr.github.noRepo.contents%" + }, + { + "view": "pr:github", + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount == 1", + "contents": "%welcome.pr.github.parentRepo.contents%" + }, + { + "view": "pr:github", + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount > 1", + "contents": "%welcome.pr.github.parentRepo.contents%" + }, + { + "view": "issues:github", + "when": "git.state != initialized && !github:initialized && workspaceFolderCount > 0", + "contents": "%welcome.issues.github.uninitialized.contents%" + }, + { + "view": "issues:github", + "when": "workspaceFolderCount > 0 && github:loadingPrsTree", + "contents": "%welcome.issues.github.uninitialized.contents%" + }, + { + "view": "issues:github", + "when": "workspaceFolderCount == 0", + "contents": "%welcome.issues.github.noFolder.contents%" + }, + { + "view": "issues:github", + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount == 0", + "contents": "%welcome.issues.github.noRepo.contents%" + }, + { + "view": "issues:github", + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount == 1", + "contents": "%welcome.pr.github.parentRepo.contents%" + }, + { + "view": "issues:github", + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount > 1", + "contents": "%welcome.pr.github.parentRepo.contents%" + }, + { + "view": "github:activePullRequest:welcome", + "when": "!github:stateValidated", + "contents": "%welcome.github.activePullRequest.contents%" + } + ], + "keybindings": [ + { + "key": "ctrl+shift+space", + "command": "issue.suggestRefresh", + "when": "suggestWidgetVisible" + }, + { + "key": "ctrl+s", + "mac": "cmd+s", + "command": "issue.createIssueFromFile", + "when": "resourceScheme == newIssue && config.files.autoSave != off" + }, + { + "key": "ctrl+enter", + "mac": "cmd+enter", + "command": "issue.createIssueFromFile", + "when": "resourceScheme == newIssue" + }, + { + "key": "ctrl+k m", + "mac": "cmd+k m", + "command": "pr.makeSuggestion", + "when": "commentEditorFocused" + } + ], + "menus": { + "commandPalette": [ + { + "command": "github.api.preloadPullRequest", + "when": "false" + }, + { + "command": "pr.configureRemotes", + "when": "gitHubOpenRepositoryCount != 0" + }, + { + "command": "pr.configurePRViewlet", + "when": "gitHubOpenRepositoryCount != 0" + }, + { + "command": "pr.pick", + "when": "false" + }, + { + "command": "pr.openChanges", + "when": "false" + }, + { + "command": "pr.pickOnVscodeDev", + "when": "false" + }, + { + "command": "pr.exit", + "when": "github:inReviewMode" + }, + { + "command": "pr.dismissNotification", + "when": "false" + }, + { + "command": "pr.resetViewedFiles", + "when": "github:inReviewMode" + }, + { + "command": "review.openFile", + "when": "false" + }, + { + "command": "review.openLocalFile", + "when": "false" + }, + { + "command": "pr.close", + "when": "gitHubOpenRepositoryCount != 0 && github:inReviewMode" + }, + { + "command": "pr.create", + "when": "gitHubOpenRepositoryCount != 0 && github:authenticated" + }, + { + "command": "pr.pushAndCreate", + "when": "false" + }, + { + "command": "pr.merge", + "when": "gitHubOpenRepositoryCount != 0 && github:inReviewMode" + }, + { + "command": "pr.readyForReview", + "when": "gitHubOpenRepositoryCount != 0 && github:inReviewMode" + }, + { + "command": "pr.openPullRequestOnGitHub", + "when": "gitHubOpenRepositoryCount != 0 && github:inReviewMode" + }, + { + "command": "pr.openAllDiffs", + "when": "gitHubOpenRepositoryCount != 0 && github:inReviewMode" + }, + { + "command": "pr.refreshDescription", + "when": "gitHubOpenRepositoryCount != 0 && github:inReviewMode" + }, + { + "command": "pr.openFileOnGitHub", + "when": "false" + }, + { + "command": "pr.openOriginalFile", + "when": "false" + }, + { + "command": "pr.openModifiedFile", + "when": "false" + }, + { + "command": "pr.refreshPullRequest", + "when": "false" + }, + { + "command": "pr.deleteLocalBranch", + "when": "false" + }, + { + "command": "pr.openDiffView", + "when": "false" + }, + { + "command": "pr.openDiffViewFromEditor", + "when": "false" + }, + { + "command": "pr.openDescriptionToTheSide", + "when": "false" + }, + { + "command": "pr.openDescription", + "when": "gitHubOpenRepositoryCount != 0 && github:inReviewMode" + }, + { + "command": "pr.showDiffSinceLastReview", + "when": "false" + }, + { + "command": "pr.showDiffAll", + "when": "false" + }, + { + "command": "review.suggestDiff", + "when": "false" + }, + { + "command": "review.approve", + "when": "false" + }, + { + "command": "review.comment", + "when": "false" + }, + { + "command": "review.requestChanges", + "when": "false" + }, + { + "command": "review.approveOnDotCom", + "when": "false" + }, + { + "command": "review.requestChangesOnDotCom", + "when": "false" + }, + { + "command": "review.approveDescription", + "when": "false" + }, + { + "command": "review.commentDescription", + "when": "false" + }, + { + "command": "review.requestChangesDescription", + "when": "false" + }, + { + "command": "review.approveOnDotComDescription", + "when": "false" + }, + { + "command": "review.requestChangesOnDotComDescription", + "when": "false" + }, + { + "command": "pr.refreshList", + "when": "gitHubOpenRepositoryCount != 0 && github:authenticated && github:hasGitHubRemotes" + }, + { + "command": "pr.setFileListLayoutAsTree", + "when": "false" + }, + { + "command": "pr.setFileListLayoutAsFlat", + "when": "false" + }, + { + "command": "pr.refreshChanges", + "when": "false" + }, + { + "command": "pr.signin", + "when": "gitHubOpenRepositoryCount != 0 && github:hasGitHubRemotes" + }, + { + "command": "pr.signinNoEnterprise", + "when": "false" + }, + { + "command": "pr.signinenterprise", + "when": "gitHubOpenRepositoryCount != 0 && github:hasGitHubRemotes" + }, + { + "command": "pr.signinAndRefreshList", + "when": "false" + }, + { + "command": "pr.copyCommitHash", + "when": "false" + }, + { + "command": "pr.createComment", + "when": "false" + }, + { + "command": "pr.createSingleComment", + "when": "false" + }, + { + "command": "pr.makeSuggestion", + "when": "false" + }, + { + "command": "pr.startReview", + "when": "false" + }, + { + "command": "pr.editComment", + "when": "false" + }, + { + "command": "pr.cancelEditComment", + "when": "false" + }, + { + "command": "pr.saveComment", + "when": "false" + }, + { + "command": "pr.deleteComment", + "when": "false" + }, + { + "command": "pr.openReview", + "when": "false" + }, + { + "command": "pr.editQuery", + "when": "false" + }, + { + "command": "pr.markFileAsViewed", + "when": "false" + }, + { + "command": "pr.unmarkFileAsViewed", + "when": "false" + }, + { + "command": "pr.checkoutByNumber", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && github:authenticated" + }, + { + "command": "pr.collapseAllComments", + "when": "false" + }, + { + "command": "pr.copyVscodeDevPrLink", + "when": "github:inReviewMode && remoteName != codespaces && embedderIdentifier != github.dev" + }, + { + "command": "pr.goToNextDiffInPr", + "when": "activeEditor == workbench.editors.textDiffEditor && resourcePath in github:unviewedFiles" + }, + { + "command": "pr.goToNextDiffInPr", + "when": "activeEditor == workbench.editors.textDiffEditor && resourcePath in github:viewedFiles" + }, + { + "command": "pr.goToPreviousDiffInPr", + "when": "activeEditor == workbench.editors.textDiffEditor && resourcePath in github:unviewedFiles" + }, + { + "command": "pr.goToPreviousDiffInPr", + "when": "activeEditor == workbench.editors.textDiffEditor && resourcePath in github:viewedFiles" + }, + { + "command": "pr.copyCommentLink", + "when": "false" + }, + { + "command": "pr.addAssigneesToNewPr", + "when": "false" + }, + { + "command": "pr.addReviewersToNewPr", + "when": "false" + }, + { + "command": "pr.addLabelsToNewPr", + "when": "false" + }, + { + "command": "pr.addMilestoneToNewPr", + "when": "false" + }, + { + "command": "pr.addProjectsToNewPr", + "when": "false" + }, + { + "command": "pr.addFileComment", + "when": "false" + }, + { + "command": "review.diffWithPrHead", + "when": "false" + }, + { + "command": "review.diffLocalWithPrHead", + "when": "false" + }, + { + "command": "pr.createPrMenuCreate", + "when": "false" + }, + { + "command": "pr.createPrMenuDraft", + "when": "false" + }, + { + "command": "pr.createPrMenuMergeWhenReady", + "when": "false" + }, + { + "command": "pr.createPrMenuMerge", + "when": "false" + }, + { + "command": "pr.createPrMenuSquash", + "when": "false" + }, + { + "command": "pr.createPrMenuRebase", + "when": "false" + }, + { + "command": "pr.refreshComments", + "when": "gitHubOpenRepositoryCount != 0" + }, + { + "command": "issue.copyGithubPermalink", + "when": "github:hasGitHubRemotes" + }, + { + "command": "issue.copyGithubHeadLink", + "when": "github:hasGitHubRemotes" + }, + { + "command": "issue.copyMarkdownGithubPermalink", + "when": "github:hasGitHubRemotes" + }, + { + "command": "issue.openGithubPermalink", + "when": "github:hasGitHubRemotes" + }, + { + "command": "issue.openIssue", + "when": "false" + }, + { + "command": "issue.copyIssueNumber", + "when": "false" + }, + { + "command": "issue.copyIssueUrl", + "when": "false" + }, + { + "command": "issue.refresh", + "when": "false" + }, + { + "command": "issue.suggestRefresh", + "when": "false" + }, + { + "command": "issue.startWorking", + "when": "false" + }, + { + "command": "issue.startWorkingBranchDescriptiveTitle", + "when": "false" + }, + { + "command": "issue.continueWorking", + "when": "false" + }, + { + "command": "issue.startWorkingBranchPrompt", + "when": "false" + }, + { + "command": "issue.stopWorking", + "when": "false" + }, + { + "command": "issue.stopWorkingBranchDescriptiveTitle", + "when": "false" + }, + { + "command": "issue.statusBar", + "when": "false" + }, + { + "command": "issue.getCurrent", + "when": "false" + }, + { + "command": "issue.editQuery", + "when": "false" + }, + { + "command": "issue.createIssue", + "when": "github:hasGitHubRemotes && github:authenticated" + }, + { + "command": "issue.createIssueFromFile", + "when": "false" + }, + { + "command": "issue.issueCompletion", + "when": "false" + }, + { + "command": "issue.userCompletion", + "when": "false" + }, + { + "command": "issue.signinAndRefreshList", + "when": "false" + }, + { + "command": "issue.goToLinkedCode", + "when": "false" + }, + { + "command": "issue.copyGithubDevLinkWithoutRange", + "when": "false" + }, + { + "command": "issue.copyGithubDevLinkFile", + "when": "false" + }, + { + "command": "issue.copyGithubDevLink", + "when": "false" + }, + { + "command": "issue.copyGithubPermalinkWithoutRange", + "when": "false" + }, + { + "command": "issue.copyMarkdownGithubPermalinkWithoutRange", + "when": "false" + }, + { + "command": "issue.copyGithubHeadLinkWithoutRange", + "when": "false" + }, + { + "command": "pr.refreshActivePullRequest", + "when": "false" + }, + { + "command": "pr.openPullsWebsite", + "when": "github:hasGitHubRemotes" + }, + { + "command": "issues.openIssuesWebsite", + "when": "github:hasGitHubRemotes" + } + ], + "view/title": [ + { + "command": "pr.create", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == pr:github", + "group": "navigation@1" + }, + { + "command": "pr.refreshList", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == pr:github", + "group": "navigation@2" + }, + { + "command": "pr.openPullsWebsite", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == pr:github", + "group": "overflow@1" + }, + { + "command": "pr.checkoutByNumber", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == pr:github", + "group": "overflow@2" + }, + { + "command": "pr.configurePRViewlet", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == pr:github", + "group": "overflow@3" + }, + { + "command": "pr.refreshChanges", + "when": "view == prStatus:github", + "group": "navigation@2" + }, + { + "command": "pr.setFileListLayoutAsTree", + "when": "view == prStatus:github && fileListLayout:flat", + "group": "navigation" + }, + { + "command": "pr.setFileListLayoutAsFlat", + "when": "view == prStatus:github && !fileListLayout:flat", + "group": "navigation" + }, + { + "command": "issue.createIssue", + "when": "view == issues:github && github:hasGitHubRemotes", + "group": "navigation@1" + }, + { + "command": "issue.refresh", + "when": "view == issues:github", + "group": "navigation@2" + }, + { + "command": "issues.openIssuesWebsite", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == issues:github", + "group": "overflow@1" + }, + { + "command": "pr.configurePRViewlet", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == issues:github", + "group": "overflow@2" + }, + { + "command": "pr.refreshActivePullRequest", + "when": "view == github:activePullRequest && github:hasGitHubRemotes", + "group": "navigation@1" + }, + { + "command": "pr.openDescription", + "when": "view == github:activePullRequest && github:hasGitHubRemotes", + "group": "navigation@2" + }, + { + "command": "pr.openPullRequestOnGitHub", + "when": "view == github:activePullRequest && github:hasGitHubRemotes", + "group": "navigation@3" + }, + { + "command": "pr.addAssigneesToNewPr", + "when": "view == github:createPullRequestWebview && github:createPrPermissions != READ && github:createPrPermissions", + "group": "navigation@1" + }, + { + "command": "pr.addReviewersToNewPr", + "when": "view == github:createPullRequestWebview && github:createPrPermissions != READ && github:createPrPermissions", + "group": "navigation@2" + }, + { + "command": "pr.addLabelsToNewPr", + "when": "view == github:createPullRequestWebview && github:createPrPermissions != READ && github:createPrPermissions", + "group": "navigation@3" + }, + { + "command": "pr.addMilestoneToNewPr", + "when": "view == github:createPullRequestWebview && github:createPrPermissions != READ && github:createPrPermissions", + "group": "navigation@4" + }, + { + "command": "pr.addProjectsToNewPr", + "when": "view == github:createPullRequestWebview && github:createPrPermissions != READ && github:createPrPermissions", + "group": "navigation@5" + }, + { + "command": "pr.refreshComments", + "when": "view == workbench.panel.comments", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "pr.pick", + "when": "view == pr:github && viewItem =~ /(pullrequest(:local)?:nonactive)|(description:nonactive)/", + "group": "1_pullrequest@1" + }, + { + "command": "pr.pick", + "when": "view == pr:github && viewItem =~ /description:nonactive/", + "group": "inline@0" + }, + { + "command": "pr.openChanges", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /description/ && config.multiDiffEditor.experimental.enabled", + "group": "inline@1" + }, + { + "command": "pr.showDiffSinceLastReview", + "group": "inline@1", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /description:(active|nonactive):hasChangesSinceReview:showingAllChanges/" + }, + { + "command": "pr.showDiffAll", + "group": "inline@1", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /description:(active|nonactive):hasChangesSinceReview:showingChangesSinceReview/" + }, + { + "command": "pr.openDescriptionToTheSide", + "group": "inline@2", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /description/" + }, + { + "command": "pr.exit", + "when": "view == pr:github && viewItem =~ /pullrequest(:local)?:active|description:active/", + "group": "1_pullrequest@1" + }, + { + "command": "pr.pickOnVscodeDev", + "when": "view == pr:github && viewItem =~ /pullrequest(:local)?:nonactive|description/ && (!isWeb || remoteName != codespaces && virtualWorkspace != vscode-vfs)", + "group": "1_pullrequest@2" + }, + { + "command": "pr.refreshPullRequest", + "when": "view == pr:github && viewItem =~ /pullrequest|description/", + "group": "pullrequest@1" + }, + { + "command": "pr.openPullRequestOnGitHub", + "when": "view == pr:github && viewItem =~ /pullrequest|description/", + "group": "1_pullrequest@3" + }, + { + "command": "pr.deleteLocalBranch", + "when": "view == pr:github && viewItem =~ /pullrequest:local:nonactive/", + "group": "pullrequest@4" + }, + { + "command": "pr.dismissNotification", + "when": "view == pr:github && viewItem =~ /pullrequest(.*):notification/", + "group": "pullrequest@5" + }, + { + "command": "pr.copyCommitHash", + "when": "view == prStatus:github && viewItem =~ /commit/" + }, + { + "command": "review.openFile", + "group": "inline@0", + "when": "openDiffOnClick && view == prStatus:github && viewItem =~ /filechange(?!:DELETE)/" + }, + { + "command": "pr.openDiffView", + "group": "inline@0", + "when": "!openDiffOnClick && view == prStatus:github && viewItem =~ /filechange(?!:DELETE)/" + }, + { + "command": "pr.openFileOnGitHub", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /filechange/", + "group": "0_open@0" + }, + { + "command": "pr.openOriginalFile", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /filechange:MODIFY/", + "group": "0_open@1" + }, + { + "command": "pr.openModifiedFile", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /filechange:MODIFY/", + "group": "0_open@2" + }, + { + "command": "review.diffWithPrHead", + "group": "1_diff@0", + "when": "openDiffOnClick && view == prStatus:github && viewItem =~ /filechange(?!:DELETE)/" + }, + { + "command": "review.diffLocalWithPrHead", + "group": "1_diff@1", + "when": "openDiffOnClick && view == prStatus:github && viewItem =~ /filechange(?!:DELETE)/" + }, + { + "command": "pr.editQuery", + "when": "view == pr:github && viewItem == query", + "group": "inline" + }, + { + "command": "pr.editQuery", + "when": "view == pr:github && viewItem == query" + }, + { + "command": "issue.openIssue", + "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/", + "group": "inline@2" + }, + { + "command": "issue.openIssue", + "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/", + "group": "issues_0@1" + }, + { + "command": "issue.goToLinkedCode", + "when": "view == issues:github && viewItem =~ /^link(current|continue)?issue/", + "group": "issues_0@0" + }, + { + "command": "issue.startWorking", + "when": "view == issues:github && viewItem =~ /^(link)?issue/ && config.githubIssues.useBranchForIssues != on", + "group": "inline@1" + }, + { + "command": "issue.startWorkingBranchDescriptiveTitle", + "when": "view == issues:github && viewItem =~ /^(link)?issue/ && config.githubIssues.useBranchForIssues == on", + "group": "inline@1" + }, + { + "command": "issue.startWorking", + "when": "view == issues:github && viewItem =~ /^(link)?continueissue/ && config.githubIssues.useBranchForIssues != on", + "group": "inline@1" + }, + { + "command": "issue.startWorkingBranchDescriptiveTitle", + "when": "view == issues:github && viewItem =~ /^(link)?continueissue/ && config.githubIssues.useBranchForIssues == on", + "group": "inline@1" + }, + { + "command": "issue.startWorking", + "alt": "issue.startWorkingBranchPrompt", + "when": "view == issues:github && viewItem =~ /^(link)?issue/", + "group": "issues_0@2" + }, + { + "command": "issue.continueWorking", + "when": "view == issues:github && viewItem =~ /^(link)?continueissue/", + "group": "issues_0@2" + }, + { + "command": "pr.create", + "when": "view == issues:github && viewItem =~ /^(link)?currentissue/", + "group": "issues_0@2" + }, + { + "command": "issue.stopWorking", + "when": "view == issues:github && viewItem =~ /^(link)?currentissue/", + "group": "issues_0@3" + }, + { + "command": "issue.stopWorking", + "when": "view == issues:github && viewItem =~ /^(link)?currentissue/ && config.githubIssues.useBranchForIssues != on", + "group": "inline@1" + }, + { + "command": "issue.stopWorkingBranchDescriptiveTitle", + "when": "view == issues:github && viewItem =~ /^(link)?currentissue/ && config.githubIssues.useBranchForIssues == on", + "group": "inline@1" + }, + { + "command": "issue.copyIssueNumber", + "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/", + "group": "issues_1@1" + }, + { + "command": "issue.copyIssueUrl", + "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/", + "group": "issues_1@2" + }, + { + "command": "issue.editQuery", + "when": "view == issues:github && viewItem == query", + "group": "inline" + }, + { + "command": "issue.editQuery", + "when": "view == issues:github && viewItem == query" + } + ], + "commentsView/commentThread/context": [ + { + "command": "pr.diffOutdatedCommentWithHead", + "group": "inline@0", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /outdated/" + }, + { + "command": "pr.resolveReviewThread", + "group": "inline@1", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canResolve/" + }, + { + "command": "pr.unresolveReviewThread", + "group": "inline@1", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canUnresolve/" + }, + { + "command": "pr.diffOutdatedCommentWithHead", + "group": "context@0", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /outdated/" + }, + { + "command": "pr.resolveReviewThread", + "group": "context@1", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canResolve/" + }, + { + "command": "pr.unresolveReviewThread", + "group": "context@1", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canUnresolve/" + } + ], + "editor/title": [ + { + "command": "review.openFile", + "group": "navigation", + "when": "resourceScheme =~ /^review$/ && isInDiffEditor" + }, + { + "command": "review.openLocalFile", + "group": "navigation", + "when": "resourceScheme =~ /^review$/ && !isInDiffEditor" + }, + { + "command": "issue.createIssueFromFile", + "group": "navigation", + "when": "resourceFilename == NewIssue.md" + }, + { + "command": "pr.markFileAsViewed", + "group": "navigation", + "when": "resourceScheme != pr && resourceScheme != review && resourceScheme != filechange && resourcePath in github:unviewedFiles" + }, + { + "command": "pr.unmarkFileAsViewed", + "group": "navigation", + "when": "resourceScheme != pr && resourceScheme != review && resourceScheme != filechange && resourcePath in github:viewedFiles" + }, + { + "command": "pr.openDiffViewFromEditor", + "group": "navigation", + "when": "!isInDiffEditor && resourceScheme != pr && resourceScheme != review && resourceScheme != filechange && resourcePath in github:unviewedFiles" + }, + { + "command": "pr.openDiffViewFromEditor", + "group": "navigation", + "when": "!isInDiffEditor && resourceScheme != pr && resourceScheme != review && resourceScheme != filechange && resourcePath in github:viewedFiles" + }, + { + "command": "pr.addFileComment", + "group": "navigation", + "when": "(resourceScheme == pr) || (resourcePath in github:viewedFiles) || (resourcePath in github:unviewedFiles)" + } + ], + "scm/title": [ + { + "command": "review.suggestDiff", + "when": "scmProvider =~ /^git|^remoteHub:github/ && scmProviderRootUri in github:reposInReviewMode", + "group": "inline" + }, + { + "command": "pr.create", + "when": "scmProvider =~ /^git|^remoteHub:github/ && scmProviderRootUri in github:reposNotInReviewMode", + "group": "navigation" + } + ], + "comments/commentThread/context": [ + { + "command": "pr.createComment", + "group": "inline@1", + "when": "(commentController =~ /^github-browse/ && prInDraft) || (commentController =~ /^github-review/ && reviewInDraftMode)" + }, + { + "command": "pr.createSingleComment", + "group": "inline@1", + "when": "config.githubPullRequests.defaultCommentType != review && ((commentController =~ /^github-browse/ && !prInDraft) || (commentController =~ /^github-review/ && !reviewInDraftMode))" + }, + { + "command": "pr.startReview", + "group": "inline@1", + "when": "config.githubPullRequests.defaultCommentType == review && ((commentController =~ /^github-browse/ && !prInDraft) || (commentController =~ /^github-review/ && !reviewInDraftMode))" + }, + { + "command": "pr.startReview", + "group": "inline@2", + "when": "config.githubPullRequests.defaultCommentType != review && ((commentController =~ /^github-browse/ && !prInDraft) || (commentController =~ /^github-review/ && !reviewInDraftMode))" + }, + { + "command": "pr.createSingleComment", + "group": "inline@2", + "when": "config.githubPullRequests.defaultCommentType == review && ((commentController =~ /^github-browse/ && !prInDraft) || commentController =~ /^github-review/ && !reviewInDraftMode)" + } + ], + "comments/comment/editorActions": [ + { + "command": "pr.makeSuggestion", + "group": "inline@3", + "when": "commentController =~ /^github-(browse|review)/" + } + ], + "comments/commentThread/additionalActions": [ + { + "command": "pr.resolveReviewThread", + "group": "inline@1", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canResolve/" + }, + { + "command": "pr.unresolveReviewThread", + "group": "inline@1", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canUnresolve/" + }, + { + "command": "pr.openReview", + "group": "inline@2", + "when": "(commentController =~ /^github-browse/ && prInDraft) || (commentController =~ /^github-review/ && reviewInDraftMode)" + } + ], + "comments/commentThread/title/context": [ + { + "command": "pr.resolveReviewThread", + "group": "inline@3", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canResolve/" + }, + { + "command": "pr.unresolveReviewThread", + "group": "inline@3", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canUnresolve/" + } + ], + "comments/commentThread/comment/context": [ + { + "command": "pr.resolveReviewThread", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canResolve/" + }, + { + "command": "pr.unresolveReviewThread", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canUnresolve/" + }, + { + "command": "pr.applySuggestion", + "when": "commentController =~ /^github-review/ && comment =~ /hasSuggestion/" + } + ], + "comments/comment/title": [ + { + "command": "pr.copyCommentLink", + "group": "inline@1", + "when": "commentController =~ /^github-(browse|review)/ && comment =~ /canEdit/" + }, + { + "command": "pr.applySuggestion", + "group": "inline@0", + "when": "commentController =~ /^github-review/ && comment =~ /hasSuggestion/" + }, + { + "command": "pr.editComment", + "group": "inline@2", + "when": "commentController =~ /^github-(browse|review)/ && comment =~ /canEdit/" + }, + { + "command": "pr.deleteComment", + "group": "inline@3", + "when": "commentController =~ /^github-(browse|review)/ && comment =~ /canDelete/" + } + ], + "comments/commentThread/title": [ + { + "command": "pr.refreshComments", + "group": "0_refresh@0", + "when": "commentController =~ /^github-(browse|review)/" + }, + { + "command": "pr.collapseAllComments", + "group": "1_collapse@0", + "when": "commentController =~ /^github-(browse|review)/" + } + ], + "comments/comment/context": [ + { + "command": "pr.saveComment", + "group": "inline@1", + "when": "commentController =~ /^github-(browse|review)/" + }, + { + "command": "pr.cancelEditComment", + "group": "inline@2", + "when": "commentController =~ /^github-(browse|review)/" + } + ], + "editor/context/copy": [ + { + "command": "issue.copyGithubPermalink", + "when": "github:hasGitHubRemotes", + "group": "3_githubPullRequests@0" + }, + { + "command": "issue.copyMarkdownGithubPermalink", + "when": "github:hasGitHubRemotes", + "group": "3_githubPullRequests@1" + }, + { + "command": "issue.copyGithubHeadLink", + "when": "github:hasGitHubRemotes", + "group": "3_githubPullRequests@2" + } + ], + "editor/context/share": [ + { + "command": "issue.copyGithubPermalink", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@0" + }, + { + "command": "issue.copyMarkdownGithubPermalink", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@1" + }, + { + "command": "issue.copyGithubHeadLink", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@2" + }, + { + "command": "issue.copyGithubDevLink", + "when": "github:hasGitHubRemotes && remoteName == codespaces && isWeb || github:hasGitHubRemotes && embedderIdentifier == github.dev", + "group": "0_vscode@0" + } + ], + "file/share": [ + { + "command": "issue.copyGithubPermalink", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@0" + }, + { + "command": "pr.copyVscodeDevPrLink", + "when": "github:hasGitHubRemotes && github:inReviewMode && remoteName != codespaces && embedderIdentifier != github.dev", + "group": "1_githubPullRequests@1" + }, + { + "command": "issue.copyMarkdownGithubPermalink", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@2" + }, + { + "command": "issue.copyGithubHeadLink", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@3" + }, + { + "command": "issue.copyGithubDevLinkFile", + "when": "github:hasGitHubRemotes && remoteName == codespaces && isWeb || github:hasGitHubRemotes && embedderIdentifier == github.dev", + "group": "0_vscode@0" + } + ], + "editor/lineNumber/context": [ + { + "command": "issue.copyGithubPermalink", + "when": "github:hasGitHubRemotes && activeEditor == workbench.editors.files.textFileEditor && config.editor.lineNumbers == on", + "group": "1_cutcopypaste@3" + }, + { + "command": "issue.copyMarkdownGithubPermalink", + "when": "github:hasGitHubRemotes && activeEditor == workbench.editors.files.textFileEditor && config.editor.lineNumbers == on", + "group": "1_cutcopypaste@4" + }, + { + "command": "issue.copyGithubHeadLink", + "when": "github:hasGitHubRemotes && activeEditor == workbench.editors.files.textFileEditor && config.editor.lineNumbers == on", + "group": "1_cutcopypaste@5" + }, + { + "command": "issue.copyGithubDevLink", + "when": "github:hasGitHubRemotes && remoteName == codespaces && isWeb || github:hasGitHubRemotes && embedderIdentifier == github.dev", + "group": "1_cutcopypaste@0" + } + ], + "editor/title/context/share": [ + { + "command": "issue.copyGithubPermalinkWithoutRange", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@10" + }, + { + "command": "issue.copyMarkdownGithubPermalinkWithoutRange", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@11" + }, + { + "command": "issue.copyGithubHeadLinkWithoutRange", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@12" + }, + { + "command": "issue.copyGithubDevLinkWithoutRange", + "when": "github:hasGitHubRemotes && remoteName == codespaces && isWeb || github:hasGitHubRemotes && embedderIdentifier == github.dev", + "group": "0_vscode@0" + } + ], + "explorer/context/share": [ + { + "command": "issue.copyGithubPermalinkWithoutRange", + "when": "github:hasGitHubRemotes", + "group": "5_githubPulLRequests@10" + }, + { + "command": "issue.copyMarkdownGithubPermalinkWithoutRange", + "when": "github:hasGitHubRemotes", + "group": "5_githubPulLRequests@11" + }, + { + "command": "issue.copyGithubHeadLinkWithoutRange", + "when": "github:hasGitHubRemotes", + "group": "5_githubPulLRequests@12" + }, + { + "command": "issue.copyGithubDevLinkWithoutRange", + "when": "github:hasGitHubRemotes && remoteName == codespaces && isWeb || github:hasGitHubRemotes && embedderIdentifier == github.dev", + "group": "0_vscode@0" + } + ], + "menuBar/edit/copy": [ + { + "command": "issue.copyGithubPermalink", + "when": "github:hasGitHubRemotes" + }, + { + "command": "issue.copyMarkdownGithubPermalink", + "when": "github:hasGitHubRemotes" + } + ], + "remoteHub/pullRequest": [ + { + "command": "pr.create", + "when": "scmProvider =~ /^remoteHub:github/", + "group": "1_modification@0" + } + ], + "webview/context": [ + { + "command": "pr.createPrMenuCreate", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu", + "group": "0_create@0" + }, + { + "command": "pr.createPrMenuDraft", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu && github:createPrMenuDraft", + "group": "0_create@1" + }, + { + "command": "pr.createPrMenuMergeWhenReady", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu && github:createPrMenuMergeWhenReady", + "group": "1_create@0" + }, + { + "command": "pr.createPrMenuMerge", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu && github:createPrMenuMerge", + "group": "1_create@0" + }, + { + "command": "pr.createPrMenuSquash", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu && github:createPrMenuSquash", + "group": "1_create@1" + }, + { + "command": "pr.createPrMenuRebase", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu && github:createPrMenuRebase", + "group": "1_create@2" + }, + { + "command": "review.approve", + "when": "webviewId == 'github:activePullRequest' && github:reviewCommentMenu && github:reviewCommentApprove" + }, + { + "command": "review.comment", + "when": "webviewId == 'github:activePullRequest' && github:reviewCommentMenu && github:reviewCommentComment" + }, + { + "command": "review.requestChanges", + "when": "webviewId == 'github:activePullRequest' && github:reviewCommentMenu && github:reviewCommentRequestChanges" + }, + { + "command": "review.approveOnDotCom", + "when": "webviewId == 'github:activePullRequest' && github:reviewCommentMenu && github:reviewCommentApproveOnDotCom" + }, + { + "command": "review.requestChangesOnDotCom", + "when": "webviewId == 'github:activePullRequest' && github:reviewCommentMenu && github:reviewCommentRequestChangesOnDotCom" + }, + { + "command": "review.approveDescription", + "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentApprove" + }, + { + "command": "review.commentDescription", + "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentComment" + }, + { + "command": "review.requestChangesDescription", + "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentRequestChanges" + }, + { + "command": "review.approveOnDotComDescription", + "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentApproveOnDotCom" + }, + { + "command": "review.requestChangesOnDotComDescription", + "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentRequestChangesOnDotCom" + } + ] + }, + "colors": [ + { + "id": "issues.newIssueDecoration", + "defaults": { + "dark": "#ffffff48", + "light": "#00000048", + "highContrast": "editor.foreground", + "highContrastLight": "editor.foreground" + }, + "description": "The color used for the assignees and labels fields in a new issue editor." + }, + { + "id": "issues.open", + "defaults": { + "dark": "#3FB950", + "light": "#3FB950", + "highContrast": "editor.foreground", + "highContrastLight": "editor.foreground" + }, + "description": "The color used for indicating that an issue is open." + }, + { + "id": "issues.closed", + "defaults": { + "dark": "#cb2431", + "light": "#cb2431", + "highContrast": "editor.foreground", + "highContrastLight": "editor.foreground" + }, + "description": "The color used for indicating that an issue is closed." + }, + { + "id": "pullRequests.merged", + "defaults": { + "dark": "#8957e5", + "light": "#8957e5", + "highContrast": "editor.background", + "highContrastLight": "editor.background" + }, + "description": "The color used for indicating that a pull request is merged." + }, + { + "id": "pullRequests.draft", + "defaults": { + "dark": "#6e7681", + "light": "#6e7681", + "highContrast": "editor.background", + "highContrastLight": "editor.background" + }, + "description": "The color used for indicating that a pull request is a draft." + }, + { + "id": "pullRequests.open", + "defaults": { + "dark": "issues.open", + "light": "issues.open", + "highContrast": "editor.background", + "highContrastLight": "editor.background" + }, + "description": "The color used for indicating that a pull request is open." + }, + { + "id": "pullRequests.closed", + "defaults": { + "dark": "issues.closed", + "light": "issues.closed", + "highContrast": "editor.background", + "highContrastLight": "editor.background" + }, + "description": "The color used for indicating that a pull request is closed." + }, + { + "id": "pullRequests.notification", + "defaults": { + "dark": "notificationsInfoIcon.foreground", + "light": "notificationsInfoIcon.foreground", + "highContrast": "editor.foreground", + "highContrastLight": "editor.foreground" + }, + "description": "The color used for indicating a notification on a pull request" + } + ], + "resourceLabelFormatters": [ + { + "scheme": "review", + "formatting": { + "label": "${path}", + "separator": "/", + "workspaceSuffix": "GitHub", + "stripPathStartingSeparator": true + } + } + ] + }, + "scripts": { + "postinstall": "yarn update-dts", + "bundle": "webpack --mode production --env esbuild", + "bundle:node": "webpack --mode production --config-name extension:node --config-name webviews", + "bundle:web": "webpack --mode production --config-name extension:webworker --config-name webviews", + "clean": "rm -r dist/", + "compile": "webpack --mode development --env esbuild", + "compile:test": "tsc -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.json --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}\"", + "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", + "browsertest:preprocess": "tsc ./src/test/browser/runTests.ts --outDir ./dist/browser/test --rootDir ./src/test/browser --target es6 --module commonjs", + "browsertest": "yarn run browsertest:preprocess && node ./dist/browser/test/runTests.js", + "test:preprocess-gql": "node scripts/preprocess-gql --in src/github/queries.gql --out out/src/github/queries.gql && node scripts/preprocess-gql --in src/github/queriesExtra.gql --out out/src/github/queriesExtra.gql && node scripts/preprocess-gql --in src/github/queriesShared.gql --out out/src/github/queriesShared.gql && node scripts/preprocess-gql --in src/github/queriesLimited.gql --out out/src/github/queriesLimited.gql", + "test:preprocess-svg": "node scripts/preprocess-svg --in ../resources/ --out out/resources", + "update-dts": "cd \"src/@types\" && npx vscode-dts main && npx vscode-dts dev", + "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" + }, + "devDependencies": { + "@types/chai": "^4.1.4", + "@types/glob": "7.1.3", + "@types/lru-cache": "^5.1.0", + "@types/marked": "^0.7.2", + "@types/mocha": "^8.2.2", + "@types/node": "12.12.70", + "@types/react": "^16.8.4", + "@types/react-dom": "^16.8.2", + "@types/sinon": "7.0.11", + "@types/temp": "0.8.34", + "@types/vscode": "1.79.0", + "@types/webpack-env": "^1.16.0", + "@typescript-eslint/eslint-plugin": "6.10.0", + "@typescript-eslint/parser": "6.10.0", + "@vscode/test-electron": "^2.3.8", + "@vscode/test-web": "^0.0.29", + "assert": "^2.0.0", + "buffer": "^6.0.3", + "constants-browserify": "^1.0.0", + "crypto-browserify": "3.12.0", + "css-loader": "5.1.3", + "esbuild-loader": "2.10.0", + "eslint": "7.22.0", + "eslint-cli": "1.1.1", + "eslint-plugin-import": "2.22.1", + "event-stream": "^4.0.1", + "fork-ts-checker-webpack-plugin": "6.1.1", + "glob": "7.1.6", + "graphql": "15.5.0", + "graphql-tag": "2.11.0", + "gulp-filter": "^7.0.0", + "husky": "^8.0.1", + "jsdom": "19.0.0", + "jsdom-global": "3.0.2", + "json5": "2.2.2", + "merge-options": "3.0.4", + "minimist": "^1.2.6", + "mkdirp": "1.0.4", + "mocha": "^9.0.1", + "mocha-junit-reporter": "1.23.0", + "mocha-multi-reporters": "1.1.7", + "os-browserify": "^0.3.0", + "p-all": "^1.0.0", + "path-browserify": "1.0.1", + "process": "^0.11.10", + "raw-loader": "4.0.2", + "react-testing-library": "7.0.1", + "sinon": "9.0.0", + "source-map-support": "0.5.19", + "stream-browserify": "^3.0.0", + "style-loader": "2.0.0", + "svg-inline-loader": "^0.8.2", + "temp": "0.9.4", + "terser-webpack-plugin": "5.1.1", + "timers-browserify": "^2.0.12", + "ts-loader": "8.0.18", + "tty": "1.0.1", + "typescript": "4.5.5", + "typescript-formatter": "^7.2.2", + "vinyl-fs": "^3.0.3", + "webpack": "5.76.0", + "webpack-cli": "4.2.0" + }, + "dependencies": { + "@octokit/rest": "18.2.1", + "@octokit/types": "6.10.1", + "@vscode/extension-telemetry": "0.7.5", + "apollo-boost": "^0.4.9", + "apollo-link-context": "1.0.20", + "cockatiel": "^3.1.1", + "cross-fetch": "3.1.5", + "dayjs": "1.10.4", + "debounce": "^1.2.1", + "events": "3.2.0", + "fast-deep-equal": "^3.1.3", + "lru-cache": "6.0.0", + "marked": "^4.0.10", + "react": "^16.12.0", + "react-dom": "^16.12.0", + "ssh-config": "4.1.1", + "tunnel": "0.0.6", + "url-search-params-polyfill": "^8.1.1", + "uuid": "8.3.2", + "vscode-tas-client": "^0.1.75", + "vsls": "^0.3.967" + }, + "license": "MIT" +} diff --git a/src/test/common/fixtures/gitdiff/03-large-many-changes b/src/test/common/fixtures/gitdiff/03-large-many-changes new file mode 100644 index 0000000000..4928a2ba45 --- /dev/null +++ b/src/test/common/fixtures/gitdiff/03-large-many-changes @@ -0,0 +1,2928 @@ +{ + "name": "vscode-pull-request-github", + "displayName": "%displayName%", + "description": "%description%", + "icon": "resources/icons/github_logo.png", + "repository": { + "type": "git", + "url": "https://github.com/Microsoft/vscode-pull-request-github" + }, + "bugs": { + "url": "https://github.com/Microsoft/vscode-pull-request-github/issues" + }, + "enabledApiProposals": [ + + ], + "version": "0.86.0", + "publisher": "GitHub", + "engines": { + "vscode": "^1.88.0" + }, + "categories": [ + "Other" + ], + "extensionDependencies": [ + "vscode.github-authentication" + ], + "activationEvents": [ + "onStartupFinished", + "onFileSystem:newIssue", + "onFileSystem:pr", + "onFileSystem:githubpr", + "onFileSystem:review" + ], + "browser": "./dist/browser/extension", + "l10n": "./dist/browser/extension", + "main": "./dist/extension", + "capabilities": { + "untrustedWorkspaces": { + "supported": true + }, + "virtualWorkspaces": true + }, + "contributes": { + "configuration": { + "type": "object", + "title": "GitHub Pull Requests", + "properties": { + "githubPullRequests.pullRequestTitle": { + "deprecationMessage": "The pull request title now uses the same defaults as GitHub, and can be edited before create.", + "type": "string", + "enum": [ + "commit", + "branch", + "custom", + "ask" + ], + "enumDescriptions": [ + "Use the latest commit message", + "Use the branch name", + "Specify a custom title", + "Ask which of the above methods to use" + ], + "default": "ask", + "description": "The title used when creating pull requests." + }, + "githubPullRequests.pullRequestDescription": { + "type": "string", + "enum": [ + "template", + "commit", + "none", + "Copilot" + ], + "enumDescriptions": [ + "%githubPullRequests.pullRequestDescription.template%", + "%githubPullRequests.pullRequestDescription.commit%", + "%githubPullRequests.pullRequestDescription.none%", + "%githubPullRequests.pullRequestDescription.copilot%" + ], + "default": "template", + "description": "%githubPullRequests.pullRequestDescription.description%" + }, + "githubPullRequests.defaultCreateOption": { + "type":"string", + "enum": [ + "lastUsed", + "create", + "createDraft", + "createAutoMerge" + ], + "markdownEnumDescriptions": [ + "%githubPullRequests.defaultCreateOption.lastUsed%", + "%githubPullRequests.defaultCreateOption.create%", + "%githubPullRequests.defaultCreateOption.createDraft%", + "%githubPullRequests.defaultCreateOption.createAutoMerge%" + ], + "default": "lastUsed", + "description": "%githubPullRequests.defaultCreateOption.description%" + }, + "githubPullRequests.createDraft": { + "type": "boolean", + "default": false, + "deprecationMessage": "Use the setting 'githubPullRequests.defaultCreateOption' instead.", + "description": "%githubPullRequests.createDraft%" + }, + "githubPullRequests.logLevel": { + "type": "string", + "enum": [ + "info", + "debug", + "off" + ], + "default": "info", + "description": "%githubPullRequests.logLevel.description%", + "markdownDeprecationMessage": "%githubPullRequests.logLevel.markdownDeprecationMessage%" + }, + "githubPullRequests.remotes": { + "type": "array", + "default": [ + "origin", + "upstream" + ], + "items": { + "type": "string" + }, + "markdownDescription": "%githubPullRequests.remotes.markdownDescription%" + }, + "githubPullRequests.includeRemotes": { + "type": "string", + "enum": [ + "default", + "all" + ], + "default": "default", + "deprecationMessage": "The setting `githubPullRequests.includeRemotes` has been deprecated. Use `githubPullRequests.remotes` to configure what remotes are shown.", + "description": "By default we only support remotes created by users. If you want to see pull requests from remotes this extension created for pull requests, change this setting to 'all'." + }, + "githubPullRequests.queries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "%githubPullRequests.queries.label.description%" + }, + "query": { + "type": "string", + "description": "%githubPullRequests.queries.query.description%" + } + } + }, + "scope": "resource", + "markdownDescription": "%githubPullRequests.queries.markdownDescription%", + "default": [ + { + "label": "%githubPullRequests.queries.waitingForMyReview%", + "query": "is:open review-requested:${user}" + }, + { + "label": "%githubPullRequests.queries.assignedToMe%", + "query": "is:open assignee:${user}" + }, + { + "label": "%githubPullRequests.queries.createdByMe%", + "query": "is:open author:${user}" + } + ] + }, + "githubPullRequests.labelCreated": { + "type": "array", + "items": { + "type": "string", + "description": "%githubPullRequests.labelCreated.label.description%" + }, + "default": [], + "description": "%githubPullRequests.labelCreated.description%" + }, + "githubPullRequests.defaultMergeMethod": { + "type": "string", + "enum": [ + "merge", + "squash", + "rebase" + ], + "default": "merge", + "description": "%githubPullRequests.defaultMergeMethod.description%" + }, + "githubPullRequests.showInSCM": { + "type": "boolean", + "default": false, + "deprecationMessage": "This setting is deprecated. Views can now be dragged to any location.", + "description": "When true, show GitHub Pull Requests within the SCM viewlet. Otherwise show a separate view container for them." + }, + "githubPullRequests.notifications": { + "type": "string", + "enum": [ + "pullRequests", + "off" + ], + "default": "off", + "description": "%githubPullRequests.notifications.description%" + }, + "githubPullRequests.fileListLayout": { + "type": "string", + "enum": [ + "flat", + "tree" + ], + "default": "tree", + "description": "%githubPullRequests.fileListLayout.description%" + }, + "githubPullRequests.defaultDeletionMethod.selectLocalBranch": { + "type": "boolean", + "default": true, + "description": "%githubPullRequests.defaultDeletionMethod.selectLocalBranch.description%" + }, + "githubPullRequests.defaulteletionMethod.selectRemote": { + "type": "boolean", + "default": true, + "description": "%githubPullRequests.defaultDeletionMethod.selectRemote.description%" + }, + "githubPullRequests.terminalLinksHandler": { + "type": "string", + "enum": [ + "github", + "vscode", + "ask" + ], + "enumDescriptions": [ + "%githubPullRequests.terminalLinksHandler.github%", + "%githubPullRequests.terminalLinksHandler.vscode%", + "%githubPullRequests.terminalLinksHandler.ask%" + ], + "default": "ask", + "description": "%githubPullRequests.terminalLinksHandler.description%" + }, + "githubPullRequests.createOnPublishBranch": { + "type": "string", + "enum": [ + "never", + "ask" + ], + "enumDescriptions": [ + "%githubPullRequests.createOnPublishBranch.never%", + "%githubPullRequests.createOnPublishBranch.ask%" + ], + "default": "ask", + "description": "%githubPullRequests.createOnPublishBranch.description%" + }, + "githubPullRequests.commentExpandState": { + "type": "string", + "enum": [ + "expandUnresolved", + "collapseAll" + ], + "enumDescriptions": [ + "%githubPullRequests.commentExpandState.expandUnresolved%", + "%githubPullRequests.commentExpandState.collapseAll%" + ], + "default": "expandUnresolved", + "description": "%githubPullRequests.commentExpandState.description%" + }, + "githubPullRequests.useReviewMode": { + "description": "%githubPullRequests.useReviewMode.description%", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "merged": { + "type": "boolean", + "description": "%githubPullRequests.useReviewMode.merged%", + "default": false + }, + "closed": { + "type": "boolean", + "description": "%githubPullRequests.useReviewMode.closed%", + "default": false + } + }, + "required": [ + "merged", + "closed" + ] + }, + { + "type": "string", + "enum": [ + "auto" + ] + } + ], + "default": "auto" + }, + "githubPullRequests.assignCreated": { + "type": "string", + "description": "%githubPullRequests.assignCreated.description%" + }, + "githubPullRequests.pushBranch": { + "type": "string", + "enum": [ + "prompt", + "always" + ], + "default": "prompt", + "enumDescriptions": [ + "%githubPullRequests.pushBranch.prompt%", + "%githubPullRequests.pushBranch.always%" + ], + "description": "%githubPullRequests.pushBranch.description%" + }, + "githubPullRequests.pullBranch": { + "type": "string", + "enum": [ + "prompt", + "never", + "always" + ], + "default": "prompt", + "markdownEnumDescriptions": [ + "%githubPullRequests.pullBranch.prompt%", + "%githubPullRequests.pullBranch.never%", + "%githubPullRequests.pullBranch.always%" + ], + "description": "%githubPullRequests.pullBranch.description%" + }, + "githubPullRequests.allowFetch": { + "type": "boolean", + "default": true, + "description": "%githubPullRequests.allowFetch.description%" + }, + "githubPullRequests.ignoredPullRequestBranches": { + "type": "array", + "default": [], + "items": { + "type": "string", + "description": "%githubPullRequests.ignoredPullRequestBranches.items%" + }, + "description": "%githubPullRequests.ignoredPullRequestBranches.description%" + }, + "githubPullRequests.neverIgnoreDefaultBranch": { + "type": "boolean", + "description": "%githubPullRequests.neverIgnoreDefaultBranch.description%" + }, + "githubPullRequests.overrideDefaultBranch": { + "type": "string", + "description": "%githubPullRequests.overrideDefaultBranch.description%" + }, + "githubPullRequests.postCreate": { + "type": "string", + "enum": [ + "none", + "openOverview", + "checkoutDefaultBranch", + "checkoutDefaultBranchAndShow", + "checkoutDefaultBranchAndCopy" + ], + "description": "%githubPullRequests.postCreate.description%", + "default": "openOverview", + "enumDescriptions": [ + "%githubPullRequests.postCreate.none%", + "%githubPullRequests.postCreate.openOverview%", + "%githubPullRequests.postCreate.checkoutDefaultBranch%", + "%githubPullRequests.postCreate.checkoutDefaultBranchAndShow%", + "%githubPullRequests.postCreate.checkoutDefaultBranchAndCopy%" + ] + }, + "githubPullRequests.defaultCommentType": { + "type": "string", + "enum": [ + "single", + "review" + ], + "default": "single", + "description": "%githubPullRequests.defaultCommentType.description%", + "enumDescriptions": [ + "%githubPullRequests.defaultCommentType.single%", + "%githubPullRequests.defaultCommentType.review%" + ] + }, + "githubPullRequests.quickDiff": { + "type": "boolean", + "description": "Enables quick diff in the editor gutter for checked-out pull requests. Requires a reload to take effect", + "default": false + }, + "githubPullRequests.setAutoMerge": { + "type": "boolean", + "description": "%githubPullRequests.setAutoMerge.description%", + "deprecationMessage": "Use the setting 'githubPullRequests.defaultCreateOption' instead.", + "default": false + }, + "githubPullRequests.pullPullRequestBranchBeforeCheckout": { + "type": "string", + "description": "%githubPullRequests.pullPullRequestBranchBeforeCheckout.description%", + "enum": [ + "never", + "pull", + "pullAndMergeBase", + "pullAndUpdateBase" + ], + "default": "pull", + "enumDescriptions": [ + "%githubPullRequests.pullPullRequestBranchBeforeCheckout.never%", + "%githubPullRequests.pullPullRequestBranchBeforeCheckout.pull%", + "%githubPullRequests.pullPullRequestBranchBeforeCheckout.pullAndMergeBase%", + "%githubPullRequests.pullPullRequestBranchBeforeCheckout.pullAndUpdateBase%" + ] + }, + "githubPullRequests.upstreamRemote": { + "type": "string", + "enum": [ + "add", + "never" + ], + "markdownDescription": "%githubPullRequests.upstreamRemote.description%", + "markdownEnumDescriptions": [ + "%githubPullRequests.upstreamRemote.add%", + "%githubPullRequests.upstreamRemote.never%" + ], + "default": "add" + }, + "githubPullRequests.createDefaultBaseBranch": { + "type": "string", + "enum": ["repositoryDefault", "createdFromBranch"], + "markdownEnumDescriptions": [ + "%githubPullRequests.createDefaultBaseBranch.repositoryDefault%", + "%githubPullRequests.createDefaultBaseBranch.createdFromBranch%" + ], + "default": "createdFromBranch", + "markdownDescription": "%githubPullRequests.createDefaultBaseBranch.description%" + }, + "githubIssues.ignoreMilestones": { + "type": "array", + "default": [], + "description": "%githubIssues.ignoreMilestones.description%" + }, + "githubIssues.createIssueTriggers": { + "type": "array", + "items": { + "type": "string", + "description": "%githubIssues.createIssueTriggers.items%" + }, + "default": [ + "TODO", + "todo", + "BUG", + "FIXME", + "ISSUE", + "HACK" + ], + "description": "%githubIssues.createIssueTriggers.description%" + }, + "githubIssues.createInsertFormat": { + "type": "string", + "enum": [ + "number", + "url" + ], + "default": "number", + "description": "%githubIssues.createInsertFormat.description%" + }, + "githubIssues.issueCompletions.enabled": { + "type": "boolean", + "default": true, + "description": "%githubIssues.issueCompletions.enabled.description%" + }, + "githubIssues.userCompletions.enabled": { + "type": "boolean", + "default": true, + "description": "%githubIssues.userCompletions.enabled.description%" + }, + "githubIssues.ignoreCompletionTrigger": { + "type": "array", + "items": { + "type": "string", + "description": "%githubIssues.ignoreCompletionTrigger.items%" + }, + "default": [ + "coffeescript", + "diff", + "dockerfile", + "dockercompose", + "ignore", + "ini", + "julia", + "makefile", + "perl", + "powershell", + "python", + "r", + "ruby", + "shellscript", + "yaml" + ], + "description": "%githubIssues.ignoreCompletionTrigger.description%" + }, + "githubIssues.ignoreUserCompletionTrigger": { + "type": "array", + "items": { + "type": "string", + "description": "%githubIssues.ignoreUserCompletionTrigger.items%" + }, + "default": [ + "python" + ], + "description": "%githubIssues.ignoreUserCompletionTrigger.description%" + }, + "githubIssues.issueBranchTitle": { + "type": "string", + "default": "${user}/issue${issueNumber}", + "markdownDescription": "%githubIssues.issueBranchTitle.markdownDescription%" + }, + "githubIssues.useBranchForIssues": { + "type": "string", + "enum": [ + "on", + "off", + "prompt" + ], + "enumDescriptions": [ + "%githubIssues.useBranchForIssues.on%", + "%githubIssues.useBranchForIssues.off%", + "%githubIssues.useBranchForIssues.prompt%" + ], + "default": "on", + "markdownDescription": "%githubIssues.useBranchForIssues.markdownDescription%" + }, + "githubIssues.issueCompletionFormatScm": { + "type": "string", + "default": "${issueTitle} ${issueNumberLabel}", + "markdownDescription": "%githubIssues.issueCompletionFormatScm.markdownDescription%" + }, + "githubIssues.workingIssueFormatScm": { + "type": "string", + "default": "${issueTitle} \nFixes ${issueNumberLabel}", + "markdownDescription": "%githubIssues.workingIssueFormatScm.markdownDescription%", + "editPresentation": "multilineText" + }, + "githubIssues.queries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "%githubIssues.queries.label%" + }, + "query": { + "type": "string", + "markdownDescription": "%githubIssues.queries.query%" + }, + "groupBy": { + "type": "array", + "markdownDescription": "%githubIssues.queries.groupBy%", + "items": { + "type": "string", + "enum": [ + "repository", + "milestone" + ], + "enumDescriptions": [ + "%githubIssues.queries.groupBy.milestone%", + "%githubIssues.queries.groupBy.repository%" + ] + } + } + } + }, + "scope": "resource", + "markdownDescription": "%githubIssues.queries.markdownDescription%", + "default": [ + { + "label": "%githubIssues.queries.default.myIssues%", + "query": "is:open assignee:${user} repo:${owner}/${repository}", + "groupBy": ["milestone"] + }, + { + "label": "%githubIssues.queries.default.createdIssues%", + "query": "author:${user} state:open repo:${owner}/${repository} sort:created-desc" + }, + { + "label": "%githubIssues.queries.default.recentIssues%", + "query": "state:open repo:${owner}/${repository} sort:updated-desc" + } + ] + }, + "githubIssues.assignWhenWorking": { + "type": "boolean", + "default": true, + "description": "%githubIssues.assignWhenWorking.description%" + }, + "githubPullRequests.focusedMode": { + "properties": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + }, + "enum": [ + "firstDiff", + "overview", + "multiDiff", + false + ], + "default": "firstDiff", + "description": "%githubPullRequests.focusedMode.description%" + }, + "githubPullRequests.showPullRequestNumberInTree": { + "type": "boolean", + "default": false, + "description": "%githubPullRequests.showPullRequestNumberInTree.description%" + } + } + }, + "viewsContainers": { + "activitybar": [ + { + "id": "github-pull-requests", + "title": "%view.github.pull.requests.name%", + "icon": "$(github)" + }, + { + "id": "github-pull-request", + "title": "%view.github.pull.request.name%", + "icon": "$(git-pull-request)" + } + ] + }, + "views": { + "github-pull-requests": [ + { + "id": "github:login", + "name": "%view.github.login.name%", + "when": "ReposManagerStateContext == NeedsAuthentication", + "icon": "$(git-pull-request)" + }, + { + "id": "pr:github", + "name": "%view.pr.github.name%", + "when": "ReposManagerStateContext != NeedsAuthentication", + "icon": "$(git-pull-request)" + }, + { + "id": "issues:github", + "name": "%view.issues.github.name%", + "when": "ReposManagerStateContext != NeedsAuthentication", + "icon": "$(issues)" + } + ], + "github-pull-request": [ + { + "id": "github:createPullRequestWebview", + "type": "webview", + "name": "%view.github.create.pull.request.name%", + "when": "github:createPullRequest", + "visibility": "visible", + "initialSize": 2 + }, + { + "id": "github:compareChangesFiles", + "name": "%view.github.compare.changes.name%", + "when": "github:createPullRequest", + "visibility": "visible", + "initialSize": 1 + }, + { + "id": "github:compareChangesCommits", + "name": "%view.github.compare.changesCommits.name%", + "when": "github:createPullRequest", + "visibility": "visible", + "initialSize": 1 + }, + { + "id": "prStatus:github", + "name": "%view.pr.status.github.name%", + "when": "github:inReviewMode && !github:createPullRequest", + "icon": "$(git-pull-request)", + "visibility": "visible", + "initialSize": 3 + }, + { + "id": "github:activePullRequest", + "type": "webview", + "name": "%view.github.active.pull.request.name%", + "when": "github:inReviewMode && github:focusedReview && !github:createPullRequest && github:activePRCount <= 1", + "initialSize": 2 + }, + { + "id": "github:activePullRequest:welcome", + "name": "%view.github.active.pull.request.welcome.name%", + "when": "!github:stateValidated && github:focusedReview" + } + ] + }, + "commands": [ + { + "command": "github.api.preloadPullRequest", + "title": "Preload Pull Request", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.create", + "title": "%command.pr.create.title%", + "icon": "$(git-pull-request-create)", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.pushAndCreate", + "title": "%command.pr.create.title%", + "icon": "$(git-pull-request-create)", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.pick", + "title": "%command.pr.pick.title%", + "category": "%command.pull.request.category%", + "icon": "$(arrow-right)" + }, + { + "command": "pr.openChanges", + "title": "%command.pr.openChanges.title%", + "category": "%command.pull.request.category%", + "icon": "$(diff-multiple)" + }, + { + "command": "pr.pickOnVscodeDev", + "title": "%command.pr.pickOnVscodeDev.title%", + "category": "%command.pull.request.category%", + "icon": "$(globe)" + }, + { + "command": "pr.exit", + "title": "%command.pr.exit.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.dismissNotification", + "title": "%command.pr.dismissNotification.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.merge", + "title": "%command.pr.merge.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.readyForReview", + "title": "%command.pr.readyForReview.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.close", + "title": "%command.pr.close.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.openPullRequestOnGitHub", + "title": "%command.pr.openPullRequestOnGitHub.title%", + "category": "%command.pull.request.category%", + "icon": "$(globe)" + }, + { + "command": "pr.openAllDiffs", + "title": "%command.pr.openAllDiffs.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.refreshPullRequest", + "title": "%command.pr.refreshPullRequest.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.openFileOnGitHub", + "title": "%command.pr.openFileOnGitHub.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.copyCommitHash", + "title": "%command.pr.copyCommitHash.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.openOriginalFile", + "title": "%command.pr.openOriginalFile.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.openModifiedFile", + "title": "%command.pr.openModifiedFile.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.openDiffView", + "title": "%command.pr.openDiffView.title%", + "category": "%command.pull.request.category%", + "icon": "$(compare-changes)" + }, + { + "command": "pr.openDiffViewFromEditor", + "title": "%command.pr.openDiffViewFromEditor.title%", + "category": "%command.pull.request.category%", + "icon": "$(git-pull-request)" + }, + { + "command": "pr.openDescription", + "title": "%command.pr.openDescription.title%", + "category": "%command.pull.request.category%", + "when": "github:inReviewMode", + "icon": "$(note)" + }, + { + "command": "pr.openDescriptionToTheSide", + "title": "%command.pr.openDescriptionToTheSide.title%", + "icon": "$(split-horizontal)" + }, + { + "command": "pr.refreshDescription", + "title": "%command.pr.refreshDescription.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.showDiffSinceLastReview", + "title": "%command.pr.showDiffSinceLastReview.title%", + "icon": "$(git-pull-request-new-changes)" + }, + { + "command": "pr.showDiffAll", + "title": "%command.pr.showDiffAll.title%", + "icon": "$(git-pull-request-go-to-changes)" + }, + { + "command": "pr.checkoutByNumber", + "title": "%command.pr.checkoutByNumber.title%", + "category": "%command.pull.request.category%", + "icon": "$(symbol-numeric)" + }, + { + "command": "review.openFile", + "title": "%command.review.openFile.title%", + "icon": "$(go-to-file)" + }, + { + "command": "review.openLocalFile", + "title": "%command.review.openLocalFile.title%", + "icon": "$(go-to-file)" + }, + { + "command": "review.suggestDiff", + "title": "%command.review.suggestDiff.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.refreshList", + "title": "%command.pr.refreshList.title%", + "icon": "$(refresh)", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.setFileListLayoutAsTree", + "title": "%command.pr.setFileListLayoutAsTree.title%", + "icon": "$(list-tree)", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.setFileListLayoutAsFlat", + "title": "%command.pr.setFileListLayoutAsFlat.title%", + "icon": "$(list-flat)", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.refreshChanges", + "title": "%command.pr.refreshChanges.title%", + "icon": "$(refresh)", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.configurePRViewlet", + "title": "%command.pr.configurePRViewlet.title%", + "category": "%command.pull.request.category%", + "icon": "$(gear)" + }, + { + "command": "pr.deleteLocalBranch", + "title": "%command.pr.deleteLocalBranch.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.signin", + "title": "%command.pr.signin.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.signinNoEnterprise", + "title": "%command.pr.signin.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.signinenterprise", + "title": "%command.pr.signinenterprise.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.deleteLocalBranchesNRemotes", + "title": "%command.pr.deleteLocalBranchesNRemotes.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createComment", + "title": "%command.pr.createComment.title%", + "category": "%command.pull.request.category%", + "enablement": "!commentIsEmpty" + }, + { + "command": "pr.createSingleComment", + "title": "%command.pr.createSingleComment.title%", + "category": "%command.pull.request.category%", + "enablement": "!commentIsEmpty" + }, + { + "command": "pr.makeSuggestion", + "title": "%command.pr.makeSuggestion.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.startReview", + "title": "%command.pr.startReview.title%", + "category": "%command.pull.request.category%", + "enablement": "!commentIsEmpty" + }, + { + "command": "pr.editComment", + "title": "%command.pr.editComment.title%", + "category": "%command.pull.request.category%", + "icon": "$(edit)", + "enablement": "!(comment =~ /temporary/)" + }, + { + "command": "pr.cancelEditComment", + "title": "%command.pr.cancelEditComment.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.saveComment", + "title": "%command.pr.saveComment.title%", + "category": "%command.pull.request.category%", + "enablement": "!commentIsEmpty" + }, + { + "command": "pr.deleteComment", + "title": "%command.pr.deleteComment.title%", + "category": "%command.pull.request.category%", + "icon": "$(trash)", + "enablement": "!(comment =~ /temporary/)" + }, + { + "command": "pr.resolveReviewThread", + "title": "%command.pr.resolveReviewThread.title%", + "category": "%command.pull.request.category%", + "icon": "$(check)" + }, + { + "command": "pr.unresolveReviewThread", + "title": "%command.pr.unresolveReviewThread.title%", + "category": "%command.pull.request.category%", + "icon": "$(discard)" + }, + { + "command": "pr.diffOutdatedCommentWithHead", + "title": "%command.pr.diffOutdatedCommentWithHead.title%", + "category": "%command.pull.request.category%", + "icon": "$(git-compare)" + }, + { + "command": "pr.signinAndRefreshList", + "title": "%command.pr.signinAndRefreshList.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.configureRemotes", + "title": "%command.pr.configureRemotes.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.refreshActivePullRequest", + "title": "%command.pr.refreshActivePullRequest.title%", + "category": "%command.pull.request.category%", + "icon": "$(refresh)" + }, + { + "command": "pr.markFileAsViewed", + "title": "%command.pr.markFileAsViewed.title%", + "category": "%command.pull.request.category%", + "icon": "$(pass)" + }, + { + "command": "pr.unmarkFileAsViewed", + "title": "%command.pr.unmarkFileAsViewed.title%", + "category": "%command.pull.request.category%", + "icon": "$(pass-filled)" + }, + { + "command": "pr.openReview", + "title": "%command.pr.openReview.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.collapseAllComments", + "title": "%command.pr.collapseAllComments.title%", + "category": "%command.comments.category%", + "icon": "$(collapse-all)" + }, + { + "command": "pr.editQuery", + "title": "%command.pr.editQuery.title%", + "category": "%command.pull.request.category%", + "icon": "$(edit)" + }, + { + "command": "pr.openPullsWebsite", + "title": "%command.pr.openPullsWebsite.title%", + "category": "%command.pull.request.category%", + "icon": "$(globe)" + }, + { + "command": "pr.resetViewedFiles", + "title": "%command.pr.resetViewedFiles.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.goToNextDiffInPr", + "title": "%command.pr.goToNextDiffInPr.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.goToPreviousDiffInPr", + "title": "%command.pr.goToPreviousDiffInPr.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.copyCommentLink", + "title": "%command.pr.copyCommentLink.title%", + "category": "%command.pull.request.category%", + "icon": "$(copy)", + "enablement": "!(comment =~ /temporary/)" + }, + { + "command": "pr.applySuggestion", + "title": "%command.pr.applySuggestion.title%", + "category": "%command.pull.request.category%", + "icon": "$(gift)" + }, + { + "command": "pr.addAssigneesToNewPr", + "title": "%command.pr.addAssigneesToNewPr.title%", + "category": "%command.pull.request.category%", + "icon": "$(account)" + }, + { + "command": "pr.addReviewersToNewPr", + "title": "%command.pr.addReviewersToNewPr.title%", + "category": "%command.pull.request.category%", + "icon": "$(feedback)" + }, + { + "command": "pr.addLabelsToNewPr", + "title": "%command.pr.addLabelsToNewPr.title%", + "category": "%command.pull.request.category%", + "icon": "$(tag)" + }, + { + "command": "pr.addMilestoneToNewPr", + "title": "%command.pr.addMilestoneToNewPr.title%", + "category": "%command.pull.request.category%", + "icon": "$(milestone)" + }, + { + "command": "pr.addProjectsToNewPr", + "title": "%command.pr.addProjectsToNewPr.title%", + "category": "%command.pull.request.category%", + "icon": "$(github-project)" + }, + { + "command": "pr.addFileComment", + "title": "%command.pr.addFileComment.title%", + "category": "%command.pull.request.category%", + "icon": "$(comment)" + }, + { + "command": "pr.checkoutFromReadonlyFile", + "title": "%command.pr.pick.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.diffWithPrHead", + "title": "%command.review.diffWithPrHead.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.diffLocalWithPrHead", + "title": "%command.review.diffLocalWithPrHead.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.approve", + "title": "%command.review.approve.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.comment", + "title": "%command.review.comment.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.requestChanges", + "title": "%command.review.requestChanges.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.approveOnDotCom", + "title": "%command.review.approveOnDotCom.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.requestChangesOnDotCom", + "title": "%command.review.requestChangesOnDotCom.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.approveDescription", + "title": "%command.review.approve.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.commentDescription", + "title": "%command.review.comment.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.commentDescription", + "title": "%command.review.comment.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.requestChangesDescription", + "title": "%command.review.requestChanges.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.approveOnDotComDescription", + "title": "%command.review.approveOnDotCom.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.requestChangesOnDotComDescription", + "title": "%command.review.requestChangesOnDotCom.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuCreate", + "title": "%command.pr.createPrMenuCreate.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuDraft", + "title": "%command.pr.createPrMenuDraft.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuMergeWhenReady", + "title": "%command.pr.createPrMenuMergeWhenReady.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuMerge", + "title": "%command.pr.createPrMenuMerge.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuSquash", + "title": "%command.pr.createPrMenuSquash.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuRebase", + "title": "%command.pr.createPrMenuRebase.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "issue.createIssueFromSelection", + "title": "%command.issue.createIssueFromSelection.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.createIssueFromClipboard", + "title": "%command.issue.createIssueFromClipboard.title%", + "category": "%command.issues.category%" + }, + { + "command": "pr.copyVscodeDevPrLink", + "title": "%command.pr.copyVscodeDevPrLink.title%", + "category": "%command.issues.category%" + }, + { + "command": "pr.refreshComments", + "title": "%command.pr.refreshComments.title%", + "category": "%command.pull.request.category%", + "icon": "$(refresh)" + }, + { + "command": "issue.copyGithubDevLinkWithoutRange", + "title": "%command.issue.copyGithubDevLink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyGithubDevLinkFile", + "title": "%command.issue.copyGithubDevLink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyGithubDevLink", + "title": "%command.issue.copyGithubDevLink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyGithubPermalink", + "title": "%command.issue.copyGithubPermalink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyGithubHeadLink", + "title": "%command.issue.copyGithubHeadLink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyGithubPermalinkWithoutRange", + "title": "%command.issue.copyGithubPermalink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyGithubHeadLinkWithoutRange", + "title": "%command.issue.copyGithubHeadLink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyMarkdownGithubPermalink", + "title": "%command.issue.copyMarkdownGithubPermalink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.copyMarkdownGithubPermalinkWithoutRange", + "title": "%command.issue.copyMarkdownGithubPermalink.title%", + "category": "%command.issues.category%", + "enablement": "!isInEmbeddedEditor" + }, + { + "command": "issue.openGithubPermalink", + "title": "%command.issue.openGithubPermalink.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.openIssue", + "title": "%command.issue.openIssue.title%", + "category": "%command.issues.category%", + "icon": "$(globe)" + }, + { + "command": "issue.copyIssueNumber", + "title": "%command.issue.copyIssueNumber.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.copyIssueUrl", + "title": "%command.issue.copyIssueUrl.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.refresh", + "title": "%command.issue.refresh.title%", + "category": "%command.issues.category%", + "icon": "$(refresh)" + }, + { + "command": "issue.suggestRefresh", + "title": "%command.issue.suggestRefresh.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.startWorking", + "title": "%command.issue.startWorking.title%", + "category": "%command.issues.category%", + "icon": "$(arrow-right)" + }, + { + "command": "issue.startWorkingBranchDescriptiveTitle", + "title": "%command.issue.startWorkingBranchDescriptiveTitle.title%", + "category": "%command.issues.category%", + "icon": "$(arrow-right)" + }, + { + "command": "issue.continueWorking", + "title": "%command.issue.continueWorking.title%", + "category": "%command.issues.category%", + "icon": "$(arrow-right)" + }, + { + "command": "issue.startWorkingBranchPrompt", + "title": "%command.issue.startWorkingBranchPrompt.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.stopWorking", + "title": "%command.issue.stopWorking.title%", + "category": "%command.issues.category%", + "icon": "$(primitive-square)" + }, + { + "command": "issue.stopWorkingBranchDescriptiveTitle", + "title": "%command.issue.stopWorkingBranchDescriptiveTitle.title%", + "category": "%command.issues.category%", + "icon": "$(primitive-square)" + }, + { + "command": "issue.statusBar", + "title": "%command.issue.statusBar.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.getCurrent", + "title": "%command.issue.getCurrent.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.editQuery", + "title": "%command.issue.editQuery.title%", + "category": "%command.issues.category%", + "icon": "$(edit)" + }, + { + "command": "issue.createIssue", + "title": "%command.issue.createIssue.title%", + "category": "%command.issues.category%", + "icon": "$(plus)" + }, + { + "command": "issue.createIssueFromFile", + "title": "%command.issue.createIssueFromFile.title%", + "icon": "$(check)", + "enablement": "!issues.creatingFromFile" + }, + { + "command": "issue.issueCompletion", + "title": "%command.issue.issueCompletion.title%" + }, + { + "command": "issue.userCompletion", + "title": "%command.issue.userCompletion.title%" + }, + { + "command": "issue.signinAndRefreshList", + "title": "%command.issue.signinAndRefreshList.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.goToLinkedCode", + "title": "%command.issue.goToLinkedCode.title%", + "category": "%command.issues.category%" + }, + { + "command": "issues.openIssuesWebsite", + "title": "%command.issues.openIssuesWebsite.title%", + "category": "%command.pull.request.category%", + "icon": "$(globe)" + } + ], + "viewsWelcome": [ + { + "view": "github:login", + "when": "ReposManagerStateContext == NeedsAuthentication && github:hasGitHubRemotes", + "contents": "%welcome.github.login.contents%" + }, + { + "view": "pr:github", + "when": "gitNotInstalled", + "contents": "%welcome.github.noGit.contents%" + }, + { + "view": "github:login", + "when": "ReposManagerStateContext == NeedsAuthentication && !github:hasGitHubRemotes && gitOpenRepositoryCount", + "contents": "%welcome.github.loginNoEnterprise.contents%" + }, + { + "view": "github:login", + "when": "ReposManagerStateContext == NeedsAuthentication && !github:hasGitHubRemotes && gitOpenRepositoryCount", + "contents": "%welcome.github.loginWithEnterprise.contents%" + }, + { + "view": "pr:github", + "when": "git.state != initialized && !github:initialized && workspaceFolderCount > 0", + "contents": "%welcome.pr.github.uninitialized.contents%" + }, + { + "view": "pr:github", + "when": "workspaceFolderCount > 0 && github:loadingPrsTree", + "contents": "%welcome.pr.github.uninitialized.contents%" + }, + { + "view": "pr:github", + "when": "workspaceFolderCount == 0", + "contents": "%welcome.pr.github.noFolder.contents%" + }, + { + "view": "pr:github", + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount == 0", + "contents": "%welcome.pr.github.noRepo.contents%" + }, + { + "view": "pr:github", + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount == 1", + "contents": "%welcome.pr.github.parentRepo.contents%" + }, + { + "view": "pr:github", + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount > 1", + "contents": "%welcome.pr.github.parentRepo.contents%" + }, + { + "view": "issues:github", + "when": "git.state != initialized && !github:initialized && workspaceFolderCount > 0", + "contents": "%welcome.issues.github.uninitialized.contents%" + }, + { + "view": "issues:github", + "when": "workspaceFolderCount > 0 && github:loadingPrsTree", + "contents": "%welcome.issues.github.uninitialized.contents%" + }, + { + "view": "issues:github", + "when": "workspaceFolderCount == 0", + "contents": "%welcome.issues.github.noFolder.contents%" + }, + { + "view": "issues:github", + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount == 0", + "contents": "%welcome.issues.github.noRepo.contents%" + }, + { + "view": "issues:github", + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount == 1", + "contents": "%welcome.pr.github.parentRepo.contents%" + }, + { + "view": "issues:github", + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount > 1", + "contents": "%welcome.pr.github.parentRepo.contents%" + }, + { + "view": "github:activePullRequest:welcome", + "when": "!github:stateValidated", + "contents": "%welcome.github.activePullRequest.contents%" + } + ], + "keybindings": [ + { + "key": "ctrl+shift+space", + "command": "issue.suggestRefresh", + "when": "suggestWidgetVisible" + }, + { + "key": "ctrl+s", + "mac": "cmd+s", + "command": "issue.createIssueFromFile", + "when": "resourceScheme == newIssue && config.files.autoSave != off" + }, + { + "key": "ctrl+enter", + "mac": "cmd+enter", + "command": "issue.createIssueFromFile", + "when": "resourceScheme == newIssue" + }, + { + "key": "ctrl+k m", + "mac": "cmd+k m", + "command": "pr.makeSuggestion", + "when": "commentEditorFocused" + } + ], + "menus": { + "commandPalette": [ + { + "command": "github.api.preloadPullRequest", + "when": "false" + }, + { + "command": "pr.configureRemotes", + "when": "gitHubOpenRepositoryCount != 0" + }, + { + "command": "pr.configurePRViewlet", + "when": "gitHubOpenRepositoryCount != 0" + }, + { + "command": "pr.pick", + "when": "false" + }, + { + "command": "pr.openChanges", + "when": "false" + }, + { + "command": "pr.pickOnVscodeDev", + "when": "false" + }, + { + "command": "pr.exit", + "when": "github:inReviewMode" + }, + { + "command": "pr.dismissNotification", + "when": "false" + }, + { + "command": "pr.resetViewedFiles", + "when": "github:inReviewMode" + }, + { + "command": "review.openFile", + "when": "false" + }, + { + "command": "review.openLocalFile", + "when": "false" + }, + { + "command": "pr.close", + "when": "gitHubOpenRepositoryCount != 0 && github:inReviewMode" + }, + { + "command": "pr.create", + "when": "gitHubOpenRepositoryCount != 0 && github:authenticated" + }, + { + "command": "pr.pushAndCreate", + "when": "false" + }, + { + "command": "pr.merge", + "when": "gitHubOpenRepositoryCount != 0 && github:inReviewMode" + }, + { + "command": "pr.readyForReview", + "when": "gitHubOpenRepositoryCount != 0 && github:inReviewMode" + }, + { + "command": "pr.openPullRequestOnGitHub", + "when": "gitHubOpenRepositoryCount != 0 && github:inReviewMode" + }, + { + "command": "pr.openAllDiffs", + "when": "gitHubOpenRepositoryCount != 0 && github:inReviewMode" + }, + { + "command": "pr.refreshDescription", + "when": "gitHubOpenRepositoryCount != 0 && github:inReviewMode" + }, + { + "command": "pr.openFileOnGitHub", + "when": "false" + }, + { + "command": "pr.openOriginalFile", + "when": "false" + }, + { + "command": "pr.openModifiedFile", + "when": "false" + }, + { + "command": "pr.refreshPullRequest", + "when": "false" + }, + { + "command": "pr.deleteLocalBranch", + "when": "false" + }, + { + "command": "pr.openDiffView", + "when": "false" + }, + { + "command": "pr.openDiffViewFromEditor", + "when": "false" + }, + { + "command": "pr.openDescriptionToTheSide", + "when": "false" + }, + { + "command": "pr.openDescription", + "when": "gitHubOpenRepositoryCount != 0 && github:inReviewMode" + }, + { + "command": "pr.showDiffSinceLastReview", + "when": "false" + }, + { + "command": "pr.showDiffAll", + "when": "false" + }, + { + "command": "review.suggestDiff", + "when": "false" + }, + { + "command": "review.approve", + "when": "false" + }, + { + "command": "review.comment", + "when": "false" + }, + { + "command": "review.requestChanges", + "when": "false" + }, + { + "command": "review.approveOnDotCom", + "when": "false" + }, + { + "command": "review.requestChangesOnDotCom", + "when": "false" + }, + { + "command": "review.approveDescription", + "when": "false" + }, + { + "command": "review.commentDescription", + "when": "false" + }, + { + "command": "review.requestChangesDescription", + "when": "false" + }, + { + "command": "review.approveOnDotComDescription", + "when": "false" + }, + { + "command": "review.requestChangesOnDotComDescription", + "when": "false" + }, + { + "command": "pr.refreshList", + "when": "gitHubOpenRepositoryCount != 0 && github:authenticated && github:hasGitHubRemotes" + }, + { + "command": "pr.setFileListLayoutAsTree", + "when": "false" + }, + { + "command": "pr.setFileListLayoutAsFlat", + "when": "false" + }, + { + "command": "pr.refreshChanges", + "when": "false" + }, + { + "command": "pr.signin", + "when": "gitHubOpenRepositoryCount != 0 && github:hasGitHubRemotes" + }, + { + "command": "pr.signinNoEnterprise", + "when": "false" + }, + { + "command": "pr.signinenterprise", + "when": "gitHubOpenRepositoryCount != 0 && github:hasGitHubRemotes" + }, + { + "command": "pr.signinAndRefreshList", + "when": "false" + }, + { + "command": "pr.copyCommitHash", + "when": "false" + }, + { + "command": "pr.createComment", + "when": "false" + }, + { + "command": "pr.createSingleComment", + "when": "false" + }, + { + "command": "pr.makeSuggestion", + "when": "false" + }, + { + "command": "pr.startReview", + "when": "false" + }, + { + "command": "pr.editComment", + "when": "false" + }, + { + "command": "pr.cancelEditComment", + "when": "false" + }, + { + "command": "pr.saveComment", + "when": "false" + }, + { + "command": "pr.deleteComment", + "when": "false" + }, + { + "command": "pr.openReview", + "when": "false" + }, + { + "command": "pr.editQuery", + "when": "false" + }, + { + "command": "pr.markFileAsViewed", + "when": "false" + }, + { + "command": "pr.unmarkFileAsViewed", + "when": "false" + }, + { + "command": "pr.checkoutByNumber", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && github:authenticated" + }, + { + "command": "pr.collapseAllComments", + "when": "false" + }, + { + "command": "pr.copyVscodeDevPrLink", + "when": "github:inReviewMode && remoteName != codespaces && embedderIdentifier != github.dev" + }, + { + "command": "pr.goToNextDiffInPr", + "when": "activeEditor == workbench.editors.textDiffEditor && resourcePath in github:unviewedFiles" + }, + { + "command": "pr.goToNextDiffInPr", + "when": "activeEditor == workbench.editors.textDiffEditor && resourcePath in github:viewedFiles" + }, + { + "command": "pr.goToPreviousDiffInPr", + "when": "activeEditor == workbench.editors.textDiffEditor && resourcePath in github:unviewedFiles" + }, + { + "command": "pr.goToPreviousDiffInPr", + "when": "activeEditor == workbench.editors.textDiffEditor && resourcePath in github:viewedFiles" + }, + { + "command": "pr.copyCommentLink", + "when": "false" + }, + { + "command": "pr.addAssigneesToNewPr", + "when": "false" + }, + { + "command": "pr.addReviewersToNewPr", + "when": "false" + }, + { + "command": "pr.addLabelsToNewPr", + "when": "false" + }, + { + "command": "pr.addMilestoneToNewPr", + "when": "false" + }, + { + "command": "pr.addProjectsToNewPr", + "when": "false" + }, + { + "command": "pr.addFileComment", + "when": "false" + }, + { + "command": "review.diffWithPrHead", + "when": "false" + }, + { + "command": "review.diffLocalWithPrHead", + "when": "false" + }, + { + "command": "pr.createPrMenuCreate", + "when": "false" + }, + { + "command": "pr.createPrMenuDraft", + "when": "false" + }, + { + "command": "pr.createPrMenuMergeWhenReady", + "when": "false" + }, + { + "command": "pr.createPrMenuMerge", + "when": "false" + }, + { + "command": "pr.createPrMenuSquash", + "when": "false" + }, + { + "command": "pr.createPrMenuRebase", + "when": "false" + }, + { + "command": "pr.refreshComments", + "when": "gitHubOpenRepositoryCount != 0" + }, + { + "command": "issue.copyGithubPermalink", + "when": "github:hasGitHubRemotes" + }, + { + "command": "issue.copyGithubHeadLink", + "when": "github:hasGitHubRemotes" + }, + { + "command": "issue.copyMarkdownGithubPermalink", + "when": "github:hasGitHubRemotes" + }, + { + "command": "issue.openGithubPermalink", + "when": "github:hasGitHubRemotes" + }, + { + "command": "issue.openIssue", + "when": "false" + }, + { + "command": "issue.copyIssueNumber", + "when": "false" + }, + { + "command": "issue.copyIssueUrl", + "when": "false" + }, + { + "command": "issue.refresh", + "when": "false" + }, + { + "command": "issue.suggestRefresh", + "when": "false" + }, + { + "command": "issue.startWorking", + "when": "false" + }, + { + "command": "issue.startWorkingBranchDescriptiveTitle", + "when": "false" + }, + { + "command": "issue.continueWorking", + "when": "false" + }, + { + "command": "issue.startWorkingBranchPrompt", + "when": "false" + }, + { + "command": "issue.stopWorking", + "when": "false" + }, + { + "command": "issue.stopWorkingBranchDescriptiveTitle", + "when": "false" + }, + { + "command": "issue.statusBar", + "when": "false" + }, + { + "command": "issue.getCurrent", + "when": "false" + }, + { + "command": "issue.editQuery", + "when": "false" + }, + { + "command": "issue.createIssue", + "when": "github:hasGitHubRemotes && github:authenticated" + }, + { + "command": "issue.createIssueFromFile", + "when": "false" + }, + { + "command": "issue.issueCompletion", + "when": "false" + }, + { + "command": "issue.userCompletion", + "when": "false" + }, + { + "command": "issue.signinAndRefreshList", + "when": "false" + }, + { + "command": "issue.goToLinkedCode", + "when": "false" + }, + { + "command": "issue.copyGithubDevLinkWithoutRange", + "when": "false" + }, + { + "command": "issue.copyGithubDevLinkFile", + "when": "false" + }, + { + "command": "issue.copyGithubDevLink", + "when": "false" + }, + { + "command": "issue.copyGithubPermalinkWithoutRange", + "when": "false" + }, + { + "command": "issue.copyMarkdownGithubPermalinkWithoutRange", + "when": "false" + }, + { + "command": "issue.copyGithubHeadLinkWithoutRange", + "when": "false" + }, + { + "command": "pr.refreshActivePullRequest", + "when": "false" + }, + { + "command": "pr.openPullsWebsite", + "when": "github:hasGitHubRemotes" + }, + { + "command": "issues.openIssuesWebsite", + "when": "github:hasGitHubRemotes" + } + ], + "view/title": [ + { + "command": "pr.create", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == pr:github", + "group": "navigation@1" + }, + { + "command": "pr.refreshList", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == pr:github", + "group": "navigation@2" + }, + { + "command": "pr.openPullsWebsite", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == pr:github", + "group": "overflow@1" + }, + { + "command": "pr.checkoutByNumber", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == pr:github", + "group": "overflow@2" + }, + { + "command": "pr.configurePRViewlet", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == pr:github", + "group": "overflow@3" + }, + { + "command": "pr.refreshChanges", + "when": "view == prStatus:github", + "group": "navigation@2" + }, + { + "command": "pr.setFileListLayoutAsTree", + "when": "view == prStatus:github && fileListLayout:flat", + "group": "navigation" + }, + { + "command": "pr.setFileListLayoutAsFlat", + "when": "view == prStatus:github && !fileListLayout:flat", + "group": "navigation" + }, + { + "command": "issue.createIssue", + "when": "view == issues:github && github:hasGitHubRemotes", + "group": "navigation@1" + }, + { + "command": "issue.refresh", + "when": "view == issues:github", + "group": "navigation@2" + }, + { + "command": "issues.openIssuesWebsite", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == issues:github", + "group": "overflow@1" + }, + { + "command": "pr.configurePRViewlet", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == issues:github", + "group": "overflow@2" + }, + { + "command": "pr.refreshActivePullRequest", + "when": "view == github:activePullRequest && github:hasGitHubRemotes", + "group": "navigation@1" + }, + { + "command": "pr.openDescription", + "when": "view == github:activePullRequest && github:hasGitHubRemotes", + "group": "navigation@2" + }, + { + "command": "pr.openPullRequestOnGitHub", + "when": "view == github:activePullRequest && github:hasGitHubRemotes", + "group": "navigation@3" + }, + { + "command": "pr.addAssigneesToNewPr", + "when": "view == github:createPullRequestWebview && github:createPrPermissions != READ && github:createPrPermissions", + "group": "navigation@1" + }, + { + "command": "pr.addReviewersToNewPr", + "when": "view == github:createPullRequestWebview && github:createPrPermissions != READ && github:createPrPermissions", + "group": "navigation@2" + }, + { + "command": "pr.addLabelsToNewPr", + "when": "view == github:createPullRequestWebview && github:createPrPermissions != READ && github:createPrPermissions", + "group": "navigation@3" + }, + { + "command": "pr.addMilestoneToNewPr", + "when": "view == github:createPullRequestWebview && github:createPrPermissions != READ && github:createPrPermissions", + "group": "navigation@4" + }, + { + "command": "pr.addProjectsToNewPr", + "when": "view == github:createPullRequestWebview && github:createPrPermissions != READ && github:createPrPermissions", + "group": "navigation@5" + }, + { + "command": "pr.refreshComments", + "when": "view == workbench.panel.comments", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "pr.pick", + "when": "view == pr:github && viewItem =~ /(pullrequest(:local)?:nonactive)|(description:nonactive)/", + "group": "1_pullrequest@1" + }, + { + "command": "pr.pick", + "when": "view == pr:github && viewItem =~ /description:nonactive/", + "group": "inline@0" + }, + { + "command": "pr.openChanges", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /description/ && config.multiDiffEditor.experimental.enabled", + "group": "inline@1" + }, + { + "command": "pr.showDiffSinceLastReview", + "group": "inline@1", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /description:(active|nonactive):hasChangesSinceReview:showingAllChanges/" + }, + { + "command": "pr.showDiffAll", + "group": "inline@1", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /description:(active|nonactive):hasChangesSinceReview:showingChangesSinceReview/" + }, + { + "command": "pr.openDescriptionToTheSide", + "group": "inline@2", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /description/" + }, + { + "command": "pr.exit", + "when": "view == pr:github && viewItem =~ /pullrequest(:local)?:active|description:active/", + "group": "1_pullrequest@1" + }, + { + "command": "pr.pickOnVscodeDev", + "when": "view == pr:github && viewItem =~ /pullrequest(:local)?:nonactive|description/ && (!isWeb || remoteName != codespaces && virtualWorkspace != vscode-vfs)", + "group": "1_pullrequest@2" + }, + { + "command": "pr.refreshPullRequest", + "when": "view == pr:github && viewItem =~ /pullrequest|description/", + "group": "pullrequest@1" + }, + { + "command": "pr.openPullRequestOnGitHub", + "when": "view == pr:github && viewItem =~ /pullrequest|description/", + "group": "1_pullrequest@3" + }, + { + "command": "pr.deleteLocalBranch", + "when": "view == pr:github && viewItem =~ /pullrequest:local:nonactive/", + "group": "pullrequest@4" + }, + { + "command": "pr.dismissNotification", + "when": "view == pr:github && viewItem =~ /pullrequest(.*):notification/", + "group": "pullrequest@5" + }, + { + "command": "pr.copyCommitHash", + "when": "view == prStatus:github && viewItem =~ /commit/" + }, + { + "command": "review.openFile", + "group": "inline@0", + "when": "openDiffOnClick && view == prStatus:github && viewItem =~ /filechange(?!:DELETE)/" + }, + { + "command": "pr.openDiffView", + "group": "inline@0", + "when": "!openDiffOnClick && view == prStatus:github && viewItem =~ /filechange(?!:DELETE)/" + }, + { + "command": "pr.openFileOnGitHub", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /filechange/", + "group": "0_open@0" + }, + { + "command": "pr.openOriginalFile", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /filechange:MODIFY/", + "group": "0_open@1" + }, + { + "command": "pr.openModifiedFile", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /filechange:MODIFY/", + "group": "0_open@2" + }, + { + "command": "review.diffWithPrHead", + "group": "1_diff@0", + "when": "openDiffOnClick && view == prStatus:github && viewItem =~ /filechange(?!:DELETE)/" + }, + { + "command": "review.diffLocalWithPrHead", + "group": "1_diff@1", + "when": "openDiffOnClick && view == prStatus:github && viewItem =~ /filechange(?!:DELETE)/" + }, + { + "command": "pr.editQuery", + "when": "view == pr:github && viewItem == query", + "group": "inline" + }, + { + "command": "pr.editQuery", + "when": "view == pr:github && viewItem == query" + }, + { + "command": "issue.openIssue", + "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/", + "group": "inline@2" + }, + { + "command": "issue.openIssue", + "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/", + "group": "issues_0@1" + }, + { + "command": "issue.goToLinkedCode", + "when": "view == issues:github && viewItem =~ /^link(current|continue)?issue/", + "group": "issues_0@0" + }, + { + "command": "issue.startWorking", + "when": "view == issues:github && viewItem =~ /^(link)?issue/ && config.githubIssues.useBranchForIssues != on", + "group": "inline@1" + }, + { + "command": "issue.startWorkingBranchDescriptiveTitle", + "when": "view == issues:github && viewItem =~ /^(link)?issue/ && config.githubIssues.useBranchForIssues == on", + "group": "inline@1" + }, + { + "command": "issue.startWorking", + "when": "view == issues:github && viewItem =~ /^(link)?continueissue/ && config.githubIssues.useBranchForIssues != on", + "group": "inline@1" + }, + { + "command": "issue.startWorkingBranchDescriptiveTitle", + "when": "view == issues:github && viewItem =~ /^(link)?continueissue/ && config.githubIssues.useBranchForIssues == on", + "group": "inline@1" + }, + { + "command": "issue.startWorking", + "alt": "issue.startWorkingBranchPrompt", + "when": "view == issues:github && viewItem =~ /^(link)?issue/", + "group": "issues_0@2" + }, + { + "command": "issue.continueWorking", + "when": "view == issues:github && viewItem =~ /^(link)?continueissue/", + "group": "issues_0@2" + }, + { + "command": "pr.create", + "when": "view == issues:github && viewItem =~ /^(link)?currentissue/", + "group": "issues_0@2" + }, + { + "command": "issue.stopWorking", + "when": "view == issues:github && viewItem =~ /^(link)?currentissue/", + "group": "issues_0@3" + }, + { + "command": "issue.stopWorking", + "when": "view == issues:github && viewItem =~ /^(link)?currentissue/ && config.githubIssues.useBranchForIssues != on", + "group": "inline@1" + }, + { + "command": "issue.stopWorkingBranchDescriptiveTitle", + "when": "view == issues:github && viewItem =~ /^(link)?currentissue/ && config.githubIssues.useBranchForIssues == on", + "group": "inline@1" + }, + { + "command": "issue.copyIssueNumber", + "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/", + "group": "issues_1@1" + }, + { + "command": "issue.copyIssueUrl", + "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/", + "group": "issues_1@2" + }, + { + "command": "issue.editQuery", + "when": "view == issues:github && viewItem == query", + "group": "inline" + }, + { + "command": "issue.editQuery", + "when": "view == issues:github && viewItem == query" + } + ], + "commentsView/commentThread/context": [ + { + "command": "pr.diffOutdatedCommentWithHead", + "group": "inline@0", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /outdated/" + }, + { + "command": "pr.resolveReviewThread", + "group": "inline@1", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canResolve/" + }, + { + "command": "pr.unresolveReviewThread", + "group": "inline@1", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canUnresolve/" + }, + { + "command": "pr.diffOutdatedCommentWithHead", + "group": "context@0", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /outdated/" + }, + { + "command": "pr.resolveReviewThread", + "group": "context@1", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canResolve/" + }, + { + "command": "pr.unresolveReviewThread", + "group": "context@1", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canUnresolve/" + } + ], + "editor/title": [ + { + "command": "review.openFile", + "group": "navigation", + "when": "resourceScheme =~ /^review$/ && isInDiffEditor" + }, + { + "command": "review.openLocalFile", + "group": "navigation", + "when": "resourceScheme =~ /^review$/ && !isInDiffEditor" + }, + { + "command": "issue.createIssueFromFile", + "group": "navigation", + "when": "resourceFilename == NewIssue.md" + }, + { + "command": "pr.markFileAsViewed", + "group": "navigation", + "when": "resourceScheme != pr && resourceScheme != review && resourceScheme != filechange && resourcePath in github:unviewedFiles" + }, + { + "command": "pr.unmarkFileAsViewed", + "group": "navigation", + "when": "resourceScheme != pr && resourceScheme != review && resourceScheme != filechange && resourcePath in github:viewedFiles" + }, + { + "command": "pr.openDiffViewFromEditor", + "group": "navigation", + "when": "!isInDiffEditor && resourceScheme != pr && resourceScheme != review && resourceScheme != filechange && resourcePath in github:unviewedFiles" + }, + { + "command": "pr.openDiffViewFromEditor", + "group": "navigation", + "when": "!isInDiffEditor && resourceScheme != pr && resourceScheme != review && resourceScheme != filechange && resourcePath in github:viewedFiles" + }, + { + "command": "pr.addFileComment", + "group": "navigation", + "when": "(resourceScheme == pr) || (resourcePath in github:viewedFiles) || (resourcePath in github:unviewedFiles)" + } + ], + "scm/title": [ + { + "command": "review.suggestDiff", + "when": "scmProvider =~ /^git|^remoteHub:github/ && scmProviderRootUri in github:reposInReviewMode", + "group": "inline" + }, + { + "command": "pr.create", + "when": "scmProvider =~ /^git|^remoteHub:github/ && scmProviderRootUri in github:reposNotInReviewMode", + "group": "navigation" + } + ], + "comments/commentThread/context": [ + { + "command": "pr.createComment", + "group": "inline@1", + "when": "(commentController =~ /^github-browse/ && prInDraft) || (commentController =~ /^github-review/ && reviewInDraftMode)" + }, + { + "command": "pr.createSingleComment", + "group": "inline@1", + "when": "config.githubPullRequests.defaultCommentType != review && ((commentController =~ /^github-browse/ && !prInDraft) || (commentController =~ /^github-review/ && !reviewInDraftMode))" + }, + { + "command": "pr.startReview", + "group": "inline@1", + "when": "config.githubPullRequests.defaultCommentType == review && ((commentController =~ /^github-browse/ && !prInDraft) || (commentController =~ /^github-review/ && !reviewInDraftMode))" + }, + { + "command": "pr.startReview", + "group": "inline@2", + "when": "config.githubPullRequests.defaultCommentType != review && ((commentController =~ /^github-browse/ && !prInDraft) || (commentController =~ /^github-review/ && !reviewInDraftMode))" + }, + { + "command": "pr.createSingleComment", + "group": "inline@2", + "when": "config.githubPullRequests.defaultCommentType == review && ((commentController =~ /^github-browse/ && !prInDraft) || commentController =~ /^github-review/ && !reviewInDraftMode)" + } + ], + "comments/comment/editorActions": [ + { + "command": "pr.makeSuggestion", + "group": "inline@3", + "when": "commentController =~ /^github-(browse|review)/" + } + ], + "comments/commentThread/additionalActions": [ + { + "command": "pr.resolveReviewThread", + "group": "inline@1", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canResolve/" + }, + { + "command": "pr.unresolveReviewThread", + "group": "inline@1", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canUnresolve/" + }, + { + "command": "pr.openReview", + "group": "inline@2", + "when": "(commentController =~ /^github-browse/ && prInDraft) || (commentController =~ /^github-review/ && reviewInDraftMode)" + } + ], + "comments/commentThread/title/context": [ + { + "command": "pr.resolveReviewThread", + "group": "inline@3", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canResolve/" + }, + { + "command": "pr.unresolveReviewThread", + "group": "inline@3", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canUnresolve/" + } + ], + "comments/commentThread/comment/context": [ + { + "command": "pr.resolveReviewThread", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canResolve/" + }, + { + "command": "pr.unresolveReviewThread", + "when": "commentController =~ /^github-(browse|review)/ && commentThread =~ /canUnresolve/" + }, + { + "command": "pr.applySuggestion", + "when": "commentController =~ /^github-review/ && comment =~ /hasSuggestion/" + } + ], + "comments/comment/title": [ + { + "command": "pr.copyCommentLink", + "group": "inline@1", + "when": "commentController =~ /^github-(browse|review)/ && comment =~ /canEdit/" + }, + { + "command": "pr.applySuggestion", + "group": "inline@0", + "when": "commentController =~ /^github-review/ && comment =~ /hasSuggestion/" + }, + { + "command": "pr.editComment", + "group": "inline@2", + "when": "commentController =~ /^github-(browse|review)/ && comment =~ /canEdit/" + }, + { + "command": "pr.deleteComment", + "group": "inline@3", + "when": "commentController =~ /^github-(browse|review)/ && comment =~ /canDelete/" + } + ], + "comments/commentThread/title": [ + { + "command": "pr.refreshComments", + "group": "0_refresh@0", + "when": "commentController =~ /^github-(browse|review)/" + }, + { + "command": "pr.collapseAllComments", + "group": "1_collapse@0", + "when": "commentController =~ /^github-(browse|review)/" + } + ], + "comments/comment/context": [ + { + "command": "pr.saveComment", + "group": "inline@1", + "when": "commentController =~ /^github-(browse|review)/" + }, + { + "command": "pr.cancelEditComment", + "group": "inline@2", + "when": "commentController =~ /^github-(browse|review)/" + } + ], + "comments/comment2/context": [ + { + "command": "pr.saveComment", + "group": "inline@1", + "when": "commentController =~ /^github-(browse|review)/" + }, + { + "command": "pr.cancelEditComment", + "group": "inline@2", + "when": "commentController =~ /^github-(browse|review)/" + } + ], + "editor/context/copy": [ + { + "command": "issue.copyGithubPermalink", + "when": "github:hasGitHubRemotes", + "group": "3_githubPullRequests@0" + }, + { + "command": "issue.copyMarkdownGithubPermalink", + "when": "github:hasGitHubRemotes", + "group": "3_githubPullRequests@1" + }, + { + "command": "issue.copyGithubHeadLink", + "when": "github:hasGitHubRemotes", + "group": "3_githubPullRequests@2" + } + ], + "editor/context/share": [ + { + "command": "issue.copyGithubPermalink", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@0" + }, + { + "command": "issue.copyMarkdownGithubPermalink", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@1" + }, + { + "command": "issue.copyGithubHeadLink", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@2" + }, + { + "command": "issue.copyGithubDevLink", + "when": "github:hasGitHubRemotes && remoteName == codespaces && isWeb || github:hasGitHubRemotes && embedderIdentifier == github.dev", + "group": "0_vscode@0" + } + ], + "file/share": [ + { + "command": "issue.copyGithubPermalink", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@0" + }, + { + "command": "pr.copyVscodeDevPrLink", + "when": "github:hasGitHubRemotes && github:inReviewMode && remoteName != codespaces && embedderIdentifier != github.dev", + "group": "1_githubPullRequests@1" + }, + { + "command": "issue.copyMarkdownGithubPermalink", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@2" + }, + { + "command": "issue.copyGithubHeadLink", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@3" + }, + { + "command": "issue.copyGithubDevLinkFile", + "when": "github:hasGitHubRemotes && remoteName == codespaces && isWeb || github:hasGitHubRemotes && embedderIdentifier == github.dev", + "group": "0_vscode@0" + } + ], + "editor/lineNumber/context": [ + { + "command": "issue.copyGithubPermalink", + "when": "github:hasGitHubRemotes && activeEditor == workbench.editors.files.textFileEditor && config.editor.lineNumbers == on", + "group": "1_cutcopypaste@3" + }, + { + "command": "issue.copyMarkdownGithubPermalink", + "when": "github:hasGitHubRemotes && activeEditor == workbench.editors.files.textFileEditor && config.editor.lineNumbers == on", + "group": "1_cutcopypaste@4" + }, + { + "command": "issue.copyGithubHeadLink", + "when": "github:hasGitHubRemotes && activeEditor == workbench.editors.files.textFileEditor && config.editor.lineNumbers == on", + "group": "1_cutcopypaste@5" + }, + { + "command": "issue.copyGithubDevLink", + "when": "github:hasGitHubRemotes && remoteName == codespaces && isWeb || github:hasGitHubRemotes && embedderIdentifier == github.dev", + "group": "1_cutcopypaste@0" + } + ], + "editor/title/context/share": [ + { + "command": "issue.copyGithubPermalinkWithoutRange", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@10" + }, + { + "command": "issue.copyMarkdownGithubPermalinkWithoutRange", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@11" + }, + { + "command": "issue.copyGithubHeadLinkWithoutRange", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@12" + }, + { + "command": "issue.copyGithubDevLinkWithoutRange", + "when": "github:hasGitHubRemotes && remoteName == codespaces && isWeb || github:hasGitHubRemotes && embedderIdentifier == github.dev", + "group": "0_vscode@0" + } + ], + "explorer/context/share": [ + { + "command": "issue.copyGithubPermalinkWithoutRange", + "when": "github:hasGitHubRemotes", + "group": "5_githubPulLRequests@10" + }, + { + "command": "issue.copyMarkdownGithubPermalinkWithoutRange", + "when": "github:hasGitHubRemotes", + "group": "5_githubPulLRequests@11" + }, + { + "command": "issue.copyGithubHeadLinkWithoutRange", + "when": "github:hasGitHubRemotes", + "group": "5_githubPulLRequests@12" + }, + { + "command": "issue.copyGithubDevLinkWithoutRange", + "when": "github:hasGitHubRemotes && remoteName == codespaces && isWeb || github:hasGitHubRemotes && embedderIdentifier == github.dev", + "group": "0_vscode@0" + } + ], + "menuBar/edit/copy": [ + { + "command": "issue.copyGithubPermalink", + "when": "github:hasGitHubRemotes" + }, + { + "command": "issue.copyMarkdownGithubPermalink", + "when": "github:hasGitHubRemotes" + } + ], + "remoteHub/pullRequest": [ + { + "command": "pr.create", + "when": "scmProvider =~ /^remoteHub:github/", + "group": "1_modification@0" + } + ], + "webview/context": [ + { + "command": "pr.createPrMenuCreate", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu", + "group": "0_create@0" + }, + { + "command": "pr.createPrMenuDraft", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu && github:createPrMenuDraft", + "group": "0_create@1" + }, + { + "command": "pr.createPrMenuMergeWhenReady", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu && github:createPrMenuMergeWhenReady", + "group": "1_create@0" + }, + { + "command": "pr.createPrMenuMerge", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu && github:createPrMenuMerge", + "group": "1_create@0" + }, + { + "command": "pr.createPrMenuSquash", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu && github:createPrMenuSquash", + "group": "1_create@1" + }, + { + "command": "pr.createPrMenuRebase", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu && github:createPrMenuRebase", + "group": "1_create@2" + }, + { + "command": "review.approve", + "when": "webviewId == 'github:activePullRequest' && github:reviewCommentMenu && github:reviewCommentApprove" + }, + { + "command": "review.comment", + "when": "webviewId == 'github:activePullRequest' && github:reviewCommentMenu && github:reviewCommentComment" + }, + { + "command": "review.requestChanges", + "when": "webviewId == 'github:activePullRequest' && github:reviewCommentMenu && github:reviewCommentRequestChanges" + }, + { + "command": "review.approveOnDotCom", + "when": "webviewId == 'github:activePullRequest' && github:reviewCommentMenu && github:reviewCommentApproveOnDotCom" + }, + { + "command": "review.requestChangesOnDotCom", + "when": "webviewId == 'github:activePullRequest' && github:reviewCommentMenu && github:reviewCommentRequestChangesOnDotCom" + }, + { + "command": "review.approveDescription", + "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentApprove" + }, + { + "command": "review.commentDescription", + "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentComment" + }, + { + "command": "review.requestChangesDescription", + "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentRequestChanges" + }, + { + "command": "review.approveOnDotComDescription", + "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentApproveOnDotCom" + }, + { + "command": "review.requestChangesOnDotComDescription", + "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentRequestChangesOnDotCom" + } + ] + }, + "colors": [ + { + "id": "issues.newIssueDecoration", + "defaults": { + "dark": "#ffffff48", + "light": "#00000048", + "highContrast": "editor.foreground", + "highContrastLight": "editor.foreground" + }, + "description": "The color used for the assignees and labels fields in a new issue editor." + }, + { + "id": "issues.open", + "defaults": { + "dark": "#3FB950", + "light": "#3FB950", + "highContrast": "editor.foreground", + "highContrastLight": "editor.foreground" + }, + "description": "The color used for indicating that an issue is open." + }, + { + "id": "issues.closed", + "defaults": { + "dark": "#cb2431", + "light": "#cb2431", + "highContrast": "editor.foreground", + "highContrastLight": "editor.foreground" + }, + "description": "The color used for indicating that an issue is closed." + }, + { + "id": "pullRequests.merged", + "defaults": { + "dark": "#8957e5", + "light": "#8957e5", + "highContrast": "editor.background", + "highContrastLight": "editor.background" + }, + "description": "The color used for indicating that a pull request is merged." + }, + { + "id": "pullRequests.draft", + "defaults": { + "dark": "#6e7681", + "light": "#6e7681", + "highContrast": "editor.background", + "highContrastLight": "editor.background" + }, + "description": "The color used for indicating that a pull request is a draft." + }, + { + "id": "pullRequests.open", + "defaults": { + "dark": "issues.open", + "light": "issues.open", + "highContrast": "editor.background", + "highContrastLight": "editor.background" + }, + "description": "The color used for indicating that a pull request is open." + }, + { + "id": "pullRequests.closed", + "defaults": { + "dark": "issues.closed", + "light": "issues.closed", + "highContrast": "editor.background", + "highContrastLight": "editor.background" + }, + "description": "The color used for indicating that a pull request is closed." + }, + { + "id": "pullRequests.notification", + "defaults": { + "dark": "notificationsInfoIcon.foreground", + "light": "notificationsInfoIcon.foreground", + "highContrast": "editor.foreground", + "highContrastLight": "editor.foreground" + }, + "description": "The color used for indicating a notification on a pull request" + } + ], + "resourceLabelFormatters": [ + { + "scheme": "review", + "formatting": { + "label": "${path}", + "separator": "/", + "workspaceSuffix": "GitHub", + "stripPathStartingSeparator": true + } + } + ] + }, + "scripts": { + "postinstall": "yarn update-dts", + "bundle": "webpack --mode production --env esbuild", + "bundle:node": "webpack --mode production --config-name extension:node --config-name webviews", + "bundle:web": "webpack --mode production --config-name extension:webworker --config-name webviews", + "clean": "rm -r dist/", + "compile": "webpack --mode development --env esbuild", + "compile:test": "tsc -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.json --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}\"", + "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", + "browsertest:preprocess": "tsc ./src/test/browser/runTests.ts --outDir ./dist/browser/test --rootDir ./src/test/browser --target es6 --module commonjs", + "browsertest": "yarn run browsertest:preprocess && node ./dist/browser/test/runTests.js", + "test:preprocess-gql": "node scripts/preprocess-gql --in src/github/queries.gql --out out/src/github/queries.gql && node scripts/preprocess-gql --in src/github/queriesExtra.gql --out out/src/github/queriesExtra.gql && node scripts/preprocess-gql --in src/github/queriesShared.gql --out out/src/github/queriesShared.gql && node scripts/preprocess-gql --in src/github/queriesLimited.gql --out out/src/github/queriesLimited.gql", + "test:preprocess-svg": "node scripts/preprocess-svg --in ../resources/ --out out/resources", + "test:preprocess-fixtures": "node scripts/preprocess-fixtures --in src --out out", + "update-dts": "cd \"src/@types\" && npx vscode-dts main && npx vscode-dts dev", + "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" + }, + "devDependencies": { + "@types/chai": "^4.1.4", + "@types/glob": "7.1.3", + "@types/lru-cache": "^5.1.0", + "@types/marked": "^0.7.2", + "@types/mocha": "^8.2.2", + "@types/node": "18.17.1", + "@types/react": "^16.8.4", + "@types/react-dom": "^16.8.2", + "@types/sinon": "7.0.11", + "@types/temp": "0.8.34", + "@types/vscode": "1.79.0", + "@types/webpack-env": "^1.16.0", + "@typescript-eslint/eslint-plugin": "6.10.0", + "@typescript-eslint/parser": "6.10.0", + "@vscode/test-electron": "^2.3.8", + "@vscode/test-web": "^0.0.29", + "assert": "^2.0.0", + "buffer": "^6.0.3", + "constants-browserify": "^1.0.0", + "crypto-browserify": "3.12.0", + "css-loader": "5.1.3", + "esbuild-loader": "2.10.0", + "eslint": "7.22.0", + "eslint-cli": "1.1.1", + "eslint-plugin-import": "2.22.1", + "event-stream": "^4.0.1", + "fork-ts-checker-webpack-plugin": "6.1.1", + "glob": "7.1.6", + "graphql": "15.5.0", + "graphql-tag": "2.11.0", + "gulp-filter": "^7.0.0", + "husky": "^8.0.1", + "jsdom": "19.0.0", + "jsdom-global": "3.0.2", + "json5": "2.2.2", + "merge-options": "3.0.4", + "minimist": "^1.2.6", + "mkdirp": "1.0.4", + "mocha": "^9.0.1", + "mocha-junit-reporter": "1.23.0", + "mocha-multi-reporters": "1.1.7", + "os-browserify": "^0.3.0", + "p-all": "^1.0.0", + "path-browserify": "1.0.1", + "process": "^0.11.10", + "raw-loader": "4.0.2", + "react-testing-library": "7.0.1", + "sinon": "9.0.0", + "source-map-support": "0.5.19", + "stream-browserify": "^3.0.0", + "style-loader": "2.0.0", + "svg-inline-loader": "^0.8.2", + "temp": "0.9.4", + "terser-webpack-plugin": "5.1.1", + "timers-browserify": "^2.0.12", + "ts-loader": "8.0.18", + "tty": "1.0.1", + "typescript": "4.5.5", + "typescript-formatter": "^7.2.2", + "vinyl-fs": "^3.0.3", + "webpack": "5.76.0", + "webpack-cli": "4.2.0" + }, + "dependencies": { + "@octokit/rest": "18.2.1", + "@octokit/types": "6.10.1", + "@vscode/extension-telemetry": "0.7.5", + "apollo-boost": "^0.4.9", + "apollo-link-context": "1.0.20", + "cockatiel": "^3.1.1", + "cross-fetch": "3.1.5", + "dayjs": "1.10.4", + "debounce": "^1.2.1", + "events": "3.2.0", + "fast-deep-equal": "^3.1.3", + "lru-cache": "6.0.0", + "marked": "^4.0.10", + "react": "^16.12.0", + "react-dom": "^16.12.0", + "ssh-config": "4.1.1", + "tunnel": "0.0.6", + "url-search-params-polyfill": "^8.1.1", + "uuid": "8.3.2", + "vscode-tas-client": "^0.1.75", + "vsls": "^0.3.967" + }, + "license": "MIT" +} diff --git a/src/test/common/fixtures/gitdiff/03-large-many-changes.diff b/src/test/common/fixtures/gitdiff/03-large-many-changes.diff new file mode 100644 index 0000000000..4748b497b6 --- /dev/null +++ b/src/test/common/fixtures/gitdiff/03-large-many-changes.diff @@ -0,0 +1,96 @@ +diff --git a/03-large b/03-large-many-changes +index 51efe28a..4928a2ba 100644 +--- a/03-large ++++ b/03-large-many-changes +@@ -11,26 +11,9 @@ + "url": "https://github.com/Microsoft/vscode-pull-request-github/issues" + }, + "enabledApiProposals": [ +- "activeComment", +- "commentingRangeHint", +- "commentThreadApplicability", +- "contribCommentsViewThreadMenus", +- "tokenInformation", +- "contribShareMenu", +- "fileComments", +- "codeActionRanges", +- "commentReactor", +- "contribCommentPeekContext", +- "contribCommentThreadAdditionalMenu", +- "codiconDecoration", +- "diffCommand", +- "contribCommentEditorActionsMenu", +- "shareProvider", +- "quickDiffProvider", +- "tabInputTextMerge", +- "treeViewMarkdownMessage" ++ + ], +- "version": "0.84.0", ++ "version": "0.86.0", + "publisher": "GitHub", + "engines": { + "vscode": "^1.88.0" +@@ -232,7 +215,7 @@ + "default": true, + "description": "%githubPullRequests.defaultDeletionMethod.selectLocalBranch.description%" + }, +- "githubPullRequests.defaultDeletionMethod.selectRemote": { ++ "githubPullRequests.defaulteletionMethod.selectRemote": { + "type": "boolean", + "default": true, + "description": "%githubPullRequests.defaultDeletionMethod.selectRemote.description%" +@@ -1157,6 +1140,11 @@ + "title": "%command.review.comment.title%", + "category": "%command.pull.request.category%" + }, ++ { ++ "command": "review.commentDescription", ++ "title": "%command.review.comment.title%", ++ "category": "%command.pull.request.category%" ++ }, + { + "command": "review.requestChangesDescription", + "title": "%command.review.requestChanges.title%", +@@ -2495,6 +2483,18 @@ + "when": "commentController =~ /^github-(browse|review)/" + } + ], ++ "comments/comment2/context": [ ++ { ++ "command": "pr.saveComment", ++ "group": "inline@1", ++ "when": "commentController =~ /^github-(browse|review)/" ++ }, ++ { ++ "command": "pr.cancelEditComment", ++ "group": "inline@2", ++ "when": "commentController =~ /^github-(browse|review)/" ++ } ++ ], + "editor/context/copy": [ + { + "command": "issue.copyGithubPermalink", +@@ -2825,11 +2825,12 @@ + "lint:browser": "eslint --fix --cache --cache-location .eslintcache.browser --config .eslintrc.browser.json --ignore-pattern src/env/node/**/* \"{src,webviews}/**/*.{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", ++ "test:preprocess": "yarn run compile:test && yarn run test:preprocess-gql && yarn run test:preprocess-svg && yarn run test:preprocess-fixtures", + "browsertest:preprocess": "tsc ./src/test/browser/runTests.ts --outDir ./dist/browser/test --rootDir ./src/test/browser --target es6 --module commonjs", + "browsertest": "yarn run browsertest:preprocess && node ./dist/browser/test/runTests.js", + "test:preprocess-gql": "node scripts/preprocess-gql --in src/github/queries.gql --out out/src/github/queries.gql && node scripts/preprocess-gql --in src/github/queriesExtra.gql --out out/src/github/queriesExtra.gql && node scripts/preprocess-gql --in src/github/queriesShared.gql --out out/src/github/queriesShared.gql && node scripts/preprocess-gql --in src/github/queriesLimited.gql --out out/src/github/queriesLimited.gql", + "test:preprocess-svg": "node scripts/preprocess-svg --in ../resources/ --out out/resources", ++ "test:preprocess-fixtures": "node scripts/preprocess-fixtures --in src --out out", + "update-dts": "cd \"src/@types\" && npx vscode-dts main && npx vscode-dts dev", + "watch": "webpack --watch --mode development --env esbuild", + "watch:web": "webpack --watch --mode development --config-name extension:webworker --config-name webviews", +@@ -2842,7 +2843,7 @@ + "@types/lru-cache": "^5.1.0", + "@types/marked": "^0.7.2", + "@types/mocha": "^8.2.2", +- "@types/node": "12.12.70", ++ "@types/node": "18.17.1", + "@types/react": "^16.8.4", + "@types/react-dom": "^16.8.2", + "@types/sinon": "7.0.11", diff --git a/src/test/common/fixtures/gitdiff/generate-diffs.js b/src/test/common/fixtures/gitdiff/generate-diffs.js new file mode 100644 index 0000000000..6a04479292 --- /dev/null +++ b/src/test/common/fixtures/gitdiff/generate-diffs.js @@ -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. + *--------------------------------------------------------------------------------------------*/ + +const fs = require('fs'); +const cp = require('child_process'); +const path = require('path'); + + +fs.readdirSync(__dirname).forEach(function (file) { + const match = file.match(/^(\d\d-\w+)-[^.]+$/); + if (match) { + + const originalName = match[1]; + const diffName = `${file}.diff`; + try { + console.log(`Updating ${diffName}`); + cp.execFileSync(`git`, [`diff`, `--no-index`, `--relative`, `--output=${diffName}`, '--', originalName, file]); + } catch (e) { + } + } +}); \ No newline at end of file diff --git a/src/test/common/fixtures/gitdiff/sessionParsing.ts b/src/test/common/fixtures/gitdiff/sessionParsing.ts new file mode 100644 index 0000000000..8ffc905a3f --- /dev/null +++ b/src/test/common/fixtures/gitdiff/sessionParsing.ts @@ -0,0 +1,23 @@ +export const simpleDiff = `diff --git a/src/file.ts b/src/file.ts +index 1234567..abcdefg 100644 +--- a/src/file.ts ++++ b/src/file.ts +@@ -1,4 +1,4 @@ + export function hello() { +- console.log('hello'); ++ console.log('hello world'); + }` + +export const diffHeaders = `diff --git a/package.json b/package.json +index 1111111..2222222 100644 +--- a/package.json ++++ b/package.json +@@ -1,5 +1,5 @@ + { + "name": "test" + }`; + +export const diffNoAts = `diff --git a/file.txt b/file.txt +index 1234567..abcdefg 100644 +--- a/file.txt ++++ b/file.txt`; \ No newline at end of file diff --git a/src/test/common/getModifiedContentFromDiffHunk.test.ts b/src/test/common/getModifiedContentFromDiffHunk.test.ts new file mode 100644 index 0000000000..62c80782d2 --- /dev/null +++ b/src/test/common/getModifiedContentFromDiffHunk.test.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { promises as fs, readdirSync } from 'fs'; +import * as path from 'path'; +import { getModifiedContentFromDiffHunk } from '../../common/diffHunk'; + +describe('Real Diff Apply', function () { + createTestsFromFixtures(path.join(__dirname, './fixtures/gitdiff'), (original: string, diff: string, expected: string, messages: string[]) => { + const actual = getModifiedContentFromDiffHunk(original, diff); + assert.deepStrictEqual(actual, expected); + }); +}); + + +function createTestsFromFixtures(testDir: string, runTest: (original: string, diff: string, expected: string, messages: string[]) => void) { + const entries = readdirSync(testDir); + for (const entry of entries) { + + const match = entry.match(/^(\d\d-\w+)-([^.]+)$/); + if (match) { + it(`${match[1]} - ${match[2].replace(/_/g, ' ')}`, async () => { + const expected = await fs.readFile(path.join(testDir, entry), 'utf8'); + const diff = await fs.readFile(path.join(testDir, `${entry}.diff`), 'utf8'); + const original = await fs.readFile(path.join(testDir, match[1]), 'utf8'); + let messages = []; + try { + messages = JSON.parse(await fs.readFile(path.join(testDir, `${entry}.messages`), 'utf8')); + } catch (e) { + // ignore + } + runTest(original, diff, expected, messages); + }); + } + } +} diff --git a/src/test/common/sessionParsing.test.ts b/src/test/common/sessionParsing.test.ts new file mode 100644 index 0000000000..0ea95e865d --- /dev/null +++ b/src/test/common/sessionParsing.test.ts @@ -0,0 +1,609 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { + parseSessionLogs, + parseToolCallDetails, + parseDiff, + toFileLabel, + SessionResponseLogChunk +} from '../../../common/sessionParsing'; +import { diffHeaders, diffNoAts, simpleDiff } from './fixtures/gitdiff/sessionParsing'; + +// Helper to construct a toolCall object +function makeToolCall(name: string, args: any): any { + return { + function: { name, arguments: JSON.stringify(args) }, + id: 'id_' + name + '_' + Math.random().toString(36).slice(2), + type: 'function', + index: 0 + }; +} + +describe('sessionParsing', function () { + describe('parseSessionLogs()', function () { + it('should parse valid session logs', function () { + const rawText = `data: {"choices":[{"finish_reason":"tool_calls","delta":{"content":"","role":"assistant","tool_calls":[{"function":{"arguments":"{\\"command\\": \\"view\\", \\"path\\": \\"/home/runner/work/repo/repo/src/file.ts\\"}","name":"str_replace_editor"},"id":"call_123","type":"function","index":0}]}}],"created":1640995200,"id":"chatcmpl-123","usage":{"completion_tokens":10,"prompt_tokens":50,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":60},"model":"gpt-4","object":"chat.completion.chunk"}`; + + const result = parseSessionLogs(rawText); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].choices.length, 1); + assert.strictEqual(result[0].choices[0].finish_reason, 'tool_calls'); + assert.strictEqual(result[0].choices[0].delta.role, 'assistant'); + assert.strictEqual(result[0].choices[0].delta.tool_calls?.length, 1); + assert.strictEqual(result[0].choices[0].delta.tool_calls?.[0].function.name, 'str_replace_editor'); + }); + + it('should handle malformed JSON gracefully', function () { + const rawText = `data: {"invalid": "json" +data: {"choices":[{"finish_reason":"stop","delta":{"content":"Hello","role":"assistant"}}],"created":1640995200,"id":"chatcmpl-123","usage":{"completion_tokens":1,"prompt_tokens":10,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":11},"model":"gpt-4","object":"chat.completion.chunk"}`; + + assert.throws(() => { + parseSessionLogs(rawText); + }); + }); + + it('should parse tool calls correctly', function () { + const rawText = `data: {"choices":[{"finish_reason":"tool_calls","delta":{"content":"","role":"assistant","tool_calls":[{"function":{"arguments":"{\\"command\\": \\"bash\\", \\"args\\": \\"ls -la\\"}","name":"bash"},"id":"call_456","type":"function","index":0}]}}],"created":1640995200,"id":"chatcmpl-456","usage":{"completion_tokens":5,"prompt_tokens":20,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":25},"model":"gpt-4","object":"chat.completion.chunk"}`; + + const result = parseSessionLogs(rawText); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].choices[0].delta.tool_calls?.[0].function.name, 'bash'); + }); + + it('should filter out non-data lines', function () { + const rawText = `some random line +data: {"choices":[{"finish_reason":"stop","delta":{"content":"Hello","role":"assistant"}}],"created":1640995200,"id":"chatcmpl-123","usage":{"completion_tokens":1,"prompt_tokens":10,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":11},"model":"gpt-4","object":"chat.completion.chunk"} +another non-data line`; + + const result = parseSessionLogs(rawText); + + assert.strictEqual(result.length, 1); + }); + }); + + describe('parseToolCallDetails()', function () { + it('should handle empty arguments string (covers ternary else)', function () { + // forces the ternary at line ~165 in sessionParsing.ts to take the else branch + const toolCall = { + function: { + name: 'str_replace_editor', + arguments: '' // empty string -> falsy -> args stays {} + }, + id: 'call_empty_args', + type: 'function', + index: 0 + }; + + const result = parseToolCallDetails(toolCall as any, ''); + assert.strictEqual(result.toolName, 'Edit'); + assert.strictEqual(result.invocationMessage, 'Edit'); + }); + it('should parse str_replace_editor tool calls with view command', function () { + const toolCall = { + function: { + name: 'str_replace_editor', + arguments: '{"command": "view", "path": "/home/runner/work/repo/repo/src/example.ts"}' + }, + id: 'call_123', + type: 'function', + index: 0 + }; + + const result = parseToolCallDetails(toolCall, ''); + + assert.strictEqual(result.toolName, 'Read'); + assert.strictEqual(result.invocationMessage, 'Read src/example.ts'); + assert.strictEqual(result.pastTenseMessage, 'Read src/example.ts'); + if (result.toolSpecificData && 'command' in result.toolSpecificData) { + assert.strictEqual(result.toolSpecificData.command, 'view'); + } + }); + + it('should parse str_replace_editor tool calls with edit command', function () { + const toolCall = { + function: { + name: 'str_replace_editor', + arguments: '{"command": "str_replace", "path": "/home/runner/work/repo/repo/src/example.ts"}' + }, + id: 'call_123', + type: 'function', + index: 0 + }; + + const result = parseToolCallDetails(toolCall, ''); + + assert.strictEqual(result.toolName, 'Edit'); + assert.strictEqual(result.invocationMessage, 'Edit [](src/example.ts)'); + assert.strictEqual(result.pastTenseMessage, 'Edit [](src/example.ts)'); + if (result.toolSpecificData && 'command' in result.toolSpecificData) { + assert.strictEqual(result.toolSpecificData.command, 'str_replace'); + } + }); + + it('should parse bash tool calls', function () { + const toolCall = { + function: { + name: 'bash', + arguments: '{"command": "npm test"}' + }, + id: 'call_456', + type: 'function', + index: 0 + }; + + const result = parseToolCallDetails(toolCall, 'Test output here'); + + assert.strictEqual(result.toolName, 'Run Bash command'); + assert.strictEqual(result.invocationMessage, '$ npm test\nTest output here'); + if (result.toolSpecificData && 'language' in result.toolSpecificData) { + assert.strictEqual(result.toolSpecificData.language, 'bash'); + assert.strictEqual(result.toolSpecificData.commandLine.original, 'npm test'); + } + }); + + it('should parse think tool calls', function () { + const toolCall = { + function: { + name: 'think', + arguments: '{}' + }, + id: 'call_789', + type: 'function', + index: 0 + }; + + const result = parseToolCallDetails(toolCall, 'I need to analyze this code'); + + assert.strictEqual(result.toolName, 'think'); + assert.strictEqual(result.invocationMessage, 'I need to analyze this code'); + }); + + it('should default think tool call to Thought when no args.thought and no content', function () { + const toolCall = { + function: { + name: 'think', + arguments: '{}' // no thought provided + }, + id: 'call_790', + type: 'function', + index: 0 + }; + + // Pass empty string content so code falls back to 'Thought' + const result = parseToolCallDetails(toolCall, ''); + assert.strictEqual(result.toolName, 'think'); + assert.strictEqual(result.invocationMessage, 'Thought'); + }); + + it('should parse report_progress tool calls', function () { + const toolCall = { + function: { + name: 'report_progress', + arguments: '{"prDescription": "Updated the test files", "commitMessage": "feat: add new tests"}' + }, + id: 'call_101', + type: 'function', + index: 0 + }; + + const result = parseToolCallDetails(toolCall, ''); + + assert.strictEqual(result.toolName, 'Progress Update'); + assert.strictEqual(result.invocationMessage, 'Updated the test files'); + assert.strictEqual(result.originMessage, 'Commit: feat: add new tests'); + }); + + it('report_progress falls back to content when prDescription empty string', function () { + // prDescription provided but empty => falsy, so chain uses content + const toolCall = { + function: { + name: 'report_progress', + arguments: '{"prDescription": ""}' + }, + id: 'call_102', + type: 'function', + index: 0 + }; + + const fallbackContent = 'Using content as progress update'; + const result = parseToolCallDetails(toolCall, fallbackContent); + assert.strictEqual(result.toolName, 'Progress Update'); + assert.strictEqual(result.invocationMessage, fallbackContent); + }); + + it('report_progress falls back to default when prDescription and content empty', function () { + // Both prDescription (empty string) and content ('') are falsy => 'Progress Update' + const toolCall = { + function: { + name: 'report_progress', + arguments: '{"prDescription": ""}' + }, + id: 'call_103', + type: 'function', + index: 0 + }; + + const result = parseToolCallDetails(toolCall, ''); + assert.strictEqual(result.toolName, 'Progress Update'); + assert.strictEqual(result.invocationMessage, 'Progress Update'); + }); + + it('should handle unknown tool types', function () { + const toolCall = { + function: { + name: 'unknown_tool', + arguments: '{"param": "value"}' + }, + id: 'call_999', + type: 'function', + index: 0 + }; + + const result = parseToolCallDetails(toolCall, 'some content'); + + assert.strictEqual(result.toolName, 'unknown_tool'); + assert.strictEqual(result.invocationMessage, 'some content'); + }); + + it('should handle malformed tool arguments', function () { + const toolCall = { + function: { + name: 'str_replace_editor', + arguments: '{"invalid": json}' + }, + id: 'call_error', + type: 'function', + index: 0 + }; + + const result = parseToolCallDetails(toolCall, ''); + + // Should fall back gracefully with empty args - goes to the 'else' branch which returns 'Edit' + assert.strictEqual(result.toolName, 'Edit'); + }); + + it('should handle repository root paths correctly', function () { + const toolCall = { + function: { + name: 'str_replace_editor', + arguments: '{"command": "view", "path": "/home/runner/work/repo/repo/"}' + }, + id: 'call_root', + type: 'function', + index: 0 + }; + + const result = parseToolCallDetails(toolCall, ''); + + assert.strictEqual(result.toolName, 'Read repository'); + assert.strictEqual(result.invocationMessage, 'Read repository'); + }); + + it('handles str_replace_editor view with diff-parsed content (empty file label -> repository)', function () { + const diff = [ + 'diff --git a/src/file.ts b/src/file.ts', + 'index 1111111..2222222 100644', + '--- a/src/file.ts', + '+++ b/src/file.ts', + '@@ -1,2 +1,2 @@', + '-old line', + '+new line' + ].join('\n'); + const toolCall = makeToolCall('str_replace_editor', { command: 'view', view_range: [1, 10] }); + const result = parseToolCallDetails(toolCall, diff); + assert.strictEqual(result.toolName, 'Read repository'); + assert.strictEqual(result.invocationMessage, 'Read repository'); + }); + + it('handles str_replace_editor view with diff-parsed content (non-empty file label)', function () { + const diff = [ + 'diff --git a/home/runner/work/repo/repo/src/deep/file.ts b/home/runner/work/repo/repo/src/deep/file.ts', + 'index 1111111..2222222 100644', + '--- a/home/runner/work/repo/repo/src/deep/file.ts', + '+++ b/home/runner/work/repo/repo/src/deep/file.ts', + '@@ -1,2 +1,2 @@', + '-old line', + '+new line' + ].join('\n'); + const toolCall = makeToolCall('str_replace_editor', { command: 'view', view_range: [2, 8] }); + const result = parseToolCallDetails(toolCall, diff); + assert.strictEqual(result.toolName, 'Read'); + assert.ok(result.invocationMessage.includes('src/deep/file.ts')); + assert.ok(result.invocationMessage.includes('lines 2 to 8')); + assert.ok(result.toolSpecificData && 'command' in result.toolSpecificData); + }); + + it('handles str_replace_editor view with diff-parsed content and no range (parsedRange undefined)', function () { + // This exercises the branch where parsedRange is falsy so no ", lines X to Y" suffix is appended + const diff = [ + 'diff --git a/home/runner/work/repo/repo/src/another/file.ts b/home/runner/work/repo/repo/src/another/file.ts', + 'index aaaaaaa..bbbbbbb 100644', + '--- a/home/runner/work/repo/repo/src/another/file.ts', + '+++ b/home/runner/work/repo/repo/src/another/file.ts', + '@@ -1,2 +1,2 @@', + '-old line', + '+new line' + ].join('\n'); + const toolCall = makeToolCall('str_replace_editor', { command: 'view' }); // no view_range provided + const result = parseToolCallDetails(toolCall, diff); + assert.strictEqual(result.toolName, 'Read'); + assert.ok(result.invocationMessage.includes('src/another/file.ts')); + assert.ok(!/lines \d+ to \d+/.test(result.invocationMessage), 'invocationMessage should not contain line range'); + assert.ok(result.pastTenseMessage && result.pastTenseMessage === result.invocationMessage); + }); + + it('handles str_replace_editor view with path but unparsable diff content (no diff headers)', function () { + const content = 'just some file content without diff headers'; + const toolCall = makeToolCall('str_replace_editor', { command: 'view', path: '/home/runner/work/repo/repo/src/other.ts' }); + const result = parseToolCallDetails(toolCall, content); + assert.strictEqual(result.toolName, 'Read'); + assert.strictEqual(result.invocationMessage, 'Read src/other.ts'); + }); + + it('handles str_replace_editor view with path and range (parsedRange defined)', function () { + // This covers the branch in sessionParsing.ts lines ~202-212 where parsedRange is defined + // and a normal file path (no diff content) is provided so invocationMessage includes the lines suffix. + const toolCall = makeToolCall('str_replace_editor', { command: 'view', path: '/home/runner/work/repo/repo/src/ranged.ts', view_range: [4, 9] }); + const result = parseToolCallDetails(toolCall, 'plain file content'); + assert.strictEqual(result.toolName, 'Read'); + assert.strictEqual(result.invocationMessage, 'Read src/ranged.ts, lines 4 to 9'); + assert.strictEqual(result.pastTenseMessage, 'Read src/ranged.ts, lines 4 to 9'); + assert.ok(result.toolSpecificData && 'viewRange' in result.toolSpecificData, 'Expected viewRange in toolSpecificData'); + if (result.toolSpecificData && 'viewRange' in result.toolSpecificData) { + assert.strictEqual(result.toolSpecificData.viewRange?.start, 4); + assert.strictEqual(result.toolSpecificData.viewRange?.end, 9); + } + }); + + it('handles str_replace_editor view with diff hunk but no diff header (fileA undefined)', function () { + // This diff content has an @@ hunk so parseDiff returns an object, but no 'diff --git' header, + // therefore fileA and fileB remain undefined. This exercises the fallback at line ~177 where + // file is chosen via parsedContent.fileA ?? parsedContent.fileB resulting in undefined and thus + // a repository-level read. + const diffOnlyHunk = [ + '@@ -1,2 +1,2 @@', + '-old line', + '+new line' + ].join('\n'); + const toolCall = makeToolCall('str_replace_editor', { command: 'view', view_range: [1, 2] }); + const result = parseToolCallDetails(toolCall, diffOnlyHunk); + // fileLabel is undefined so toolName is 'Read' but invocation message falls back to 'Read repository' + assert.strictEqual(result.toolName, 'Read'); + assert.strictEqual(result.invocationMessage, 'Read repository'); + }); + + it('handles str_replace_editor view with undefined path (no label)', function () { + const toolCall = makeToolCall('str_replace_editor', { command: 'view' }); + const result = parseToolCallDetails(toolCall, 'plain content'); + assert.strictEqual(result.toolName, 'Read repository'); + assert.strictEqual(result.invocationMessage, 'Read repository'); + }); + + it('handles str_replace_editor view with root repository path empty label branch', function () { + const toolCall = makeToolCall('str_replace_editor', { command: 'view', path: '/home/runner/work/repo/repo/' }); + const result = parseToolCallDetails(toolCall, 'content'); + assert.strictEqual(result.toolName, 'Read repository'); + }); + + it('handles str_replace_editor edit with range', function () { + const toolCall = makeToolCall('str_replace_editor', { command: 'edit', path: '/home/runner/work/repo/repo/src/editMe.ts', view_range: [5, 15] }); + const result = parseToolCallDetails(toolCall, ''); + assert.strictEqual(result.toolName, 'Edit'); + assert.ok(result.invocationMessage.includes('lines 5 to 15')); + }); + + it('handles str_replace_editor edit when args.command is undefined (defaults to edit)', function () { + // Covers sessionParsing.ts lines 220-230 where args.command || 'edit' supplies default + const toolCall = makeToolCall('str_replace_editor', { path: '/home/runner/work/repo/repo/src/implicitEdit.ts' }); + const result = parseToolCallDetails(toolCall, ''); + assert.strictEqual(result.toolName, 'Edit'); + assert.strictEqual(result.invocationMessage, 'Edit [](src/implicitEdit.ts)'); + assert.ok(result.toolSpecificData && 'command' in result.toolSpecificData, 'Expected toolSpecificData for edit operation'); + if (result.toolSpecificData && 'command' in result.toolSpecificData) { + assert.strictEqual(result.toolSpecificData.command, 'edit'); // default applied + } + }); + + it('handles str_replace (non-editor) path missing label fallback', function () { + // Provide a path that toFileLabel will still shorten; assert structure + const toolCall = makeToolCall('str_replace', { path: '/home/runner/work/repo/repo/src/x.ts' }); + const result = parseToolCallDetails(toolCall, ''); + assert.strictEqual(result.toolName, 'Edit'); + assert.strictEqual(result.invocationMessage, 'Edit [](src/x.ts)'); + }); + + it('handles str_replace with undefined path (fileLabel undefined)', function () { + // No path provided -> filePath undefined -> fileLabel undefined, should fall back to `Edit ${filePath}` which is 'Edit undefined' + const toolCall = makeToolCall('str_replace', { /* no path */ }); + const result = parseToolCallDetails(toolCall, ''); + assert.strictEqual(result.toolName, 'Edit'); + assert.strictEqual(result.invocationMessage, 'Edit undefined'); + assert.strictEqual(result.pastTenseMessage, 'Edit undefined'); + assert.strictEqual(result.toolSpecificData, undefined); + }); + + it('handles create tool call', function () { + const toolCall = makeToolCall('create', { path: '/home/runner/work/repo/repo/new/file.txt' }); + const result = parseToolCallDetails(toolCall, ''); + assert.strictEqual(result.toolName, 'Create'); + assert.strictEqual(result.invocationMessage, 'Create [](new/file.txt)'); + }); + + it('handles create tool call without path (fileLabel undefined)', function () { + const toolCall = makeToolCall('create', { /* no path provided */ }); + const result = parseToolCallDetails(toolCall, ''); + assert.strictEqual(result.toolName, 'Create'); + assert.strictEqual(result.invocationMessage, 'Create File undefined'); + assert.strictEqual(result.pastTenseMessage, 'Create File undefined'); + assert.strictEqual(result.toolSpecificData, undefined); + }); + + it('handles view tool call (non str_replace_editor) with range and root path giving repository label', function () { + const toolCall = makeToolCall('view', { path: '/home/runner/work/repo/repo/', view_range: [2, 3] }); + const result = parseToolCallDetails(toolCall, ''); + assert.strictEqual(result.toolName, 'Read repository'); + assert.strictEqual(result.invocationMessage, 'Read repository'); + }); + + it('handles view tool call (non str_replace_editor) with file path and range', function () { + const toolCall = makeToolCall('view', { path: '/home/runner/work/repo/repo/src/app.ts', view_range: [3, 7] }); + const result = parseToolCallDetails(toolCall, ''); + assert.strictEqual(result.toolName, 'Read'); + assert.ok(result.invocationMessage.includes('lines 3 to 7')); + }); + + it('handles view tool call (non str_replace_editor) with file path and no range (parsedRange undefined)', function () { + // Covers lines 261-275 in sessionParsing.ts where parsedRange is falsy, so + // the ", lines X to Y" suffix should NOT be appended. + const toolCall = makeToolCall('view', { path: '/home/runner/work/repo/repo/src/noRange.ts' }); + const result = parseToolCallDetails(toolCall, 'file content'); + assert.strictEqual(result.toolName, 'Read'); + assert.ok(result.invocationMessage === 'Read [](src/noRange.ts)', 'invocationMessage should not contain line range'); + assert.ok(result.pastTenseMessage === 'Read [](src/noRange.ts)', 'pastTenseMessage should not contain line range'); + assert.ok(result.toolSpecificData && 'viewRange' in result.toolSpecificData && !result.toolSpecificData.viewRange, 'viewRange should be undefined'); + }); + + it('handles bash tool call without command (only content)', function () { + const toolCall = makeToolCall('bash', {}); + const result = parseToolCallDetails(toolCall, 'only output'); + assert.strictEqual(result.toolName, 'Run Bash command'); + assert.strictEqual(result.invocationMessage, 'only output'); + assert.ok(!result.toolSpecificData); // no command so no toolSpecificData + }); + + it('handles bash tool call without command and without content (fallback to default message)', function () { + // Exercises bashContent empty so code uses 'Run Bash command' fallback (lines ~292-300) + const toolCall = makeToolCall('bash', {}); + const result = parseToolCallDetails(toolCall, ''); + assert.strictEqual(result.toolName, 'Run Bash command'); + assert.strictEqual(result.invocationMessage, 'Run Bash command'); + }); + + it('handles read_bash tool call', function () { + const toolCall = makeToolCall('read_bash', {}); + const result = parseToolCallDetails(toolCall, 'ignored'); + assert.strictEqual(result.toolName, 'read_bash'); + assert.strictEqual(result.invocationMessage, 'Read logs from Bash session'); + }); + + it('handles stop_bash tool call', function () { + const toolCall = makeToolCall('stop_bash', {}); + const result = parseToolCallDetails(toolCall, 'ignored'); + assert.strictEqual(result.toolName, 'stop_bash'); + assert.strictEqual(result.invocationMessage, 'Stop Bash session'); + }); + + it('handles unknown tool call with empty content falling back to name', function () { + const toolCall = makeToolCall('mystery_tool', { some: 'arg' }); + const result = parseToolCallDetails(toolCall, ''); + assert.strictEqual(result.toolName, 'mystery_tool'); + assert.strictEqual(result.invocationMessage, 'mystery_tool'); + }); + + it('handles unknown tool call with falsy name (empty string) returning unknown', function () { + // Directly craft toolCall without using makeToolCall so we can force empty name + const toolCall = { + function: { name: '', arguments: '{}' }, + id: 'call_empty_name', + type: 'function', + index: 0 + }; + const result = parseToolCallDetails(toolCall as any, ''); + assert.strictEqual(result.toolName, 'unknown'); + assert.strictEqual(result.invocationMessage, 'unknown'); + }); + + it('gracefully handles invalid JSON arguments for non-view str_replace_editor (edit path undefined)', function () { + const toolCall = { + function: { name: 'str_replace_editor', arguments: '{"command": "edit", invalid' }, + id: 'bad_json', + type: 'function', + index: 0 + }; + // Since JSON parse fails, args becomes {} and we are in else branch -> toolName Edit without file label + const result = parseToolCallDetails(toolCall as any, ''); + assert.strictEqual(result.toolName, 'Edit'); + assert.strictEqual(result.invocationMessage, 'Edit'); + }); + + it('handles str_replace_editor view with no path and no range (fileLabel undefined branch)', function () { + // Triggers the branch where args.path is undefined and thus fileLabel is undefined + const toolCall = makeToolCall('str_replace_editor', { command: 'view', path: '' }); + const result = parseToolCallDetails(toolCall, 'plain non-diff content'); + assert.strictEqual(result.toolName, 'Read repository'); + assert.strictEqual(result.invocationMessage, 'Read repository'); + assert.strictEqual(result.pastTenseMessage, 'Read repository'); + }); + }); + + describe('parseDiff()', function () { + it('should parse diff content correctly', function () { + const result = parseDiff(simpleDiff); + + assert(result); + assert.strictEqual(result.fileA, '/src/file.ts'); + assert.strictEqual(result.fileB, '/src/file.ts'); + assert(result.content.includes("export function hello()")); + assert(result.content.includes("console.log('hello world')")); + }); + + it('should extract file paths from diff headers', function () { + const result = parseDiff(diffHeaders); + + assert(result); + assert.strictEqual(result.fileA, '/package.json'); + assert.strictEqual(result.fileB, '/package.json'); + }); + + it('should handle malformed diffs', function () { + const diffContent = `not a diff at all`; + + const result = parseDiff(diffContent); + + assert.strictEqual(result, undefined); + }); + + it('should handle diffs without @@ lines', function () { + const result = parseDiff(diffNoAts); + + assert.strictEqual(result, undefined); + }); + }); + + describe('toFileLabel()', function () { + it('should convert absolute paths to relative labels', function () { + const path = '/home/runner/work/repo/repo/src/components/Button.tsx'; + + const result = toFileLabel(path); + + assert.strictEqual(result, 'src/components/Button.tsx'); + }); + + it('should handle various path formats', function () { + assert.strictEqual(toFileLabel('/home/runner/work/repo/repo/package.json'), 'package.json'); + assert.strictEqual(toFileLabel('/home/runner/work/repo/repo/src/index.ts'), 'src/index.ts'); + assert.strictEqual(toFileLabel('/home/runner/work/repo/repo/docs/README.md'), 'docs/README.md'); + }); + + it('should handle edge cases', function () { + assert.strictEqual(toFileLabel('/home/runner/work/repo/repo/'), ''); + assert.strictEqual(toFileLabel('/'), ''); + assert.strictEqual(toFileLabel(''), ''); + }); + + it('should handle shorter paths', function () { + const shortPath = '/home/runner/work/repo'; + + const result = toFileLabel(shortPath); + + // Should return empty string when path is too short + assert.strictEqual(result, ''); + }); + }); +}); diff --git a/src/test/common/uri.test.ts b/src/test/common/uri.test.ts new file mode 100644 index 0000000000..bde856e8a4 --- /dev/null +++ b/src/test/common/uri.test.ts @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * 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); + }); + + it('should work for open-pull-request-changes path', () => { + const uri = vscode.Uri.parse('vscode://github.vscode-pull-request-github/open-pull-request-changes?uri=https://github.com/test/example/pull/999'); + const result = fromOpenOrCheckoutPullRequestWebviewUri(uri); + + assert.strictEqual(result?.owner, 'test'); + assert.strictEqual(result?.repo, 'example'); + assert.strictEqual(result?.pullRequestNumber, 999); + }); + + it('should parse JSON format for open-pull-request-changes path', () => { + const uri = vscode.Uri.parse('vscode://github.vscode-pull-request-github/open-pull-request-changes?%7B%22owner%22%3A%22testowner%22%2C%22repo%22%3A%22testrepo%22%2C%22pullRequestNumber%22%3A123%7D'); + const result = fromOpenOrCheckoutPullRequestWebviewUri(uri); + + assert.strictEqual(result?.owner, 'testowner'); + assert.strictEqual(result?.repo, 'testrepo'); + assert.strictEqual(result?.pullRequestNumber, 123); + }); + }); +}); diff --git a/src/test/common/utils.test.ts b/src/test/common/utils.test.ts index 10b43aa2dc..aabe5b77d3 100644 --- a/src/test/common/utils.test.ts +++ b/src/test/common/utils.test.ts @@ -42,12 +42,12 @@ describe('utils', () => { }); it('should format an error with field errors', () => { - const error = new HookError('Validation Failed', [{ field: 'title', value: 'garbage', code: 'custom' }]); + const error = new HookError('Validation Failed', [{ field: 'title', value: 'garbage', status: 'custom' }]); assert.strictEqual(utils.formatError(error), 'Validation Failed: Value "garbage" cannot be set for field title (code: custom)'); }); it('should format an error with custom ', () => { - const error = new HookError('Validation Failed', [{ message: 'Cannot push to this repo', code: 'custom' }]); + const error = new HookError('Validation Failed', [{ message: 'Cannot push to this repo', status: 'custom' }]); assert.strictEqual(utils.formatError(error), 'Cannot push to this repo'); }); }); diff --git a/src/test/extension.isSubmodule.test.ts b/src/test/extension.isSubmodule.test.ts new file mode 100644 index 0000000000..c29ce07a37 --- /dev/null +++ b/src/test/extension.isSubmodule.test.ts @@ -0,0 +1,121 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Repository, Submodule } from '../api/api'; +import { GitApiImpl } from '../api/api1'; +import { isSubmodule } from '../common/gitUtils' + +describe('isSubmodule Tests', function () { + it('should return false for repositories with no submodules', () => { + const mockRepo: Repository = { + rootUri: vscode.Uri.file('/home/user/repo1'), + state: { + submodules: [], + remotes: [], + HEAD: undefined, + rebaseCommit: undefined, + mergeChanges: [], + indexChanges: [], + workingTreeChanges: [], + onDidChange: new vscode.EventEmitter<void>().event, + }, + } as Partial<Repository> as Repository; + + const mockGit: GitApiImpl = { + repositories: [mockRepo], + } as GitApiImpl; + + const result = isSubmodule(mockRepo, mockGit); + assert.strictEqual(result, false); + }); + + it('should return true when repository is listed as submodule in another repo', () => { + const submoduleRepo: Repository = { + rootUri: vscode.Uri.file('/home/user/parent/submodule'), + state: { + submodules: [], + remotes: [], + HEAD: undefined, + rebaseCommit: undefined, + mergeChanges: [], + indexChanges: [], + workingTreeChanges: [], + onDidChange: new vscode.EventEmitter<void>().event, + }, + } as Partial<Repository> as Repository; + + const parentRepo: Repository = { + rootUri: vscode.Uri.file('/home/user/parent'), + state: { + submodules: [ + { + name: 'submodule', + path: 'submodule', + url: 'https://github.com/example/submodule.git' + } as Submodule + ], + remotes: [], + HEAD: undefined, + rebaseCommit: undefined, + mergeChanges: [], + indexChanges: [], + workingTreeChanges: [], + onDidChange: new vscode.EventEmitter<void>().event, + }, + } as Partial<Repository> as Repository; + + const mockGit: GitApiImpl = { + repositories: [parentRepo, submoduleRepo], + } as GitApiImpl; + + const result = isSubmodule(submoduleRepo, mockGit); + assert.strictEqual(result, true); + }); + + it('should return false when repository is not listed as submodule', () => { + const repo1: Repository = { + rootUri: vscode.Uri.file('/home/user/repo1'), + state: { + submodules: [], + remotes: [], + HEAD: undefined, + rebaseCommit: undefined, + mergeChanges: [], + indexChanges: [], + workingTreeChanges: [], + onDidChange: new vscode.EventEmitter<void>().event, + }, + } as Partial<Repository> as Repository; + + const repo2: Repository = { + rootUri: vscode.Uri.file('/home/user/repo2'), + state: { + submodules: [ + { + name: 'different-submodule', + path: 'different-submodule', + url: 'https://github.com/example/different.git' + } as Submodule + ], + remotes: [], + HEAD: undefined, + rebaseCommit: undefined, + mergeChanges: [], + indexChanges: [], + workingTreeChanges: [], + onDidChange: new vscode.EventEmitter<void>().event, + }, + } as Partial<Repository> as Repository; + + const mockGit: GitApiImpl = { + repositories: [repo1, repo2], + } as GitApiImpl; + + const result = isSubmodule(repo1, mockGit); + assert.strictEqual(result, false); + }); +}); diff --git a/src/test/github/copilotPrWatcher.test.ts b/src/test/github/copilotPrWatcher.test.ts new file mode 100644 index 0000000000..678070192e --- /dev/null +++ b/src/test/github/copilotPrWatcher.test.ts @@ -0,0 +1,136 @@ +/*--------------------------------------------------------------------------------------------- + * 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, 0); + assert.strictEqual(notifications.length, 0); + assert.strictEqual(model.notifications.size, 0); + + model.set([{ item: pr, status: CopilotPRStatus.Started }]); + assert.strictEqual(changeEvents, 0); + + 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, 1); + 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.Completed }]); + + 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.Completed }]); + 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, 2); + assert.strictEqual(notifications.length, 1); + assert.deepStrictEqual(notifications[0], [prTwo, prThree]); + assert.strictEqual(model.getNotificationsCount('octo', 'repo'), 1); + 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); + + model.clearAllNotifications(); + assert.strictEqual(model.notifications.size, 0); + + 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/folderRepositoryManager.test.ts b/src/test/github/folderRepositoryManager.test.ts index 5a4e4e007d..6d6f6c3114 100644 --- a/src/test/github/folderRepositoryManager.test.ts +++ b/src/test/github/folderRepositoryManager.test.ts @@ -21,21 +21,27 @@ import { CredentialStore } from '../../github/credentials'; import { MockExtensionContext } from '../mocks/mockExtensionContext'; import { Uri } from 'vscode'; import { GitHubServerType } from '../../common/authentication'; +import { CreatePullRequestHelper } from '../../view/createPullRequestHelper'; +import { RepositoriesManager } from '../../github/repositoriesManager'; +import { MockThemeWatcher } from '../mocks/mockThemeWatcher'; describe('PullRequestManager', function () { let sinon: SinonSandbox; let manager: FolderRepositoryManager; let telemetry: MockTelemetry; + let mockThemeWatcher: MockThemeWatcher; beforeEach(function () { sinon = createSandbox(); MockCommandRegistry.install(sinon); telemetry = new MockTelemetry(); + mockThemeWatcher = new MockThemeWatcher(); const repository = new MockRepository(); const context = new MockExtensionContext(); const credentialStore = new CredentialStore(telemetry, context); - manager = new FolderRepositoryManager(context, repository, telemetry, new GitApiImpl(), credentialStore); + const repositoriesManager = new RepositoriesManager(credentialStore, telemetry); + manager = new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(repositoriesManager), credentialStore, new CreatePullRequestHelper(), mockThemeWatcher); }); afterEach(function () { @@ -53,9 +59,9 @@ describe('PullRequestManager', function () { const protocol = new Protocol(url); const remote = new GitHubRemote('origin', url, protocol, GitHubServerType.GitHubDotCom); const rootUri = Uri.file('C:\\users\\test\\repo'); - const repository = new GitHubRepository(remote, rootUri, manager.credentialStore, telemetry); + const repository = new GitHubRepository(1, remote, rootUri, manager.credentialStore, telemetry); const prItem = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().build(), repository); - const pr = new PullRequestModel(telemetry, repository, remote, prItem); + const pr = new PullRequestModel(manager.credentialStore, telemetry, repository, remote, prItem); manager.activePullRequest = pr; assert(changeFired.called); @@ -65,27 +71,163 @@ describe('PullRequestManager', function () { }); describe('titleAndBodyFrom', function () { - it('separates title and body', function () { - const message = 'title\n\ndescription 1\n\ndescription 2\n'; + it('separates title and body', async function () { + const message = Promise.resolve('title\n\ndescription 1\n\ndescription 2\n'); - const { title, body } = titleAndBodyFrom(message); - assert.strictEqual(title, 'title'); - assert.strictEqual(body, 'description 1\n\ndescription 2'); + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'description 1\n\ndescription 2'); }); - it('returns only title with no body', function () { - const message = 'title'; + it('returns only title with no body', async function () { + const message = Promise.resolve('title'); - const { title, body } = titleAndBodyFrom(message); - assert.strictEqual(title, 'title'); - assert.strictEqual(body, ''); + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, ''); }); - it('returns only title when body contains only whitespace', function () { - const message = 'title\n\n'; + it('returns only title when body contains only whitespace', async function () { + const message = Promise.resolve('title\n\n'); - const { title, body } = titleAndBodyFrom(message); - assert.strictEqual(title, 'title'); - assert.strictEqual(body, ''); + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, ''); + }); + + it('unwraps wrapped lines in body', async function () { + const message = Promise.resolve('title\n\nThis is a long line that has been wrapped at 72 characters\nto fit the conventional commit message format.'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'This is a long line that has been wrapped at 72 characters to fit the conventional commit message format.'); + }); + + it('preserves blank lines as paragraph breaks', async function () { + const message = Promise.resolve('title\n\nFirst paragraph that is wrapped\nacross multiple lines.\n\nSecond paragraph that is also wrapped\nacross multiple lines.'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'First paragraph that is wrapped across multiple lines.\n\nSecond paragraph that is also wrapped across multiple lines.'); + }); + + it('preserves list items', async function () { + const message = Promise.resolve('title\n\n- First item\n- Second item\n- Third item'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '- First item\n- Second item\n- Third item'); + }); + + it('preserves numbered list items', async function () { + const message = Promise.resolve('title\n\n1. First item\n2. Second item\n3. Third item'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '1. First item\n2. Second item\n3. Third item'); + }); + + it('preserves indented lines', async function () { + const message = Promise.resolve('title\n\nNormal paragraph.\n\n Indented code block\n More code'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'Normal paragraph.\n\n Indented code block\n More code'); + }); + + it('unwraps but preserves asterisk list items', async function () { + const message = Promise.resolve('title\n\n* First item\n* Second item'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '* First item\n* Second item'); + }); + + it('handles mixed content with wrapped paragraphs and lists', async function () { + const message = Promise.resolve('title\n\nThis is a paragraph that has been wrapped\nat 72 characters.\n\n- Item 1\n- Item 2\n\nAnother wrapped paragraph\nthat continues here.'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'This is a paragraph that has been wrapped at 72 characters.\n\n- Item 1\n- Item 2\n\nAnother wrapped paragraph that continues here.'); + }); + + it('preserves lines with special characters at the start', async function () { + const message = Promise.resolve('title\n\n> Quote line 1\n> Quote line 2'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '> Quote line 1\n> Quote line 2'); + }); + + it('handles wrapped lines with punctuation', async function () { + const message = Promise.resolve('title\n\nThis is a sentence.\nThis is another sentence on a new line.'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'This is a sentence. This is another sentence on a new line.'); + }); + + it('preserves fenced code blocks', async function () { + const message = Promise.resolve('title\n\nSome text before.\n\n```\ncode line 1\ncode line 2\n```\n\nSome text after.'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'Some text before.\n\n```\ncode line 1\ncode line 2\n```\n\nSome text after.'); + }); + + it('preserves fenced code blocks with language', async function () { + const message = Promise.resolve('title\n\nSome text.\n\n```javascript\nconst x = 1;\nconst y = 2;\n```\n\nMore text.'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'Some text.\n\n```javascript\nconst x = 1;\nconst y = 2;\n```\n\nMore text.'); + }); + + it('preserves nested list items with proper indentation', async function () { + const message = Promise.resolve('title\n\n- Item 1\n - Nested item 1.1\n - Nested item 1.2\n- Item 2'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '- Item 1\n - Nested item 1.1\n - Nested item 1.2\n- Item 2'); + }); + + it('preserves list item continuations', async function () { + const message = Promise.resolve('title\n\n- This is a list item that is long\n and continues on the next line\n- Second item'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '- This is a list item that is long\n and continues on the next line\n- Second item'); + }); + + it('preserves indented code blocks but not list continuations', async function () { + const message = Promise.resolve('title\n\nRegular paragraph.\n\n This is code\n More code\n\nAnother paragraph.'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'Regular paragraph.\n\n This is code\n More code\n\nAnother paragraph.'); + }); + + it('unwraps regular text but preserves list item continuations', async function () { + const message = Promise.resolve('title\n\nThis is wrapped text\nthat should be joined.\n\n- List item with\n continuation\n- Another item'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'This is wrapped text that should be joined.\n\n- List item with\n continuation\n- Another item'); + }); + + it('handles complex nested lists with wrapped paragraphs', async function () { + const message = Promise.resolve('title\n\nWrapped paragraph\nacross lines.\n\n- Item 1\n - Nested item\n More nested content\n- Item 2\n\nAnother wrapped paragraph\nhere.'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'Wrapped paragraph across lines.\n\n- Item 1\n - Nested item\n More nested content\n- Item 2\n\nAnother wrapped paragraph here.'); + }); + + it('handles nested lists', async function () { + const message = Promise.resolve('title\n\n* This is a list item with two lines\n that have a line break between them\n * This is a nested list item that also has\n two lines that should have been merged'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '* This is a list item with two lines that have a line break between them\n * This is a nested list item that also has two lines that should have been merged'); }); }); diff --git a/src/test/github/githubRepository.test.ts b/src/test/github/githubRepository.test.ts index 493f4a99f3..6d41977abf 100644 --- a/src/test/github/githubRepository.test.ts +++ b/src/test/github/githubRepository.test.ts @@ -40,7 +40,7 @@ describe('GitHubRepository', function () { const url = 'https://github.com/some/repo'; const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); const rootUri = Uri.file('C:\\users\\test\\repo'); - const dotcomRepository = new GitHubRepository(remote, rootUri, credentialStore, telemetry); + const dotcomRepository = new GitHubRepository(1, remote, rootUri, credentialStore, telemetry); assert(GitHubManager.isGithubDotCom(Uri.parse(remote.url).authority)); }); @@ -48,7 +48,7 @@ describe('GitHubRepository', function () { const url = 'https://github.enterprise.horse/some/repo'; const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); const rootUri = Uri.file('C:\\users\\test\\repo'); - const dotcomRepository = new GitHubRepository(remote, rootUri, credentialStore, telemetry); + const dotcomRepository = new GitHubRepository(1, remote, rootUri, credentialStore, telemetry); // assert(! dotcomRepository.isGitHubDotCom); }); }); diff --git a/src/test/github/graphql.test.ts b/src/test/github/graphql.test.ts new file mode 100644 index 0000000000..cb0bacfb01 --- /dev/null +++ b/src/test/github/graphql.test.ts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { isAccount, isTeam, Actor, Account, Team, Node } from '../../github/graphql'; + +describe('graphql type guards', () => { + + describe('isAccount', () => { + it('returns true for a valid Account', () => { + const account: Account = { + __typename: 'User', + id: 'acct1', + login: 'alice', + avatarUrl: 'https://example.com/a.png', + url: 'https://example.com/alice', + name: 'Alice', + email: 'alice@example.com' + }; + assert.strictEqual(isAccount(account), true); + }); + + it('returns false for Actor missing name/email', () => { + const actor: Actor = { + __typename: 'User', + id: 'act1', + login: 'bob', + avatarUrl: 'https://example.com/b.png', + url: 'https://example.com/bob' + }; + assert.strictEqual(isAccount(actor), false); + }); + + it('returns false for Team object', () => { + const team: Team = { + avatarUrl: 'https://example.com/t.png', + name: 'Dev Team', + url: 'https://example.com/team', + repositories: { nodes: [] }, + slug: 'dev-team', + id: 'team1' + }; + assert.strictEqual(isAccount(team), false); + }); + + it('returns false for Node object', () => { + const node: Node = { id: 'node1' }; + assert.strictEqual(isAccount(node), false); + }); + + it('returns false for null and undefined', () => { + assert.strictEqual(isAccount(null), false); + assert.strictEqual(isAccount(undefined), false); + }); + + it('returns true when name and email are null', () => { + const obj: any = { + __typename: 'User', id: 'null1', login: 'nullUser', avatarUrl: '', url: '', name: null, email: null + }; + assert.strictEqual(isAccount(obj), true); + }); + + it('returns true when name is null but email present', () => { + const obj: any = { + __typename: 'User', id: 'null2', login: 'nullName', avatarUrl: '', url: '', name: null, email: 'e@example.com' + }; + assert.strictEqual(isAccount(obj), true); + }); + + it('returns false when email or name is undefined', () => { + const obj: any = { + __typename: 'User', id: 'null3', login: 'nullEmail', avatarUrl: '', url: '', name: undefined, email: undefined + }; + assert.strictEqual(isAccount(obj), false); + }); + }); + + describe('isTeam', () => { + it('returns true for a valid Team', () => { + const team: Team = { + avatarUrl: 'https://example.com/t.png', + name: 'Engineering', + url: 'https://example.com/eng', + repositories: { nodes: [] }, + slug: 'engineering', + id: 'team2' + }; + assert.strictEqual(isTeam(team), true); + }); + + it('returns false for Account object', () => { + const account: Account = { + __typename: 'User', + id: 'acct2', + login: 'carol', + avatarUrl: 'https://example.com/c.png', + url: 'https://example.com/carol', + name: 'Carol', + email: 'carol@example.com' + }; + assert.strictEqual(isTeam(account), false); + }); + + it('returns false for Actor without slug', () => { + const actor: Actor = { + __typename: 'User', + id: 'act2', + login: 'dave', + avatarUrl: 'https://example.com/d.png', + url: 'https://example.com/dave' + }; + assert.strictEqual(isTeam(actor), false); + }); + + it('returns false for Node object', () => { + const node: Node = { id: 'node2' }; + assert.strictEqual(isTeam(node), false); + }); + + it('returns false for null and undefined', () => { + assert.strictEqual(isTeam(null), false); + assert.strictEqual(isTeam(undefined), false); + }); + + it('returns false when slug is undefined', () => { + const obj: any = { + avatarUrl: '', name: 'Team', url: '', repositories: { nodes: [] }, slug: undefined, id: 'tslugnull' + }; + assert.strictEqual(isTeam(obj), false); + }); + it('returns true when slug is null', () => { + const obj: any = { + avatarUrl: '', name: 'Team', url: '', repositories: { nodes: [] }, slug: null, id: 'tslugnull' + }; + assert.strictEqual(isTeam(obj), true); + }); + }); +}); + diff --git a/src/test/github/markdownUtils.test.ts b/src/test/github/markdownUtils.test.ts new file mode 100644 index 0000000000..3f0be975f1 --- /dev/null +++ b/src/test/github/markdownUtils.test.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as marked from 'marked'; +import { PlainTextRenderer } from '../../github/markdownUtils'; + +describe('PlainTextRenderer', () => { + it('should escape inline code by default', () => { + const renderer = new PlainTextRenderer(); + const result = marked.parse('rename the `Foo` class', { renderer, smartypants: true }); + assert.strictEqual(result.trim(), 'rename the \\`Foo\\` class'); + }); + + it('should preserve inline code when allowSimpleMarkdown is true', () => { + const renderer = new PlainTextRenderer(true); + const result = marked.parse('rename the `Foo` class', { renderer, smartypants: true }); + assert.strictEqual(result.trim(), 'rename the `Foo` class'); + }); + + it('should handle multiple inline code spans', () => { + const renderer = new PlainTextRenderer(true); + const result = marked.parse('rename the `Foo` class to `Bar`', { renderer, smartypants: true }); + assert.strictEqual(result.trim(), 'rename the `Foo` class to `Bar`'); + }); + + it('should still escape when allowSimpleMarkdown is false', () => { + const renderer = new PlainTextRenderer(false); + const result = marked.parse('rename the `Foo` class to `Bar`', { renderer, smartypants: true }); + assert.strictEqual(result.trim(), 'rename the \\`Foo\\` class to \\`Bar\\`'); + }); + + it('should strip all formatting by default', () => { + const renderer = new PlainTextRenderer(false); + const result = marked.parse('rename the `Foo` class to **`Bar`** and make it *italic*', { renderer, smartypants: true }); + assert.strictEqual(result.trim(), 'rename the \\`Foo\\` class to \\`Bar\\` and make it italic'); + }); +}); \ No newline at end of file diff --git a/src/test/github/prComment.test.ts b/src/test/github/prComment.test.ts new file mode 100644 index 0000000000..41786c8a82 --- /dev/null +++ b/src/test/github/prComment.test.ts @@ -0,0 +1,105 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { COMMIT_SHA_EXPRESSION, replaceImages } from '../../github/prComment'; + +describe('commit SHA replacement', function () { + it('should match 7-character commit SHAs', function () { + const text = 'Fixed in commit 5cf56bc and also in abc1234'; + const matches = Array.from(text.matchAll(COMMIT_SHA_EXPRESSION)); + assert.strictEqual(matches.length, 2); + assert.strictEqual(matches[0][1], '5cf56bc'); + assert.strictEqual(matches[1][1], 'abc1234'); + }); + + it('should match 40-character commit SHAs', function () { + const text = 'Fixed in commit 5cf56bc1234567890abcdef1234567890abcdef0'; + const matches = Array.from(text.matchAll(COMMIT_SHA_EXPRESSION)); + assert.strictEqual(matches.length, 1); + assert.strictEqual(matches[0][0], '5cf56bc1234567890abcdef1234567890abcdef0'); + }); + + it('should not match SHAs in URLs', function () { + const text = 'https://github.com/owner/repo/commit/5cf56bc'; + const matches = Array.from(text.matchAll(COMMIT_SHA_EXPRESSION)); + assert.strictEqual(matches.length, 0); + }); + + it('should not match SHAs in code blocks', function () { + const text = 'Fixed in commit 5cf56bc but not in `abc1234`'; + const matches = Array.from(text.matchAll(COMMIT_SHA_EXPRESSION)); + // The regex should only match the first SHA, not the one inside backticks + assert.strictEqual(matches.length, 1); + assert.strictEqual(matches[0][1], '5cf56bc'); + }); + + it('should not match non-hex strings', function () { + const text = 'Not a SHA: 1234xyz or ABCDEFG'; + const matches = Array.from(text.matchAll(COMMIT_SHA_EXPRESSION)); + assert.strictEqual(matches.length, 0); + }); + + it('should not match SHAs with alphanumeric prefix', function () { + const text = 'prefix5cf56bc is not a SHA'; + const matches = Array.from(text.matchAll(COMMIT_SHA_EXPRESSION)); + assert.strictEqual(matches.length, 0); + }); + + it('should not match SHAs with alphanumeric suffix', function () { + const text = '5cf56bcsuffix is not a SHA'; + const matches = Array.from(text.matchAll(COMMIT_SHA_EXPRESSION)); + assert.strictEqual(matches.length, 0); + }); +}); + +describe('replace images', function () { + it('github.com', function () { + const markdownBody = `Test image +![image](https://github.com/user-attachments/assets/714215c1-e994-4c69-be20-2276c558f7c3) +test again +![image](https://github.com/user-attachments/assets/3f2c170a-d0c3-4ac7-a9e5-ea13bf71a5bc)`; + const htmlBody = ` +<p dir="auto">Test image</p><p dir="auto"><a target="_blank" rel="noopener noreferrer" href="https://private-user-images.githubusercontent.com/38270282/445632993-714215c1-e994-4c69-be20-2276c558f7c3.png?jwt=TEST"><img src="https://private-user-images.githubusercontent.com/38270282/445632993-714215c1-e994-4c69-be20-2276c558f7c3.png?jwt=TEST" alt="image" style="max-width: 100%;"></a></p> +<p dir="auto">test again</p> +<p dir="auto"><a target="_blank" rel="noopener noreferrer" href="https://private-user-images.githubusercontent.com/38270282/445689518-3f2c170a-d0c3-4ac7-a9e5-ea13bf71a5bc.png?jwt=TEST"><img src="https://private-user-images.githubusercontent.com/38270282/445689518-3f2c170a-d0c3-4ac7-a9e5-ea13bf71a5bc.png?jwt=TEST" alt="image" style="max-width: 100%;"></a></p>`; + const host = 'github.com'; + const replaced = replaceImages(markdownBody, htmlBody, host); + const expected = `Test image +![image](https://private-user-images.githubusercontent.com/38270282/445632993-714215c1-e994-4c69-be20-2276c558f7c3.png?jwt=TEST) +test again +![image](https://private-user-images.githubusercontent.com/38270282/445689518-3f2c170a-d0c3-4ac7-a9e5-ea13bf71a5bc.png?jwt=TEST)`; + assert.strictEqual(replaced, expected); + }); + + it('GHCE', function () { + const markdownBody = `Test image +![image](https://test.ghe.com/user-attachments/assets/d81c6ab2-52a6-4ebf-b0c8-125492bd9662)`; + const htmlBody = ` +<p dir="auto">Test image</p> +<p dir="auto"><a target="_blank" rel="noopener noreferrer" href="https://test.ghe.com/github-production-user-asset-6210df/11296/2514616-d81c6ab2-52a6-4ebf-b0c8-125492bd9662.png?TEST"><img src="https://objects-origin.test.ghe.com/github-production-user-asset-6210df/11296/2514616-d81c6ab2-52a6-4ebf-b0c8-125492bd9662.png?TEST" alt="image" style="max-width: 100%;"></a></p>`; + const host = 'test.ghe.com'; + const replaced = replaceImages(markdownBody, htmlBody, host); + const expected = `Test image +![image](https://test.ghe.com/github-production-user-asset-6210df/11296/2514616-d81c6ab2-52a6-4ebf-b0c8-125492bd9662.png?TEST)`; + + assert.strictEqual(replaced, expected); + }); + + it('GHE', function () { + const markdownBody = `Test +![image](https://alexr00-my-test-instance.ghe-test.com/my-user/my-repo/assets/6/c267d6ce-fbdd-41a0-b86d-760882bd0c82) +`; + const htmlBody = ` <p dir="auto">Test<br> +<a target="_blank" rel="noopener noreferrer" href="https://media.alexr00-my-test-instance.ghe-test.com/user/6/files/c267d6ce-fbdd-41a0-b86d-760882bd0c82?TEST"><img src="https://media.alexr00-my-test-instance.ghe-test.com/user/6/files/c267d6ce-fbdd-41a0-b86d-760882bd0c82?TEST" alt="image" style="max-width: 100%;"></a></p>`; + const host = 'alexr00-my-test-instance.ghe-test.com'; + const replaced = replaceImages(markdownBody, htmlBody, host); + const expected = `Test +![image](https://media.alexr00-my-test-instance.ghe-test.com/user/6/files/c267d6ce-fbdd-41a0-b86d-760882bd0c82?TEST) +`; + + assert.strictEqual(replaced, expected); + }); +}); diff --git a/src/test/github/pullRequestGitHelper.test.ts b/src/test/github/pullRequestGitHelper.test.ts index 193da32737..590b35f0aa 100644 --- a/src/test/github/pullRequestGitHelper.test.ts +++ b/src/test/github/pullRequestGitHelper.test.ts @@ -44,6 +44,98 @@ describe('PullRequestGitHelper', function () { sinon.restore(); }); + describe('fetchAndCheckout', function () { + it('creates a unique branch when local branch exists with different commit to preserve user work', async function () { + const url = 'git@github.com:owner/name.git'; + const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); + const gitHubRepository = new MockGitHubRepository(remote, credentialStore, telemetry, sinon); + + const prItem = convertRESTPullRequestToRawPullRequest( + new PullRequestBuilder() + .number(100) + .user(u => u.login('me')) + .base(b => { + (b.repo)(r => (<RepositoryBuilder>r).clone_url('git@github.com:owner/name.git')); + }) + .head(h => { + h.repo(r => (<RepositoryBuilder>r).clone_url('git@github.com:owner/name.git')); + h.ref('my-branch'); + }) + .build(), + gitHubRepository, + ); + + const pullRequest = new PullRequestModel(credentialStore, telemetry, gitHubRepository, remote, prItem); + + // Setup: local branch exists with different commit than remote + await repository.createBranch('my-branch', false, 'local-commit-hash'); + + // Setup: remote branch has different commit + await repository.createBranch('refs/remotes/origin/my-branch', false, 'remote-commit-hash'); + + const remotes = [remote]; + + // Expect fetch to be called + repository.expectFetch('origin', 'my-branch'); + + await PullRequestGitHelper.fetchAndCheckout(repository, remotes, pullRequest, { report: () => undefined }); + + // Verify that the original local branch is preserved + 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 with the correct commit + const uniqueBranch = await repository.getBranch('pr/me/100'); + assert.strictEqual(uniqueBranch.commit, 'remote-commit-hash', 'Unique branch should have remote commit'); + assert.strictEqual(repository.state.HEAD?.name, 'pr/me/100', 'Should check out the unique branch'); + }); + + it('creates a unique branch even when currently checked out on conflicting local branch', async function () { + const url = 'git@github.com:owner/name.git'; + const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); + const gitHubRepository = new MockGitHubRepository(remote, credentialStore, telemetry, sinon); + + const prItem = convertRESTPullRequestToRawPullRequest( + new PullRequestBuilder() + .number(100) + .user(u => u.login('me')) + .base(b => { + (b.repo)(r => (<RepositoryBuilder>r).clone_url('git@github.com:owner/name.git')); + }) + .head(h => { + h.repo(r => (<RepositoryBuilder>r).clone_url('git@github.com:owner/name.git')); + h.ref('my-branch'); + }) + .build(), + gitHubRepository, + ); + + const pullRequest = new PullRequestModel(credentialStore, telemetry, gitHubRepository, remote, prItem); + + // Setup: local branch exists with different commit than remote AND is currently checked out + await repository.createBranch('my-branch', true, 'local-commit-hash'); // checkout = true + + // Setup: remote branch has different commit + await repository.createBranch('refs/remotes/origin/my-branch', false, 'remote-commit-hash'); + + const remotes = [remote]; + + // Expect fetch to be called + repository.expectFetch('origin', 'my-branch'); + + await PullRequestGitHelper.fetchAndCheckout(repository, remotes, pullRequest, { report: () => undefined }); + + // 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'); + assert.strictEqual(repository.state.HEAD?.name, 'pr/me/100', 'Should check out the unique branch'); + }); + }); + describe('checkoutFromFork', function () { it('fetches, checks out, and configures a branch from a fork', async function () { const url = 'git@github.com:owner/name.git'; @@ -65,10 +157,10 @@ describe('PullRequestGitHelper', function () { gitHubRepository, ); - repository.expectFetch('you', 'my-branch:pr/me/100', 1); + repository.expectFetch('you', 'my-branch:pr/me/100'); repository.expectPull(true); - const pullRequest = new PullRequestModel(telemetry, gitHubRepository, remote, prItem); + const pullRequest = new PullRequestModel(credentialStore, telemetry, gitHubRepository, remote, prItem); if (!pullRequest.isResolved()) { assert(false, 'pull request head not resolved successfully'); diff --git a/src/test/github/pullRequestModel.test.ts b/src/test/github/pullRequestModel.test.ts index b5cea79c9d..5497cde4ab 100644 --- a/src/test/github/pullRequestModel.test.ts +++ b/src/test/github/pullRequestModel.test.ts @@ -16,10 +16,10 @@ 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'; -const queries = require('../../github/queries.gql'); +import { mergeQuerySchemaWithShared } from '../../github/common'; +const queries = mergeQuerySchemaWithShared(require('../../github/queries.gql'), require('../../github/queriesShared.gql')) as any; const telemetry = new MockTelemetry(); const protocol = new Protocol('https://github.com/github/test.git'); @@ -65,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 () { @@ -77,21 +76,21 @@ describe('PullRequestModel', function () { it('should return `state` properly as `open`', function () { const pr = new PullRequestBuilder().state('open').build(); - const open = new PullRequestModel(telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo)); + const open = new PullRequestModel(credentials, telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo)); assert.strictEqual(open.state, GithubItemStateEnum.Open); }); it('should return `state` properly as `closed`', function () { const pr = new PullRequestBuilder().state('closed').build(); - const open = new PullRequestModel(telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo)); + const open = new PullRequestModel(credentials, telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo)); assert.strictEqual(open.state, GithubItemStateEnum.Closed); }); it('should return `state` properly as `merged`', function () { const pr = new PullRequestBuilder().merged(true).state('closed').build(); - const open = new PullRequestModel(telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo)); + const open = new PullRequestModel(credentials, telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo)); assert.strictEqual(open.state, GithubItemStateEnum.Merged); }); @@ -100,6 +99,7 @@ describe('PullRequestModel', function () { it('should update the cache when then cache is initialized', async function () { const pr = new PullRequestBuilder().build(); const model = new PullRequestModel( + credentials, telemetry, repo, remote, @@ -123,6 +123,9 @@ describe('PullRequestModel', function () { nodes: [ reviewThreadResponse ], + pageInfo: { + hasNextPage: false + } }, }, }, diff --git a/src/test/github/pullRequestOverview.test.ts b/src/test/github/pullRequestOverview.test.ts index 83c98ed3a5..0af58e3254 100644 --- a/src/test/github/pullRequestOverview.test.ts +++ b/src/test/github/pullRequestOverview.test.ts @@ -23,6 +23,9 @@ import { CredentialStore } from '../../github/credentials'; import { GitHubServerType } from '../../common/authentication'; import { GitHubRemote } from '../../common/remote'; import { CheckState } from '../../github/interface'; +import { CreatePullRequestHelper } from '../../view/createPullRequestHelper'; +import { RepositoriesManager } from '../../github/repositoriesManager'; +import { MockThemeWatcher } from '../mocks/mockThemeWatcher'; const EXTENSION_URI = vscode.Uri.joinPath(vscode.Uri.file(__dirname), '../../..'); @@ -33,6 +36,8 @@ describe('PullRequestOverview', function () { let remote: GitHubRemote; let repo: MockGitHubRepository; let telemetry: MockTelemetry; + let credentialStore: CredentialStore; + let mockThemeWatcher: MockThemeWatcher; beforeEach(async function () { sinon = createSandbox(); @@ -41,8 +46,11 @@ describe('PullRequestOverview', function () { const repository = new MockRepository(); telemetry = new MockTelemetry(); - const credentialStore = new CredentialStore(telemetry, context); - pullRequestManager = new FolderRepositoryManager(context, repository, telemetry, new GitApiImpl(), credentialStore); + credentialStore = new CredentialStore(telemetry, context); + mockThemeWatcher = new MockThemeWatcher(); + const createPrHelper = new CreatePullRequestHelper(); + const repositoriesManager = new RepositoriesManager(credentialStore, telemetry); + pullRequestManager = new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(repositoriesManager), credentialStore, createPrHelper, mockThemeWatcher); const url = 'https://github.com/aaa/bbb'; remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); @@ -73,15 +81,17 @@ describe('PullRequestOverview', function () { }); const prItem = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(1000).build(), repo); - const prModel = new PullRequestModel(telemetry, repo, remote, prItem); + const prModel = new PullRequestModel(credentialStore, telemetry, repo, remote, prItem); + const identity = { owner: prModel.remote.owner, repo: prModel.remote.repositoryName, number: prModel.number }; - await PullRequestOverviewPanel.createOrShow(EXTENSION_URI, pullRequestManager, prModel); + await PullRequestOverviewPanel.createOrShow(telemetry, EXTENSION_URI, pullRequestManager, identity, prModel); assert( createWebviewPanel.calledWith(sinonMatch.string, 'Pull Request #1000', vscode.ViewColumn.One, { enableScripts: true, retainContextWhenHidden: true, localResourceRoots: [vscode.Uri.joinPath(EXTENSION_URI, 'dist')], + enableFindWidget: true }), ); assert.notStrictEqual(PullRequestOverviewPanel.currentPanel, undefined); @@ -106,12 +116,14 @@ describe('PullRequestOverview', function () { }); const prItem0 = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(1000).build(), repo); - const prModel0 = new PullRequestModel(telemetry, repo, remote, prItem0); + const prModel0 = new PullRequestModel(credentialStore, telemetry, repo, remote, prItem0); + const identity0 = { owner: prModel0.remote.owner, repo: prModel0.remote.repositoryName, number: prModel0.number }; const resolveStub = sinon.stub(pullRequestManager, 'resolvePullRequest').resolves(prModel0); sinon.stub(prModel0, 'getReviewRequests').resolves([]); sinon.stub(prModel0, 'getTimelineEvents').resolves([]); - sinon.stub(prModel0, 'getStatusChecks').resolves({ state: CheckState.Pending, statuses: [] }); - await PullRequestOverviewPanel.createOrShow(EXTENSION_URI, pullRequestManager, prModel0); + sinon.stub(prModel0, 'validateDraftMode').resolves(true); + sinon.stub(prModel0, 'getStatusChecks').resolves([{ state: CheckState.Success, statuses: [] }, null]); + await PullRequestOverviewPanel.createOrShow(telemetry, EXTENSION_URI, pullRequestManager, identity0, prModel0); const panel0 = PullRequestOverviewPanel.currentPanel; assert.notStrictEqual(panel0, undefined); @@ -119,12 +131,14 @@ describe('PullRequestOverview', function () { assert.strictEqual(panel0!.getCurrentTitle(), 'Pull Request #1000'); const prItem1 = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(2000).build(), repo); - const prModel1 = new PullRequestModel(telemetry, repo, remote, prItem1); + const prModel1 = new PullRequestModel(credentialStore, telemetry, repo, remote, prItem1); + const identity1 = { owner: prModel1.remote.owner, repo: prModel1.remote.repositoryName, number: prModel1.number }; resolveStub.resolves(prModel1); sinon.stub(prModel1, 'getReviewRequests').resolves([]); sinon.stub(prModel1, 'getTimelineEvents').resolves([]); - sinon.stub(prModel1, 'getStatusChecks').resolves({ state: CheckState.Pending, statuses: [] }); - await PullRequestOverviewPanel.createOrShow(EXTENSION_URI, pullRequestManager, prModel1); + sinon.stub(prModel1, 'validateDraftMode').resolves(true); + sinon.stub(prModel1, 'getStatusChecks').resolves([{ state: CheckState.Success, statuses: [] }, null]); + await PullRequestOverviewPanel.createOrShow(telemetry, EXTENSION_URI, pullRequestManager, identity1, prModel1); assert.strictEqual(panel0, PullRequestOverviewPanel.currentPanel); assert.strictEqual(createWebviewPanel.callCount, 1); diff --git a/src/test/github/utils.test.ts b/src/test/github/utils.test.ts index 71c93c0851..94683fcf85 100644 --- a/src/test/github/utils.test.ts +++ b/src/test/github/utils.test.ts @@ -10,11 +10,10 @@ describe('utils', () => { describe('getPRFetchQuery', () => { it('replaces all instances of ${user}', () => { - const repo = 'microsoft/vscode-pull-request-github'; const user = 'rmacfarlane'; const query = 'reviewed-by:${user} -author:${user}'; - const result = getPRFetchQuery(repo, user, query) - assert.strictEqual(result, 'is:pull-request reviewed-by:rmacfarlane -author:rmacfarlane type:pr repo:microsoft/vscode-pull-request-github'); + const result = getPRFetchQuery(user, query) + assert.strictEqual(result, 'is:pull-request reviewed-by:rmacfarlane -author:rmacfarlane type:pr'); }); }); diff --git a/src/test/index.ts b/src/test/index.ts index 677bc56144..6ded19c0c2 100644 --- a/src/test/index.ts +++ b/src/test/index.ts @@ -1,3 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// @ts-nocheck // This file is providing the test runner to use when running extension tests. import * as path from 'path'; import * as vscode from 'vscode'; @@ -6,20 +12,6 @@ import Mocha from 'mocha'; import { mockWebviewEnvironment } from './mocks/mockWebviewEnvironment'; import { EXTENSION_ID } from '../constants'; -function addTests(mocha: Mocha, root: string): Promise<void> { - return new Promise((resolve, reject) => { - glob('**/**.test.js', { cwd: root }, (error, files) => { - if (error) { - return reject(error); - } - - for (const testFile of files) { - mocha.addFile(path.join(root, testFile)); - } - resolve(); - }); - }); -} async function runAllExtensionTests(testsRoot: string, clb: (error: Error | null, failures?: number) => void): Promise<any> { // Ensure the dev-mode extension is activated @@ -31,10 +23,34 @@ async function runAllExtensionTests(testsRoot: string, clb: (error: Error | null ui: 'bdd', color: true }); - mocha.addFile(path.resolve(testsRoot, 'globalHooks.js')); + // Load globalHooks if it exists + try { + mocha.addFile(path.resolve(testsRoot, 'globalHooks.js')); + } catch (e) { + // globalHooks might not exist in webpack bundle, ignore + } - await addTests(mocha, testsRoot); - await addTests(mocha, path.resolve(testsRoot, '../../../webviews/')); + // Import all test files using webpack's require.context + try { + // Load tests from src/test directory only + // Webview tests are compiled separately with the webview configuration + const importAll = (r: __WebpackModuleApi.RequireContext) => r.keys().forEach(r); + importAll(require.context('./', true, /\.test$/)); + } catch (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) { mocha.reporter('mocha-multi-reporters', { diff --git a/src/test/issues/issueTodoProvider.test.ts b/src/test/issues/issueTodoProvider.test.ts new file mode 100644 index 0000000000..89555f8278 --- /dev/null +++ b/src/test/issues/issueTodoProvider.test.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CopilotRemoteAgentManager } from '../../github/copilotRemoteAgent'; +import * as issueUtil from '../../issues/util'; + +const mockCopilotManager: Partial<CopilotRemoteAgentManager> = { + 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; + }); +}); \ No newline at end of file diff --git a/src/test/issues/issuesUtils.test.ts b/src/test/issues/issuesUtils.test.ts index 90c45e0fa0..6b48644d14 100644 --- a/src/test/issues/issuesUtils.test.ts +++ b/src/test/issues/issuesUtils.test.ts @@ -48,5 +48,21 @@ describe('Issues utilities', function () { const notIssue = '#a4'; const notIssueParsed = parseIssueExpressionOutput(notIssue.match(ISSUE_OR_URL_EXPRESSION)); assert.strictEqual(notIssueParsed, undefined); + + // Test PR URL parsing + const prUrl = 'https://github.com/microsoft/vscode/pull/123'; + const prUrlParsed = parseIssueExpressionOutput(prUrl.match(ISSUE_OR_URL_EXPRESSION)); + assert.strictEqual(prUrlParsed?.issueNumber, 123); + assert.strictEqual(prUrlParsed?.commentNumber, undefined); + assert.strictEqual(prUrlParsed?.name, 'vscode'); + assert.strictEqual(prUrlParsed?.owner, 'microsoft'); + + // Test HTTP PR URL (without S) + const prUrlHttp = 'http://github.com/owner/repo/pull/456'; + const prUrlHttpParsed = parseIssueExpressionOutput(prUrlHttp.match(ISSUE_OR_URL_EXPRESSION)); + assert.strictEqual(prUrlHttpParsed?.issueNumber, 456); + assert.strictEqual(prUrlHttpParsed?.commentNumber, undefined); + assert.strictEqual(prUrlHttpParsed?.name, 'repo'); + assert.strictEqual(prUrlHttpParsed?.owner, 'owner'); }); }); diff --git a/src/test/issues/stateManager.test.ts b/src/test/issues/stateManager.test.ts new file mode 100644 index 0000000000..4f2549ac0f --- /dev/null +++ b/src/test/issues/stateManager.test.ts @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { StateManager } from '../../issues/stateManager'; +import { CurrentIssue } from '../../issues/currentIssue'; +import { USE_BRANCH_FOR_ISSUES, ISSUES_SETTINGS_NAMESPACE } from '../../common/settingKeys'; + +// Mock classes for testing +class MockFolderRepositoryManager { + constructor(public repository: { rootUri: vscode.Uri }) { } +} + +class MockSingleRepoState { + currentIssue?: MockCurrentIssue; + constructor(public folderManager: MockFolderRepositoryManager) { } +} + +class MockCurrentIssue { + stopWorkingCalled = false; + stopWorkingCheckoutFlag = false; + issue = { number: 123 }; + + async stopWorking(checkoutDefaultBranch: boolean) { + this.stopWorkingCalled = true; + this.stopWorkingCheckoutFlag = checkoutDefaultBranch; + } +} + +describe('StateManager branch behavior with useBranchForIssues setting', function () { + let stateManager: StateManager; + let mockContext: vscode.ExtensionContext; + + beforeEach(() => { + mockContext = { + workspaceState: { + get: () => undefined, + update: () => Promise.resolve(), + }, + subscriptions: [], + } as any; + + stateManager = new StateManager(undefined as any, undefined as any, mockContext); + (stateManager as any)._singleRepoStates = new Map(); + }); + + it('should not checkout default branch when useBranchForIssues is off', async function () { + // Mock workspace configuration to return 'off' + const originalGetConfiguration = vscode.workspace.getConfiguration; + vscode.workspace.getConfiguration = (section?: string) => { + if (section === ISSUES_SETTINGS_NAMESPACE) { + return { + get: (key: string) => { + if (key === USE_BRANCH_FOR_ISSUES) { + return 'off'; + } + return undefined; + }, + } as any; + } + return originalGetConfiguration(section); + }; + + try { + // Set up test state + const mockUri = vscode.Uri.parse('file:///test'); + const mockFolderManager = new MockFolderRepositoryManager({ rootUri: mockUri }); + const mockState = new MockSingleRepoState(mockFolderManager); + const mockCurrentIssue = new MockCurrentIssue(); + mockState.currentIssue = mockCurrentIssue; + + (stateManager as any)._singleRepoStates.set(mockUri.path, mockState); + + // Call setCurrentIssue with checkoutDefaultBranch = true + await stateManager.setCurrentIssue(mockState as any, undefined, true, true); + + // Verify that stopWorking was called with false (not the original true) + assert.strictEqual(mockCurrentIssue.stopWorkingCalled, true, 'stopWorking should have been called'); + assert.strictEqual(mockCurrentIssue.stopWorkingCheckoutFlag, false, 'stopWorking should have been called with checkoutDefaultBranch=false when useBranchForIssues is off'); + } finally { + // Restore original configuration + vscode.workspace.getConfiguration = originalGetConfiguration; + } + }); + + it('should checkout default branch when useBranchForIssues is not off', async function () { + // Mock workspace configuration to return 'on' + const originalGetConfiguration = vscode.workspace.getConfiguration; + vscode.workspace.getConfiguration = (section?: string) => { + if (section === ISSUES_SETTINGS_NAMESPACE) { + return { + get: (key: string) => { + if (key === USE_BRANCH_FOR_ISSUES) { + return 'on'; + } + return undefined; + }, + } as any; + } + return originalGetConfiguration(section); + }; + + try { + // Set up test state + const mockUri = vscode.Uri.parse('file:///test'); + const mockFolderManager = new MockFolderRepositoryManager({ rootUri: mockUri }); + const mockState = new MockSingleRepoState(mockFolderManager); + const mockCurrentIssue = new MockCurrentIssue(); + mockState.currentIssue = mockCurrentIssue; + + (stateManager as any)._singleRepoStates.set(mockUri.path, mockState); + + // Call setCurrentIssue with checkoutDefaultBranch = true + await stateManager.setCurrentIssue(mockState as any, undefined, true, true); + + // Verify that stopWorking was called with true (preserving the original value) + assert.strictEqual(mockCurrentIssue.stopWorkingCalled, true, 'stopWorking should have been called'); + assert.strictEqual(mockCurrentIssue.stopWorkingCheckoutFlag, true, 'stopWorking should have been called with checkoutDefaultBranch=true when useBranchForIssues is on'); + } finally { + // Restore original configuration + vscode.workspace.getConfiguration = originalGetConfiguration; + } + }); + + it('should trim whitespace from query strings', async function () { + const mockUri = vscode.Uri.parse('file:///test'); + const mockFolderManager = { + repository: { rootUri: mockUri, state: { HEAD: { commit: 'abc123' }, remotes: [] } }, + getIssues: async (query: string) => { + // Verify that the query doesn't have trailing whitespace + assert.strictEqual(query, query.trim(), 'Query should be trimmed'); + assert.strictEqual(query.endsWith(' '), false, 'Query should not end with whitespace'); + return { items: [], hasMorePages: false, hasUnsearchedRepositories: false, totalCount: 0 }; + }, + getMaxIssue: async () => 0, + }; + + // Mock workspace configuration with query that has trailing space + const originalGetConfiguration = vscode.workspace.getConfiguration; + vscode.workspace.getConfiguration = (section?: string) => { + if (section === ISSUES_SETTINGS_NAMESPACE) { + return { + get: (key: string, defaultValue?: any) => { + if (key === 'queries') { + return [{ label: 'Test', query: 'is:open assignee:@me repo:owner/repo ', groupBy: [] }]; + } + return defaultValue; + }, + } as any; + } + return originalGetConfiguration(section); + }; + + try { + // Initialize the state manager with a query that has trailing space + const stateManager = new StateManager(undefined as any, { + folderManagers: [mockFolderManager], + credentialStore: { isAnyAuthenticated: () => true, getCurrentUser: async () => ({ login: 'testuser' }) }, + } as any, mockContext); + + // Manually trigger the setIssueData flow + await (stateManager as any).setIssueData(mockFolderManager); + + // If we get here without assertion failures in getIssues, the test passed + } finally { + vscode.workspace.getConfiguration = originalGetConfiguration; + } + }); +}); \ No newline at end of file diff --git a/src/test/mocks/mockExtensionContext.ts b/src/test/mocks/mockExtensionContext.ts index 47ef0287bf..b15c19907e 100644 --- a/src/test/mocks/mockExtensionContext.ts +++ b/src/test/mocks/mockExtensionContext.ts @@ -1,6 +1,11 @@ +/*--------------------------------------------------------------------------------------------- + * 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 temp from 'temp'; -import { ExtensionContext, Uri, SecretStorage, Event, SecretStorageChangeEvent } from 'vscode'; +import { ExtensionContext, Uri, SecretStorage, Event, SecretStorageChangeEvent, EventEmitter } from 'vscode'; import { InMemoryMemento } from './inMemoryMemento'; @@ -16,9 +21,13 @@ export class MockExtensionContext implements ExtensionContext { store(key: string, value: string): Thenable<void> { throw new Error('Method not implemented.'); } + keys(): Thenable<string[]> { + throw new Error('Method not implemented.'); + } delete(key: string): Thenable<void> { throw new Error('Method not implemented.'); } + onDidChange!: Event<SecretStorageChangeEvent>; })(); subscriptions: { dispose(): any }[] = []; @@ -39,6 +48,13 @@ export class MockExtensionContext implements ExtensionContext { extensionRuntime: any; extension: any; isNewInstall: any; + languageModelAccessInformation = { + onDidChange: new EventEmitter<void>().event, + + canSendRequest: (_chat: any) => { + return undefined; + } + }; constructor() { this.extensionPath = path.resolve(__dirname, '..'); diff --git a/src/test/mocks/mockGitHubRepository.ts b/src/test/mocks/mockGitHubRepository.ts index b7fdee9900..4bff09d7e9 100644 --- a/src/test/mocks/mockGitHubRepository.ts +++ b/src/test/mocks/mockGitHubRepository.ts @@ -20,48 +20,51 @@ import { import { MockTelemetry } from './mockTelemetry'; import { Uri } from 'vscode'; import { LoggingOctokit, RateLogger } from '../../github/loggingOctokit'; -import { MockExtensionContext } from './mockExtensionContext'; -const queries = require('../../github/queries.gql'); +import { mergeQuerySchemaWithShared } from '../../github/common'; + +const queries = mergeQuerySchemaWithShared(require('../../github/queries.gql'), require('../../github/queriesShared.gql')) as any; export class MockGitHubRepository extends GitHubRepository { readonly queryProvider: QueryProvider; constructor(remote: GitHubRemote, credentialStore: CredentialStore, telemetry: MockTelemetry, sinon: SinonSandbox) { - super(remote, Uri.file('C:\\users\\test\\repo'), credentialStore, telemetry); + super(1, remote, Uri.file('C:\\users\\test\\repo'), credentialStore, telemetry); this.queryProvider = new QueryProvider(sinon); this._hub = { - octokit: new LoggingOctokit(this.queryProvider.octokit, new RateLogger(new MockExtensionContext())), - graphql: null, + octokit: new LoggingOctokit(this.queryProvider.octokit, new RateLogger(new MockTelemetry(), true)), + graphql: {} as any, }; - this._metadata = { + this._metadata = Promise.resolve({ ...new RepositoryBuilder().build(), currentUser: new UserBuilder().build(), - }; + }); this._initialized = true; } - async ensure() { + override async ensure() { return this; } - query = async <T>(query: QueryOptions): Promise<ApolloQueryResult<T>> => - this.queryProvider.emulateGraphQLQuery(query); + override query = async <T>(query: QueryOptions): Promise<ApolloQueryResult<T>> => { + return this.queryProvider.emulateGraphQLQuery(query); + }; - mutate = async <T>(mutation: MutationOptions<T, OperationVariables>): Promise<FetchResult<T>> => - this.queryProvider.emulateGraphQLMutation(mutation); + override mutate = async <T>(mutation: MutationOptions<T, OperationVariables>): Promise<FetchResult<T>> => { + return this.queryProvider.emulateGraphQLMutation(mutation); + }; buildMetadata(block: (repoBuilder: RepositoryBuilder, userBuilder: UserBuilder) => void) { const repoBuilder = new RepositoryBuilder(); const userBuilder = new UserBuilder(); block(repoBuilder, userBuilder); - this._metadata = { + this._metadata = Promise.resolve({ ...repoBuilder.build(), currentUser: userBuilder.build(), - }; + }); } addGraphQLPullRequest(block: (builder: ManagedGraphQLPullRequestBuilder) => void): ManagedPullRequest<'graphql'> { @@ -69,8 +72,8 @@ export class MockGitHubRepository extends GitHubRepository { block(builder); const responses = builder.build(); - const prNumber = responses.pullRequest.repository.pullRequest.number; - const headRef = responses.pullRequest.repository.pullRequest.headRef; + const prNumber = responses.pullRequest.repository!.pullRequest.number; + const headRef = responses.pullRequest.repository?.pullRequest.headRef; this.queryProvider.expectGraphQLQuery( { 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<void | NotificationTreeDataItem | undefined> = new EventEmitter<void | NotificationTreeDataItem | undefined>().event; + onDidChangeNotifications: Event<NotificationTreeItem[]> = new EventEmitter<NotificationTreeItem[]>().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<PrsTreeModel> { + onDidChangeCopilotStates: Event<void> = new EventEmitter<void>().event; + onDidChangeCopilotNotifications: Event<PullRequestModel[]> = new EventEmitter<PullRequestModel[]>().event; + clearCopilotCaches(): false | undefined { + throw new Error("Method not implemented."); + } + async refreshCopilotStateChanges(clearCache?: boolean): Promise<boolean> { + return false; + } + getCopilotPullRequests(clearCache?: boolean): Promise<CodingAgentPRAndStatus[]> { + throw new Error("Method not implemented."); + } + public onDidChangePrStatus: Event<string[]> = new EventEmitter<string[]>().event; + public onDidChangeData: Event<void | FolderRepositoryManager | PullRequestChangeEvent[]> = new EventEmitter<void>().event; + public onLoaded: Event<void>; + public copilotStateModel: CopilotStateModel; + public updateExpandedQueries(element: TreeNode, isExpanded: boolean): void { + throw new Error("Method not implemented."); + } + get expandedQueries(): Set<string> | undefined { + return new Set<string>(['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<ItemsResponseResult<PullRequestModel>> { + return { + hasMorePages: false, + items: this._localPullRequests, + hasUnsearchedRepositories: false + }; + } + getPullRequestsForQuery(folderRepoManager: FolderRepositoryManager, fetchNextPage: boolean, query: string): Promise<ItemsResponseResult<PullRequestModel>> { + throw new Error("Method not implemented."); + } + getAllPullRequests(folderRepoManager: FolderRepositoryManager, fetchNextPage: boolean, update?: boolean): Promise<ItemsResponseResult<PullRequestModel>> { + 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<T extends Disposable>(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/mocks/mockRepository.ts b/src/test/mocks/mockRepository.ts index 8d719ac72f..9a70408774 100644 --- a/src/test/mocks/mockRepository.ts +++ b/src/test/mocks/mockRepository.ts @@ -68,7 +68,9 @@ export class MockRepository implements Repository { } private _state: Mutable<RepositoryState & { refs: Ref[] }> = { - HEAD: undefined, + HEAD: { + type: RefType.Head + }, refs: [], remotes: [], submodules: [], @@ -149,8 +151,12 @@ export class MockRepository implements Repository { return Promise.reject(new Error('Unexpected hashObject(...)')); } + private _hasBranch(ref: string) { + return this._branches.some(b => b.name === ref); + } + async createBranch(name: string, checkout: boolean, ref?: string | undefined): Promise<void> { - if (this._branches.some(b => b.name === name)) { + if (this._hasBranch(name)) { throw new Error(`A branch named ${name} already exists`); } @@ -188,6 +194,10 @@ export class MockRepository implements Repository { return []; } + async getBranchBase(name: string): Promise<Branch | undefined> { + throw new Error(`Unexpected getBranchBase(${name})`); + } + async setBranchUpstream(name: string, upstream: string): Promise<void> { const index = this._branches.findIndex(b => b.name === name); if (index === -1) { @@ -271,7 +281,7 @@ export class MockRepository implements Repository { throw new Error(`Unexpected fetch(${remoteName}, ${ref}, ${depth})`); } - if (ref) { + if (ref && !this._hasBranch(ref)) { const match = /^(?:\+?[^:]+\:)?(.*)$/.exec(ref); if (match) { const [, localRef] = match; @@ -319,4 +329,12 @@ export class MockRepository implements Repository { expectPush(remoteName?: string, branchName?: string, setUpstream?: boolean) { this._expectedPushes.push({ remoteName, branchName, setUpstream }); } + + merge(ref: string): Promise<void> { + return Promise.reject(new Error(`Unexpected merge(${ref})`)); + } + + mergeAbort(): Promise<void> { + return Promise.reject(new Error(`Unexpected mergeAbort`)); + } } diff --git a/src/test/mocks/mockThemeWatcher.ts b/src/test/mocks/mockThemeWatcher.ts new file mode 100644 index 0000000000..1b1eaa36f9 --- /dev/null +++ b/src/test/mocks/mockThemeWatcher.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * 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 type { ThemeData } from '../../view/theme'; +import { IThemeWatcher } from '../../themeWatcher'; + +export class MockThemeWatcher extends Disposable implements IThemeWatcher { + private _themeData: ThemeData | undefined; + private _onDidChangeTheme = new vscode.EventEmitter<ThemeData | undefined>(); + readonly onDidChangeTheme = this._onDidChangeTheme.event; + + constructor() { + super(); + this._themeData = { + colors: {}, + semanticTokenColors: [], + tokenColors: [], + type: 'dark' + }; + } + + async updateTheme(themeData?: ThemeData) { + this._themeData = themeData ?? this._themeData; + this._onDidChangeTheme.fire(this._themeData); + } + + get themeData() { + return this._themeData; + } +} diff --git a/src/test/reference-types.d.ts b/src/test/reference-types.d.ts new file mode 100644 index 0000000000..a160962af4 --- /dev/null +++ b/src/test/reference-types.d.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/// <reference path="../@types/vscode.proposed.languageModelToolResultAudience.d.ts" /> diff --git a/src/test/view/prsTree.test.ts b/src/test/view/prsTree.test.ts index 9e56200736..881d2a8385 100644 --- a/src/test/view/prsTree.test.ts +++ b/src/test/view/prsTree.test.ts @@ -9,24 +9,31 @@ 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'; import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; import { PullRequestGitHelper } from '../../github/pullRequestGitHelper'; import { PullRequestModel } from '../../github/pullRequestModel'; -import { GitHubRemote, Remote } from '../../common/remote'; +import { GitHubRemote } 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'; import { GitHubServerType } from '../../common/authentication'; +import { DataUri } from '../../common/uri'; +import { IAccount, ITeam } from '../../github/interface'; +import { asPromise } from '../../common/utils'; +import { CreatePullRequestHelper } from '../../view/createPullRequestHelper'; +import { MockThemeWatcher } from '../mocks/mockThemeWatcher'; +import { PrsTreeModel } from '../../view/prsTreeModel'; describe('GitHub Pull Requests view', function () { let sinon: SinonSandbox; @@ -34,16 +41,29 @@ describe('GitHub Pull Requests view', function () { let telemetry: MockTelemetry; let provider: PullRequestsTreeDataProvider; let credentialStore: CredentialStore; + let reposManager: RepositoriesManager; + let createPrHelper: CreatePullRequestHelper; + let mockThemeWatcher: MockThemeWatcher; + let mockNotificationsManager: MockNotificationManager; + let prsTreeModel: PrsTreeModel; beforeEach(function () { sinon = createSandbox(); MockCommandRegistry.install(sinon); + mockThemeWatcher = new MockThemeWatcher(); context = new MockExtensionContext(); telemetry = new MockTelemetry(); - provider = new PullRequestsTreeDataProvider(telemetry, context); + reposManager = new RepositoriesManager( + credentialStore, + telemetry, + ); + prsTreeModel = new PrsTreeModel(telemetry, reposManager, context); credentialStore = new CredentialStore(telemetry, context); + provider = new PullRequestsTreeDataProvider(prsTreeModel, telemetry, context, reposManager); + mockNotificationsManager = new MockNotificationManager(); + createPrHelper = new CreatePullRequestHelper(); // For tree view unit tests, we don't test the authentication flow, so `showSignInNotification` returns // a dummy GitHub/Octokit object. @@ -54,14 +74,12 @@ describe('GitHub Pull Requests view', function () { baseUrl: 'https://github.com', userAgent: 'GitHub VSCode Pull Requests', previews: ['shadow-cat-preview'], - }), new RateLogger(context)), - graphql: null, + }), new RateLogger(telemetry, true)), + graphql: {} as any, }; return github; }); - - Resource.initialize(context); }); afterEach(function () { @@ -89,12 +107,8 @@ describe('GitHub Pull Requests view', function () { it('has no children when repositories have not yet been initialized', async function () { const repository = new MockRepository(); repository.addRemote('origin', 'git@github.com:aaa/bbb'); - const manager = new RepositoriesManager( - [new FolderRepositoryManager(context, repository, telemetry, new GitApiImpl(), credentialStore)], - credentialStore, - telemetry, - ); - provider.initialize(manager, [], credentialStore); + reposManager.insertFolderManager(new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(reposManager), credentialStore, createPrHelper, mockThemeWatcher)); + provider.initialize([], mockNotificationsManager as NotificationsManager); const rootNodes = await provider.getChildren(); assert.strictEqual(rootNodes.length, 0); @@ -103,25 +117,56 @@ describe('GitHub Pull Requests view', function () { it('opens the viewlet and displays the default categories', 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); + sinon.stub(credentialStore, 'isAuthenticated').returns(true); + await reposManager.folderManagers[0].updateRepositories(); + provider.initialize([], mockNotificationsManager as NotificationsManager); - const manager = new RepositoriesManager( - [new FolderRepositoryManager(context, repository, telemetry, new GitApiImpl(), credentialStore)], - credentialStore, - telemetry, + const rootNodes = await provider.getChildren(); + + // All but the last category are expected to be collapsed + const treeItems = await Promise.all(rootNodes.map(node => node.getTreeItem())); + assert(treeItems.slice(0, treeItems.length - 1).every(n => n.collapsibleState === vscode.TreeItemCollapsibleState.Collapsed)); + assert(treeItems[treeItems.length - 1].collapsibleState === vscode.TreeItemCollapsibleState.Expanded); + assert.deepStrictEqual( + treeItems.map(n => n.label), + ['Copilot on My Behalf', 'Local Pull Request Branches', 'Waiting For My Review', 'Created By Me', 'All Open'], ); + }); + + 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 manager.folderManagers[0].updateRepositories(); - provider.initialize(manager, [], credentialStore); + await folderManager.updateRepositories(); - const rootNodes = await provider.getChildren(); + // 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'); - // All but the last category are expected to be collapsed - assert(rootNodes.slice(0, rootNodes.length - 1).every(n => n.getTreeItem().collapsibleState === vscode.TreeItemCollapsibleState.Collapsed)); - assert(rootNodes[rootNodes.length - 1].getTreeItem().collapsibleState === vscode.TreeItemCollapsibleState.Expanded); + // Verify tree now has content + rootNodes = await provider.getChildren(); + const treeItems = await Promise.all(rootNodes.map(node => node.getTreeItem())); assert.deepStrictEqual( - rootNodes.map(n => n.getTreeItem().label), - ['Local Pull Request Branches', 'Waiting For My Review', 'Assigned To Me', 'Created By Me', 'All Open'], + 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', ); }); @@ -138,33 +183,35 @@ describe('GitHub Pull Requests view', function () { builder.pullRequest(pr => { pr.repository(r => r.pullRequest(p => { + p.databaseId(1111); p.number(1111); p.title('zero'); - p.author(a => a.login('me').avatarUrl('https://avatars.com/me.jpg').url('https://github.com/me')); + p.author(a => a.login('me').avatarUrl('https://githubusercontent.com/me.jpg').url('https://githubusercontent.com/me')); p.baseRef!(b => b.repository(br => br.url('https://github.com/aaa/bbb'))); p.baseRepository(r => r.url('https://github.com/aaa/bbb')); }), ); }); }).pullRequest; - const prItem0 = parseGraphQLPullRequest(pr0.repository.pullRequest, gitHubRepository); - const pullRequest0 = new PullRequestModel(telemetry, gitHubRepository, remote, prItem0); + const prItem0 = await parseGraphQLPullRequest(pr0.repository!.pullRequest, gitHubRepository); + const pullRequest0 = new PullRequestModel(credentialStore, telemetry, gitHubRepository, remote, prItem0); const pr1 = gitHubRepository.addGraphQLPullRequest(builder => { builder.pullRequest(pr => { pr.repository(r => r.pullRequest(p => { + p.databaseId(2222); p.number(2222); p.title('one'); - p.author(a => a.login('you').avatarUrl('https://avatars.com/you.jpg')); + p.author(a => a.login('you').avatarUrl('https://githubusercontent.com/you.jpg')); p.baseRef!(b => b.repository(br => br.url('https://github.com/aaa/bbb'))); p.baseRepository(r => r.url('https://github.com/aaa/bbb')); }), ); }); }).pullRequest; - const prItem1 = parseGraphQLPullRequest(pr1.repository.pullRequest, gitHubRepository); - const pullRequest1 = new PullRequestModel(telemetry, gitHubRepository, remote, prItem1); + const prItem1 = await parseGraphQLPullRequest(pr1.repository!.pullRequest, gitHubRepository); + const pullRequest1 = new PullRequestModel(credentialStore, telemetry, gitHubRepository, remote, prItem1); const repository = new MockRepository(); await repository.addRemote(remote.remoteName, remote.url); @@ -176,39 +223,50 @@ describe('GitHub Pull Requests view', function () { await repository.createBranch('non-pr-branch', false); - const manager = new FolderRepositoryManager(context, repository, telemetry, new GitApiImpl(), credentialStore); - const reposManager = new RepositoriesManager([manager], credentialStore, telemetry); + const manager = new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(reposManager), credentialStore, createPrHelper, mockThemeWatcher); + reposManager.insertFolderManager(manager); sinon.stub(manager, 'createGitHubRepository').callsFake((r, cs) => { assert.deepStrictEqual(r, remote); assert.strictEqual(cs, credentialStore); return Promise.resolve(gitHubRepository); }); sinon.stub(credentialStore, 'isAuthenticated').returns(true); + sinon.stub(DataUri, 'avatarCirclesAsImageDataUris').callsFake((context: vscode.ExtensionContext, users: (IAccount | ITeam)[], height: number, width: number, localOnly?: boolean) => { + return Promise.resolve(users.map(user => user.avatarUrl ? vscode.Uri.parse(user.avatarUrl) : undefined)); + }); await manager.updateRepositories(); - provider.initialize(reposManager, [], credentialStore); + provider.initialize([], mockNotificationsManager as NotificationsManager); manager.activePullRequest = pullRequest1; const rootNodes = await provider.getChildren(); - const localNode = rootNodes.find(node => node.getTreeItem().label === 'Local Pull Request Branches'); + const rootTreeItems = await Promise.all(rootNodes.map(node => node.getTreeItem())); + const localNode = rootNodes.find((_node, index) => rootTreeItems[index].label === 'Local Pull Request Branches'); assert(localNode); + // Need to call getChildren twice to get past the quick render with an empty list + await localNode!.getChildren(); + await asPromise(provider.prsTreeModel.onLoaded); const localChildren = await localNode!.getChildren(); assert.strictEqual(localChildren.length, 2); - const [localItem0, localItem1] = localChildren.map(node => node.getTreeItem()); + const [localItem0, localItem1] = await Promise.all(localChildren.map(node => node.getTreeItem())); - assert.strictEqual(localItem0.label, 'zero'); - assert.strictEqual(localItem0.tooltip, 'zero by @me'); + 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'); - assert.deepStrictEqual(localItem0.iconPath!.toString(), 'https://avatars.com/me.jpg&s=64'); + assert.strictEqual(localItem0.contextValue, 'pullrequest:local:nonactive:hasHeadRef'); + assert.deepStrictEqual(localItem0.iconPath!.toString(), 'https://githubusercontent.com/me.jpg'); - assert.strictEqual(localItem1.label, '✓ one'); - assert.strictEqual(localItem1.tooltip, 'Current Branch * one by @you'); + 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); - assert.strictEqual(localItem1.contextValue, 'pullrequest:local:active'); - assert.deepStrictEqual(localItem1.iconPath!.toString(), 'https://avatars.com/you.jpg&s=64'); + assert.strictEqual(localItem1.contextValue, 'pullrequest:local:active:hasHeadRef'); + assert.deepStrictEqual(localItem1.iconPath!.toString(), 'https://githubusercontent.com/you.jpg'); }); }); }); diff --git a/src/test/view/reviewCommentController.test.ts b/src/test/view/reviewCommentController.test.ts index 5d77f3b9f7..0dead8909e 100644 --- a/src/test/view/reviewCommentController.test.ts +++ b/src/test/view/reviewCommentController.test.ts @@ -18,24 +18,30 @@ import { toReviewUri } from '../../common/uri'; import * as vscode from 'vscode'; import { PullRequestBuilder } from '../builders/rest/pullRequestBuilder'; import { convertRESTPullRequestToRawPullRequest } from '../../github/utils'; -import { PullRequestModel } from '../../github/pullRequestModel'; +import { IResolvedPullRequestModel, PullRequestModel } from '../../github/pullRequestModel'; import { Protocol } from '../../common/protocol'; -import { GitHubRemote, Remote } from '../../common/remote'; +import { GitHubRemote } from '../../common/remote'; import { GHPRCommentThread } from '../../github/prComment'; import { DiffLine } from '../../common/diffHunk'; import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; import { GitApiImpl } from '../../api/api1'; -import { DiffSide } from '../../common/comment'; +import { DiffSide, SubjectType } from '../../common/comment'; 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'; import { GitHubServerType } from '../../common/authentication'; -const schema = require('../../github/queries.gql'); +import { CreatePullRequestHelper } from '../../view/createPullRequestHelper'; +import { mergeQuerySchemaWithShared } from '../../github/common'; +import { AccountType } from '../../github/interface'; +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'); const remote = new GitHubRemote('test', 'github/test', protocol, GitHubServerType.GitHubDotCom); @@ -56,10 +62,15 @@ describe('ReviewCommentController', function () { let activePullRequest: PullRequestModel; let githubRepo: MockGitHubRepository; let reviewManager: ReviewManager; + let reposManager: RepositoriesManager; + let gitApiImpl: GitApiImpl; + let mockThemeWatcher: MockThemeWatcher; + let mockPrsTreeModel: PrsTreeModel; beforeEach(async function () { sinon = createSandbox(); MockCommandRegistry.install(sinon); + mockThemeWatcher = new MockThemeWatcher(); telemetry = new MockTelemetry(); const context = new MockExtensionContext(); @@ -67,14 +78,16 @@ describe('ReviewCommentController', function () { repository = new MockRepository(); repository.addRemote('origin', 'git@github.com:aaa/bbb'); - - provider = new PullRequestsTreeDataProvider(telemetry, context); + reposManager = new RepositoriesManager(credentialStore, telemetry); + gitApiImpl = new GitApiImpl(reposManager); + mockPrsTreeModel = new MockPrsTreeModel() as unknown as PrsTreeModel; + provider = new PullRequestsTreeDataProvider(mockPrsTreeModel, telemetry, context, reposManager); const activePrViewCoordinator = new WebviewViewCoordinator(context); - Resource.initialize(context); - const gitApiImpl = new GitApiImpl(); - manager = new FolderRepositoryManager(context, repository, telemetry, gitApiImpl, credentialStore); - const tree = new PullRequestChangesTreeDataProvider(context, gitApiImpl, new RepositoriesManager([manager], credentialStore, telemetry)); - reviewManager = new ReviewManager(context, repository, manager, telemetry, tree, new ShowPullRequest(), activePrViewCoordinator); + const createPrHelper = new CreatePullRequestHelper(); + manager = new FolderRepositoryManager(0, context, repository, telemetry, gitApiImpl, credentialStore, createPrHelper, mockThemeWatcher); + reposManager.insertFolderManager(manager); + const tree = new PullRequestChangesTreeDataProvider(gitApiImpl, reposManager); + reviewManager = new ReviewManager(0, context, repository, manager, telemetry, tree, provider, new ShowPullRequest(), activePrViewCoordinator, createPrHelper, gitApiImpl); sinon.stub(manager, 'createGitHubRepository').callsFake((r, cStore) => { return Promise.resolve(new MockGitHubRepository(GitHubRemote.remoteAsGitHub(r, GitHubServerType.GitHubDotCom), cStore, telemetry, sinon)); }); @@ -84,6 +97,7 @@ describe('ReviewCommentController', function () { const pr = new PullRequestBuilder().build(); githubRepo = new MockGitHubRepository(remote, credentialStore, telemetry, sinon); activePullRequest = new PullRequestModel( + credentialStore, telemetry, githubRepo, remote, @@ -138,7 +152,7 @@ describe('ReviewCommentController', function () { return new GitFileChangeNode( provider, manager, - activePullRequest, + activePullRequest as any as PullRequestModel & IResolvedPullRequestModel, gitFileChangeModel ); } @@ -151,8 +165,10 @@ describe('ReviewCommentController', function () { comments: [], collapsibleState: vscode.CommentThreadCollapsibleState.Expanded, label: 'Start discussion', - state: vscode.CommentThreadState.Unresolved, + state: { resolved: vscode.CommentThreadState.Unresolved, applicability: 0 }, canReply: false, + reveal: () => Promise.resolve(), + hide: () => Promise.resolve(), dispose: () => { }, }; } @@ -163,7 +179,7 @@ describe('ReviewCommentController', function () { const localFileChanges = [createLocalFileChange(uri, fileName, repository.rootUri)]; const reviewModel = new ReviewModel(); reviewModel.localFileChanges = localFileChanges; - const reviewCommentController = new TestReviewCommentController(reviewManager, manager, repository, reviewModel); + const reviewCommentController = new TestReviewCommentController(reviewManager, manager, repository, reviewModel, gitApiImpl, telemetry); sinon.stub(activePullRequest, 'validateDraftMode').returns(Promise.resolve(false)); sinon.stub(activePullRequest, 'getReviewThreads').returns( @@ -189,8 +205,10 @@ describe('ReviewCommentController', function () { createdAt: '', htmlUrl: '', graphNodeId: '', + isOutdated: false } ], + subjectType: SubjectType.LINE }, ]), ); @@ -198,6 +216,8 @@ describe('ReviewCommentController', function () { sinon.stub(manager, 'getCurrentUser').returns(Promise.resolve({ login: 'rmacfarlane', url: 'https://github.com/rmacfarlane', + id: '123', + accountType: AccountType.User })); sinon.stub(vscode.workspace, 'getWorkspaceFolder').returns({ @@ -226,6 +246,8 @@ describe('ReviewCommentController', function () { manager, repository, reviewModel, + gitApiImpl, + telemetry ); const thread = createGHPRCommentThread('review-1.1', uri); @@ -236,6 +258,8 @@ describe('ReviewCommentController', function () { sinon.stub(manager, 'getCurrentUser').returns(Promise.resolve({ login: 'rmacfarlane', url: 'https://github.com/rmacfarlane', + id: '123', + accountType: AccountType.User })); sinon.stub(vscode.workspace, 'getWorkspaceFolder').returns({ @@ -266,7 +290,8 @@ describe('ReviewCommentController', function () { pullRequestReviewId: undefined, startLine: undefined, line: 22, - side: 'RIGHT' + side: 'RIGHT', + subjectType: 'LINE' } } }, @@ -284,6 +309,7 @@ describe('ReviewCommentController', function () { originalLine: 22, diffSide: 'RIGHT', isOutdated: false, + subjectType: 'LINE', comments: { nodes: [ { @@ -303,8 +329,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/themeWatcher.ts b/src/themeWatcher.ts new file mode 100644 index 0000000000..ea8d047000 --- /dev/null +++ b/src/themeWatcher.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { COLOR_THEME, WORKBENCH } from './common/settingKeys'; +import { loadCurrentThemeData, ThemeData } from './view/theme'; + +export interface IThemeWatcher { + readonly onDidChangeTheme: vscode.Event<ThemeData | undefined>; + readonly themeData: ThemeData | undefined; +} + +export class ThemeWatcher extends Disposable implements IThemeWatcher { + private _themeData: ThemeData | undefined; + private _onDidChangeTheme = this._register(new vscode.EventEmitter<ThemeData | undefined>()); + readonly onDidChangeTheme = this._onDidChangeTheme.event; + + constructor() { + super(); + this._register( + vscode.workspace.onDidChangeConfiguration(async e => { + if (e.affectsConfiguration(`${WORKBENCH}.${COLOR_THEME}`)) { + await this.updateTheme(); + } + }), + ); + this.updateTheme(); + } + + private async updateTheme() { + this._themeData = await loadCurrentThemeData(); + this._onDidChangeTheme.fire(this._themeData); + } + + get themeData() { + return this._themeData; + } +} \ No newline at end of file diff --git a/src/uriHandler.ts b/src/uriHandler.ts new file mode 100644 index 0000000000..f884f7c39c --- /dev/null +++ b/src/uriHandler.ts @@ -0,0 +1,240 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { GitApiImpl } from './api/api1'; +import { commands } from './common/executeCommands'; +import Logger from './common/logger'; +import { ITelemetry } from './common/telemetry'; +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 { UnresolvedIdentity } from './github/views'; +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<T>(owner: string, repo: string, prNumber: number, task: (progress: vscode.Progress<{ message?: string; increment?: number }>, token: vscode.CancellationToken) => Promise<T>): Promise<T> { + 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<T>; +} + +async function performPullRequestCheckout(reviewsManager: ReviewsManager, folderManager: FolderRepositoryManager, owner: string, repo: string, prNumber: number): Promise<void> { + 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<void> { + const pending = context.globalState.get<PendingCheckoutPayload>(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<boolean> { + 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 _git: GitApiImpl + ) { } + + async handleUri(uri: vscode.Uri): Promise<void> { + switch (uri.path) { + case UriHandlerPaths.OpenIssueWebview: + 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); + case UriHandlerPaths.OpenPullRequestChanges: + return this._openPullRequestChanges(uri); + } + } + + private async _openIssueWebview(uri: vscode.Uri): Promise<void> { + const params = fromOpenIssueWebviewUri(uri); + if (!params) { + return; + } + const folderManager = this._reposManagers.getManagerForRepository(params.owner, params.repo) ?? this._reposManagers.folderManagers[0]; + const identity = { owner: params.owner, repo: params.repo, number: params.issueNumber }; + return IssueOverviewPanel.createOrShow(this._telemetry, this._context.extensionUri, folderManager, identity); + } + + private async _resolveIdentityFromUri(uri: vscode.Uri): Promise<{ folderManager: FolderRepositoryManager, identity: UnresolvedIdentity } | undefined> { + const params = fromOpenOrCheckoutPullRequestWebviewUri(uri); + if (!params) { + vscode.window.showErrorMessage(vscode.l10n.t('Invalid pull request URI.')); + Logger.error('Failed to parse pull request URI.', UriHandler.ID); + return; + } + const folderManager = this._reposManagers.getManagerForRepository(params.owner, params.repo) ?? this._reposManagers.folderManagers[0]; + return { folderManager, identity: { owner: params.owner, repo: params.repo, number: params.pullRequestNumber } }; + } + + private async _resolvePullRequestFromIdentity(identity: UnresolvedIdentity, folderManager: FolderRepositoryManager): Promise<PullRequestModel | undefined> { + const pullRequest = await folderManager.resolvePullRequest(identity.owner, identity.repo, identity.number); + if (!pullRequest) { + vscode.window.showErrorMessage(vscode.l10n.t('Pull request {0}/{1}#{2} not found.', identity.owner, identity.repo, identity.number)); + Logger.error(`Pull request not found: ${identity.owner}/${identity.repo}#${identity.number}`, UriHandler.ID); + return; + } + return pullRequest; + } + + private async _openPullRequestWebview(uri: vscode.Uri): Promise<void> { + const resolved = await this._resolveIdentityFromUri(uri); + if (!resolved) { + return; + } + return PullRequestOverviewPanel.createOrShow(this._telemetry, this._context.extensionUri, resolved.folderManager, resolved.identity); + } + + private async _openPullRequestChanges(uri: vscode.Uri): Promise<void> { + const resolved = await this._resolveIdentityFromUri(uri); + if (!resolved) { + return; + } + const pullRequest = await this._resolvePullRequestFromIdentity(resolved.identity, resolved.folderManager); + if (!pullRequest) { + return; + } + return PullRequestModel.openChanges(resolved.folderManager, pullRequest); + } + + private async _savePendingCheckoutAndOpenFolder(params: { owner: string; repo: string; pullRequestNumber: number }, folderUri: vscode.Uri): Promise<void> { + 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<void> { + 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<void> { + 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/commentControllBase.ts b/src/view/commentControllBase.ts new file mode 100644 index 0000000000..083b193ccd --- /dev/null +++ b/src/view/commentControllBase.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ITelemetry } from '../common/telemetry'; +import { Schemes } from '../common/uri'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { GitHubRepository } from '../github/githubRepository'; +import { isCopilotOnMyBehalf, PullRequestModel } from '../github/pullRequestModel'; + +export abstract class CommentControllerBase extends Disposable { + constructor( + protected _folderRepoManager: FolderRepositoryManager, + protected _telemetry: ITelemetry + + ) { + super(); + + this._register(vscode.window.onDidChangeActiveTextEditor(e => this.onDidChangeActiveTextEditor(e))); + } + + protected _commentController: vscode.CommentController; + + public get commentController(): vscode.CommentController { + return this._commentController; + } + + protected githubReposForPullRequest(pullRequest: undefined): undefined; + protected githubReposForPullRequest(pullRequest: PullRequestModel): GitHubRepository[]; + protected githubReposForPullRequest(pullRequest: PullRequestModel | undefined): GitHubRepository[] | undefined; + protected githubReposForPullRequest(pullRequest: PullRequestModel | undefined): GitHubRepository[] | undefined { + const githubRepositories = pullRequest ? [pullRequest.githubRepository] : undefined; + if (githubRepositories && pullRequest?.head) { + const headRepo = this._folderRepoManager.findExistingGitHubRepository({ owner: pullRequest.head.owner, repositoryName: pullRequest.remote.repositoryName }); + if (headRepo) { + githubRepositories.push(headRepo); + } + } + return githubRepositories; + } + + protected abstract onDidChangeActiveTextEditor(editor: vscode.TextEditor | undefined); + + protected async tryAddCopilotMention(editor: vscode.TextEditor, pullRequest: PullRequestModel) { + if (editor.document.uri.scheme !== Schemes.Comment) { + return; + } + + if (editor.document.lineCount < 1 || editor.document.lineAt(0).text.length > 0) { + return; + } + + const currentUser = await this._folderRepoManager.getCurrentUser(); + if (!await isCopilotOnMyBehalf(pullRequest, currentUser)) { + return; + } + + return editor.edit(editBuilder => { + editBuilder.insert(new vscode.Position(0, 0), '@copilot '); + }); + } +} + diff --git a/src/view/commentDecorationProvider.ts b/src/view/commentDecorationProvider.ts new file mode 100644 index 0000000000..86a14aa2fd --- /dev/null +++ b/src/view/commentDecorationProvider.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { TreeDecorationProvider } from './treeDecorationProviders'; +import { fromFileChangeNodeUri, Schemes } from '../common/uri'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { PullRequestModel } from '../github/pullRequestModel'; +import { RepositoriesManager } from '../github/repositoriesManager'; + +export class CommentDecorationProvider extends TreeDecorationProvider { + + constructor(private readonly _repositoriesManager: RepositoriesManager) { + super(); + } + + registerPullRequestPropertyChangedListeners(folderManager: FolderRepositoryManager, model: PullRequestModel): vscode.Disposable { + return model.onDidChangeReviewThreads(changed => { + [...changed.added, ...changed.removed].forEach(change => { + this._handlePullRequestPropertyChange(folderManager, model, change); + }); + }); + } + + provideFileDecoration( + uri: vscode.Uri, + _token: vscode.CancellationToken, + ): vscode.ProviderResult<vscode.FileDecoration> { + if ((uri.scheme !== Schemes.Pr) && (uri.scheme !== Schemes.File) && (uri.scheme !== Schemes.Review) && (uri.scheme !== Schemes.FileChange)) { + return undefined; + } + + const query = fromFileChangeNodeUri(uri); + const folderManager = this._repositoriesManager.getManagerForFile(uri); + if (query && folderManager) { + const hasComment = folderManager.gitHubRepositories.find(repo => { + const pr = repo.getExistingPullRequestModel(query.prNumber); + if (pr?.reviewThreadsCache.find(c => c.path === query.fileName)) { + return true; + } + }); + if (hasComment) { + const decoration: vscode.FileDecoration2 = { + propagate: false, + tooltip: vscode.l10n.t('Commented'), + badge: new vscode.ThemeIcon('comment'), + }; + return decoration as vscode.FileDecoration; + } + } + return undefined; + } + +} + diff --git a/src/view/commitsDecorationProvider.ts b/src/view/commitsDecorationProvider.ts new file mode 100644 index 0000000000..a9008b19a5 --- /dev/null +++ b/src/view/commitsDecorationProvider.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 { TreeDecorationProvider } from './treeDecorationProviders'; +import { createCommitsNodeUri, fromCommitsNodeUri, Schemes } from '../common/uri'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { PullRequestModel } from '../github/pullRequestModel'; +import { RepositoriesManager } from '../github/repositoriesManager'; + +export class CommitsDecorationProvider extends TreeDecorationProvider { + + constructor(private readonly _repositoriesManager: RepositoriesManager) { + super(); + } + + registerPullRequestPropertyChangedListeners(_folderManager: FolderRepositoryManager, model: PullRequestModel): vscode.Disposable { + return model.onDidChange(e => { + if (e.timeline) { + // Timeline changed, which may include new commits, so update the decoration + const uri = createCommitsNodeUri(model.remote.owner, model.remote.repositoryName, model.number); + this._onDidChangeFileDecorations.fire(uri); + } + }); + } + + provideFileDecoration( + uri: vscode.Uri, + _token: vscode.CancellationToken, + ): vscode.ProviderResult<vscode.FileDecoration> { + if (uri.scheme !== Schemes.CommitsNode) { + return undefined; + } + + const params = fromCommitsNodeUri(uri); + if (!params) { + return undefined; + } + + const folderManager = this._repositoriesManager.getManagerForRepository(params.owner, params.repo); + + if (folderManager) { + const repo = folderManager.findExistingGitHubRepository({ owner: params.owner, repositoryName: params.repo }); + if (repo) { + const pr = repo.getExistingPullRequestModel(params.prNumber); + if (pr) { + const commitsCount = pr.item.commits.length; + return { + badge: commitsCount.toString(), + tooltip: vscode.l10n.t('{0} commits', commitsCount) + }; + } + } + } + + return undefined; + } + +} diff --git a/src/view/compareChangesTreeDataProvider.ts b/src/view/compareChangesTreeDataProvider.ts index ad0ab937d3..edae2c9721 100644 --- a/src/view/compareChangesTreeDataProvider.ts +++ b/src/view/compareChangesTreeDataProvider.ts @@ -3,61 +3,138 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as pathLib from 'path'; import * as vscode from 'vscode'; -import { Repository } from '../api/api'; +import { CreatePullRequestDataModel } from './createPullRequestDataModel'; +import { Change, Commit } from '../api/api'; +import { Status } from '../api/api1'; import { getGitChangeType } from '../common/diffHunk'; +import { GitChangeType } from '../common/file'; +import { Disposable, toDisposable } from '../common/lifecycle'; import Logger from '../common/logger'; import { Schemes } from '../common/uri'; +import { dateFromNow } from '../common/utils'; +import { OctokitCommon } from '../github/common'; import { FolderRepositoryManager } from '../github/folderRepositoryManager'; -import { GitHubRepository } from '../github/githubRepository'; -import { GitHubContentProvider } from './gitHubContentProvider'; import { GitHubFileChangeNode } from './treeNodes/fileChangeNode'; -import { TreeNode } from './treeNodes/treeNode'; +import { BaseTreeNode, TreeNode, TreeNodeParent } from './treeNodes/treeNode'; -export class CompareChangesTreeProvider implements vscode.TreeDataProvider<TreeNode> { - private _view: vscode.TreeView<TreeNode>; +export function getGitChangeTypeFromApi(status: Status): GitChangeType { + switch (status) { + case Status.DELETED: + return GitChangeType.DELETE; + case Status.ADDED_BY_US: + case Status.INDEX_ADDED: + return GitChangeType.ADD; + case Status.INDEX_RENAMED: + return GitChangeType.RENAME; + case Status.MODIFIED: + return GitChangeType.MODIFY; + default: + return GitChangeType.UNKNOWN; + } +} - private _onDidChangeTreeData = new vscode.EventEmitter<TreeNode | void>(); - readonly onDidChangeTreeData = this._onDidChangeTreeData.event; +class GitHubCommitNode extends TreeNode { + getTreeItem(): vscode.TreeItem | Promise<vscode.TreeItem> { + return { + label: this.commit.commit.message, + description: this.commit.commit.author?.date ? dateFromNow(new Date(this.commit.commit.author.date)) : undefined, + iconPath: new vscode.ThemeIcon('git-commit'), + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed + }; + } - private _contentProvider: GitHubContentProvider | undefined; + override async getChildren(): Promise<TreeNode[]> { + if (!this.model.gitHubRepository) { + return []; + } + const { octokit, remote } = await this.model.gitHubRepository.ensure(); + const { data } = await octokit.call(octokit.api.repos.compareCommits, { + repo: remote.repositoryName, + owner: remote.owner, + base: this.parentRef, + head: this.commit.sha, + }); - private _disposables: vscode.Disposable[] = []; + const rawFiles = data.files; - private _gitHubRepository: GitHubRepository | undefined; + if (!rawFiles) { + return []; + } + return rawFiles.map(file => { + return new GitHubFileChangeNode( + this, + file.filename, + file.previous_filename, + getGitChangeType(file.status), + this.parentRef, + this.commit.sha, + false, + ); + }); + } - get view(): vscode.TreeView<TreeNode> { - return this._view; + constructor(parent: TreeNodeParent, private readonly model: CreatePullRequestDataModel, private readonly commit: OctokitCommon.CompareCommits['commits'][0], private readonly parentRef) { + super(parent); } +} - constructor( - private readonly repository: Repository, - private baseOwner: string, - public baseBranchName: string, - private _compareOwner: string, - private compareBranchName: string, - private compareHasUpstream: boolean, - private folderRepoManager: FolderRepositoryManager, - ) { - this._view = vscode.window.createTreeView('github:compareChanges', { - treeDataProvider: this, +class GitCommitNode extends TreeNode { + getTreeItem(): vscode.TreeItem | Promise<vscode.TreeItem> { + return { + label: this.commit.message, + description: this.commit.authorDate ? dateFromNow(new Date(this.commit.authorDate)) : undefined, + iconPath: new vscode.ThemeIcon('git-commit'), + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed + }; + } + + override async getChildren(): Promise<TreeNode[]> { + const changes = await this.folderRepoManager.repository.diffBetween(this.parentRef, this.commit.hash); + + return changes.map(change => { + const filename = pathLib.posix.relative(this.folderRepoManager.repository.rootUri.path, change.uri.path); + const previousFilename = pathLib.posix.relative(this.folderRepoManager.repository.rootUri.path, change.originalUri.path); + return new GitHubFileChangeNode( + this, + filename, + previousFilename, + getGitChangeTypeFromApi(change.status), + this.parentRef, + this.commit.hash, + true, + ); }); + } - this._gitHubRepository = this.folderRepoManager.gitHubRepositories.find( - repo => repo.remote.owner === this._compareOwner, - ); + constructor(parent: TreeNodeParent, private readonly commit: Commit, private readonly folderRepoManager: FolderRepositoryManager, private readonly parentRef) { + super(parent); + } +} - this._disposables.push(this._view); +abstract class CompareChangesTreeProvider extends Disposable implements vscode.TreeDataProvider<TreeNode>, BaseTreeNode { + private static readonly ID = 'CompareChangesTreeProvider'; + private _view: vscode.TreeView<TreeNode>; + private _children: TreeNode[] | undefined; + private _onDidChangeTreeData = new vscode.EventEmitter<TreeNode | void>(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + get view(): vscode.TreeView<TreeNode> { + return this._view; } - updateBaseBranch(branch: string): void { - this.baseBranchName = branch; - this._onDidChangeTreeData.fire(); + set view(view: vscode.TreeView<TreeNode>) { + this._view = this._register(view); } - updateBaseOwner(owner: string) { - this.baseOwner = owner; - this._onDidChangeTreeData.fire(); + constructor( + protected readonly model: CreatePullRequestDataModel + ) { + super(); + this._register(model.onDidChange(() => { + this._onDidChangeTreeData.fire(); + })); } async reveal(treeNode: TreeNode, options?: { select?: boolean; focus?: boolean; expand?: boolean }): Promise<void> { @@ -68,113 +145,247 @@ export class CompareChangesTreeProvider implements vscode.TreeDataProvider<TreeN this._onDidChangeTreeData.fire(); } - private async updateHasUpstream(branch: string): Promise<void> { - // Currently, the list of selectable compare branches it those on GitHub, - // plus the current branch which may not be published yet. Check the - // status of the current branch using local git, otherwise assume it is from - // GitHub. - if (this.repository.state.HEAD?.name === branch) { - const compareBranch = await this.repository.getBranch(branch); - this.compareHasUpstream = !!compareBranch.upstream; - } else { - this.compareHasUpstream = true; - } + getTreeItem(element: TreeNode): vscode.TreeItem | Thenable<vscode.TreeItem> { + return element.getTreeItem(); } - async updateCompareBranch(branch?: string): Promise<void> { - if (branch) { - await this.updateHasUpstream(branch); - this.compareBranchName = branch; + protected async getRawGitHubData() { + try { + const rawFiles = await this.model.gitHubFiles(); + const rawCommits = await this.model.gitHubCommits(); + const mergeBase = await this.model.gitHubMergeBase(); + + if (!rawFiles?.length || !rawCommits?.length) { + (this.view as vscode.TreeView2<TreeNode>).message = new vscode.MarkdownString(vscode.l10n.t('There are no commits between the base `{0}` branch and the comparing `{1}` branch', this.model.baseBranch, this.model.compareBranch)); + return {}; + } else if (this._isDisposed) { + return {}; + } else { + this.view.message = undefined; + } + + return { rawFiles, rawCommits, mergeBase }; + } catch (e) { + const eWithName: Partial<{ name: string; status: number }> = e; + if (e.name && eWithName.name === 'HttpError' && eWithName.status === 404) { + (this.view as vscode.TreeView2<TreeNode>).message = new vscode.MarkdownString(vscode.l10n.t('The upstream branch `{0}` does not exist on GitHub', this.model.baseBranch)); + } + return {}; } - this._onDidChangeTreeData.fire(); } - get compareOwner(): string { - return this._compareOwner; - } + protected abstract getGitHubChildren(element?: TreeNode): Promise<TreeNode[] | undefined>; - set compareOwner(owner: string) { - this._gitHubRepository = this.folderRepoManager.gitHubRepositories.find(repo => repo.remote.owner === owner); + protected abstract getGitChildren(element?: TreeNode): Promise<TreeNode[] | undefined>; - if (this._contentProvider && this._gitHubRepository) { - this._contentProvider.gitHubRepository = this._gitHubRepository; - } + get children(): TreeNode[] | undefined { + return this._children; + } - this._compareOwner = owner; - this._onDidChangeTreeData.fire(); + async getChildren(element?: TreeNode) { + try { + if (await this.model.getCompareHasUpstream()) { + this._children = await this.getGitHubChildren(element); + } else { + this._children = await this.getGitChildren(element); + } + } catch (e) { + Logger.error(`Comparing changes failed: ${e}`, CompareChangesTreeProvider.ID); + return []; + } + return this._children; } - getTreeItem(element: TreeNode): vscode.TreeItem | Thenable<vscode.TreeItem> { - return element.getTreeItem(); + public static closeTabs() { + vscode.window.tabGroups.all.forEach(group => group.tabs.forEach(tab => { + if (tab.input instanceof vscode.TabInputTextDiff) { + if ((tab.input.modified.scheme === Schemes.GithubPr) || (tab.input.modified.scheme === Schemes.GitPr)) { + vscode.window.tabGroups.close(tab); + } + } + })); } +} - async getChildren() { - // If no upstream, show error. - if (!this.compareHasUpstream) { - vscode.commands.executeCommand('setContext', 'github:noUpstream', true); - this._view.message = undefined; - return []; - } else { - vscode.commands.executeCommand('setContext', 'github:noUpstream', false); - } +class CompareChangesFilesTreeProvider extends CompareChangesTreeProvider { + constructor( + model: CreatePullRequestDataModel, + private folderRepoManager: FolderRepositoryManager, + ) { + super(model); + } - if (!this._gitHubRepository) { - return []; + protected async getGitHubChildren(element?: TreeNode) { + if (element) { + return element.getChildren(); } - if (!this._contentProvider) { - try { - this._contentProvider = new GitHubContentProvider(this._gitHubRepository); - this._disposables.push( - vscode.workspace.registerFileSystemProvider(Schemes.GithubPr, this._contentProvider, { - isReadonly: true, - }), + const { rawFiles, mergeBase } = await this.getRawGitHubData(); + if (rawFiles && mergeBase) { + (this.view as vscode.TreeView2<TreeNode>).message = this.addReviewMessage(); + return rawFiles.map(file => { + return new GitHubFileChangeNode( + this, + file.filename, + file.previous_filename, + getGitChangeType(file.status), + mergeBase, + this.model.compareBranch, + false, ); - } catch (e) { - // already registered - } + }); } + } - const { octokit, remote } = await this._gitHubRepository.ensure(); + private async getGitFileChildren(diff: Change[]) { + return diff.map(change => { + const filename = pathLib.posix.relative(this.folderRepoManager.repository.rootUri.path, change.uri.path); + const previousFilename = pathLib.posix.relative(this.folderRepoManager.repository.rootUri.path, change.originalUri.path); + return new GitHubFileChangeNode( + this, + filename, + previousFilename, + getGitChangeTypeFromApi(change.status), + this.model.baseBranch, + this.model.compareBranch, + true, + ); + }); + } - try { - const { data } = await octokit.call(octokit.api.repos.compareCommits, { - repo: remote.repositoryName, - owner: remote.owner, - base: `${this.baseOwner}:${this.baseBranchName}`, - head: `${this.compareOwner}:${this.compareBranchName}`, - }); + private addReviewMessage(markdown?: vscode.MarkdownString): vscode.MarkdownString | undefined { + const preReviewer = this.folderRepoManager.getAutoReviewer(); + if (!preReviewer) { + return markdown; + } + if (!markdown) { + markdown = new vscode.MarkdownString(); + } else { + markdown.appendMarkdown('\n\n'); + } + markdown.supportThemeIcons = true; + markdown.appendMarkdown(`[${vscode.l10n.t('$(sparkle) {0} Code Review', preReviewer.title)}](command:pr.preReview)`); + return markdown; + } - if (!data.files?.length) { - this._view.message = `There are no commits between the base '${this.baseBranchName}' branch and the comparing '${this.compareBranchName}' branch`; + protected async getGitChildren(element?: TreeNode) { + if (!element) { + const diff = await this.model.gitFiles(); + if (diff.length === 0) { + (this.view as vscode.TreeView2<TreeNode>).message = new vscode.MarkdownString(vscode.l10n.t('There are no commits between the base `{0}` branch and the comparing `{1}` branch', this.model.baseBranch, this.model.compareBranch)); + return []; + } else if (!(await this.model.getCompareHasUpstream())) { + const message = new vscode.MarkdownString(vscode.l10n.t({ message: 'Branch `{0}` has not been pushed yet. [Publish branch](command:git.publish) to see all changes from base branch.', args: [this.model.compareBranch], comment: "{Locked='](command:git.publish)'}" })); + message.isTrusted = { enabledCommands: ['git.publish'] }; + (this.view as vscode.TreeView2<TreeNode>).message = this.addReviewMessage(message); } else if (this._isDisposed) { return []; } else { - this._view.message = undefined; + this.view.message = undefined; } - return data.files?.map(file => { - return new GitHubFileChangeNode( - this, - file.filename, - file.previous_filename, - getGitChangeType(file.status), - data.merge_base_commit.sha, - this.compareBranchName, - ); + return this.getGitFileChildren(diff); + } else { + return element.getChildren(); + } + + } +} + +class CompareChangesCommitsTreeProvider extends CompareChangesTreeProvider { + constructor( + model: CreatePullRequestDataModel, + private readonly folderRepoManager: FolderRepositoryManager + ) { + super(model); + } + + protected async getGitHubChildren(element?: TreeNode) { + if (element) { + return element.getChildren(); + } + + const { rawCommits } = await this.getRawGitHubData(); + if (rawCommits) { + return rawCommits.map((commit, index) => { + return new GitHubCommitNode(this, this.model, commit, index === 0 ? this.model.baseBranch : rawCommits[index - 1].sha); }); - } catch (e) { - Logger.error(`Comparing changes failed: ${e}`); + } + } + + protected async getGitChildren(element?: TreeNode) { + if (element) { + return element.getChildren(); + } + + const log = await this.model.gitCommits(); + if (log.length === 0) { + (this.view as vscode.TreeView2<TreeNode>).message = new vscode.MarkdownString(vscode.l10n.t('There are no commits between the base `{0}` branch and the comparing `{1}` branch', this.model.baseBranch, this.model.compareBranch)); + return []; + } else if (this._isDisposed) { return []; + } else { + this.view.message = undefined; } + + return log.reverse().map((commit, index) => { + return new GitCommitNode(this, commit, this.folderRepoManager, index === 0 ? this.model.baseBranch : log[index - 1].hash); + }); } +} - private _isDisposed: boolean = false; - dispose() { - this._isDisposed = true; - this._disposables.forEach(d => d.dispose()); - this._contentProvider = undefined; - this._view.dispose(); +export class CompareChanges extends Disposable { + private readonly _filesView: vscode.TreeView<TreeNode>; + private readonly _filesDataProvider: CompareChangesFilesTreeProvider; + private readonly _commitsView: vscode.TreeView<TreeNode>; + private readonly _commitsDataProvider: CompareChangesCommitsTreeProvider; + + constructor( + folderRepoManager: FolderRepositoryManager, + private model: CreatePullRequestDataModel + ) { + super(); + this._filesDataProvider = this._register(new CompareChangesFilesTreeProvider(model, folderRepoManager)); + this._filesView = this._register(vscode.window.createTreeView('github:compareChangesFiles', { + treeDataProvider: this._filesDataProvider + })); + this._filesDataProvider.view = this._filesView; + this._commitsDataProvider = this._register(new CompareChangesCommitsTreeProvider(model, folderRepoManager)); + this._commitsView = this._register(vscode.window.createTreeView('github:compareChangesCommits', { + treeDataProvider: this._commitsDataProvider + })); + this._commitsDataProvider.view = this._commitsView; + + this.initialize(); + } + + set compareOwner(owner: string) { + this.model.compareOwner = owner; + } + + private initialize() { + if (!this.model.gitHubRepository) { + return; + } + + try { + this._register(vscode.workspace.registerFileSystemProvider(Schemes.GithubPr, this.model.gitHubContentProvider)); + this._register(vscode.workspace.registerFileSystemProvider(Schemes.GitPr, this.model.gitContentProvider)); + this._register(toDisposable(() => CompareChangesTreeProvider.closeTabs())); + } catch (e) { + // already registered + } + + } + + public static closeTabs() { + vscode.window.tabGroups.all.forEach(group => group.tabs.forEach(tab => { + if (tab.input instanceof vscode.TabInputTextDiff) { + if ((tab.input.modified.scheme === Schemes.GithubPr) || (tab.input.modified.scheme === Schemes.GitPr)) { + vscode.window.tabGroups.close(tab); + } + } + })); } } diff --git a/src/view/conflictResolution/conflictResolutionTreeView.ts b/src/view/conflictResolution/conflictResolutionTreeView.ts new file mode 100644 index 0000000000..e66d6e191c --- /dev/null +++ b/src/view/conflictResolution/conflictResolutionTreeView.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { commands } from '../../common/executeCommands'; +import { Disposable } from '../../common/lifecycle'; +import { Conflict, ConflictResolutionModel } from '../../github/conflictResolutionModel'; + +interface ConflictNode { + conflict: Conflict; +} + +export class ConflictResolutionTreeView extends Disposable implements vscode.TreeDataProvider<ConflictNode> { + private readonly _treeView: vscode.TreeView<ConflictNode>; + private readonly _onDidChangeTreeData: vscode.EventEmitter<void | ConflictNode[]> = this._register(new vscode.EventEmitter<void | ConflictNode[]>()); + onDidChangeTreeData: vscode.Event<void | ConflictNode[]> = this._onDidChangeTreeData.event; + + constructor(private readonly _conflictResolutionModel: ConflictResolutionModel) { + super(); + this._treeView = this._register(vscode.window.createTreeView('github:conflictResolution', { treeDataProvider: this })); + this._register(this._conflictResolutionModel.onAddedResolution(() => this._onDidChangeTreeData.fire())); + commands.focusView('github:conflictResolution'); + } + + async getTreeItem(element: ConflictNode): Promise<vscode.TreeItem> { + const resource = vscode.Uri.from({ path: element.conflict.prHeadFilePath, scheme: 'conflictResolution' }); + const item = new vscode.TreeItem(resource); + if (this._conflictResolutionModel.isResolved(element.conflict.prHeadFilePath)) { + item.iconPath = new vscode.ThemeIcon('check'); + item.command = { + command: 'vscode.diff', + arguments: [ + this._conflictResolutionModel.baseUri(element.conflict), + this._conflictResolutionModel.mergeOutputUri(element.conflict), + `Merge result for ${element.conflict.prHeadFilePath}`, + ], + title: vscode.l10n.t('View Merge Result') + }; + } else { + item.command = { + command: 'pr.resolveConflict', + title: vscode.l10n.t('Resolve Conflict'), + arguments: [element.conflict] + }; + } + + return item; + } + + async getChildren(element?: ConflictNode | undefined): Promise<ConflictNode[]> { + if (element) { + return []; + } + const exit = new vscode.MarkdownString(); + exit.isTrusted = { + enabledCommands: ['pr.exitConflictResolutionMode', 'pr.completeMerge'] + }; + let children: ConflictNode[] = []; + if (!this._conflictResolutionModel.isResolvable()) { + exit.appendMarkdown(vscode.l10n.t('Not all conflicts can be resolved here. Check out the pull request to manually resolve conflicts.\n\n[Exit conflict resolution mode](command:pr.exitConflictResolutionMode)')); + } else { + if (this._conflictResolutionModel.areAllConflictsResolved) { + exit.appendMarkdown(vscode.l10n.t('All conflicts have been resolved.\n\n[Complete merge](command:pr.completeMerge)\n\n[Exit without merging](command:pr.exitConflictResolutionMode)')); + } else { + exit.appendMarkdown(vscode.l10n.t('Resolve all conflicts or [exit conflict resolution mode](command:pr.exitConflictResolutionMode)')); + } + children = Array.from(this._conflictResolutionModel.startingConflicts.values()).map(conflict => ({ conflict })); + } + (this._treeView as vscode.TreeView2<ConflictNode>).message = exit; + return children; + } +} \ No newline at end of file diff --git a/src/view/createPullRequestDataModel.ts b/src/view/createPullRequestDataModel.ts new file mode 100644 index 0000000000..f6f3ec0f98 --- /dev/null +++ b/src/view/createPullRequestDataModel.ts @@ -0,0 +1,255 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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'; + +export interface CreateModelChangeEvent { + baseOwner?: string; + baseBranch?: string; + compareOwner?: string; + compareBranch?: string; +} + +export class CreatePullRequestDataModel extends Disposable { + private static ID = 'CreatePullRequestDataModel'; + private _baseOwner: string; + private _baseBranch: string; + private _compareOwner: string; + private _compareBranch: string; + private _constructed: Promise<void>; + private readonly _onDidChange: vscode.EventEmitter<CreateModelChangeEvent> = new vscode.EventEmitter<CreateModelChangeEvent>(); + public readonly onDidChange = this._onDidChange.event; + private _compareGitHubRepository: GitHubRepository | undefined; + + private _gitLog: Promise<Commit[]> | undefined; + private _gitFiles: Change[] | undefined; + private _compareHasUpstream: boolean = false; + + private _gitHubMergeBase: string | undefined; + private _gitHubLog: OctokitCommon.Commit[] | undefined; + private _gitHubFiles: OctokitCommon.CommitFiles; + + private _gitHubcontentProvider: GitHubContentProvider; + private _gitcontentProvider: GitContentProvider; + + constructor(private readonly folderRepositoryManager: FolderRepositoryManager, baseOwner: string, baseBranch: string, compareOwner: string, compareBranch: string, public readonly repositoryName: string) { + super(); + this._baseOwner = baseOwner; + this._baseBranch = baseBranch; + this._compareBranch = compareBranch; + this._gitcontentProvider = new GitContentProvider(this.folderRepositoryManager); + this._compareGitHubRepository = this.folderRepositoryManager.gitHubRepositories.find(githubRepo => githubRepo.remote.owner === compareOwner && githubRepo.remote.repositoryName === repositoryName); + this._gitHubcontentProvider = new GitHubContentProvider(this.folderRepositoryManager.gitHubRepositories); + this._constructed = new Promise<void>(resolve => this.setCompareBranch(compareBranch).then(resolve)); + this.compareOwner = compareOwner; + this._register(folderRepositoryManager.repository.state.onDidChange(() => { + if (folderRepositoryManager.repository.state.HEAD?.name === this._compareBranch) { + // We assume that the commit has changed. + this.update({}); + } + })); + } + + get gitHubContentProvider(): GitHubContentProvider { + return this._gitHubcontentProvider; + } + + get gitContentProvider(): GitContentProvider { + return this._gitcontentProvider; + } + + private get baseRemoteName(): string { + const findValue = `/${this._baseOwner.toLowerCase()}/`; + return this.folderRepositoryManager.repository.state.remotes.find(remote => remote.fetchUrl?.toLowerCase().includes(findValue))?.name ?? 'origin'; + } + + public get baseOwner(): string { + return this._baseOwner; + } + + public set baseOwner(value: string) { + if (value !== this._baseOwner) { + this._baseOwner = value; + this.update({ baseOwner: this._baseOwner }); + } + } + + public get baseBranch(): string { + return this._baseBranch; + } + + public set baseBranch(value: string) { + if (value !== this._baseBranch) { + this._baseBranch = value; + this.update({ baseBranch: this._baseBranch }); + } + } + + public get compareOwner(): string { + return this._compareOwner; + } + + public set compareOwner(value: string) { + if (value !== this._compareOwner) { + this._compareOwner = value; + this._compareGitHubRepository = this.folderRepositoryManager.gitHubRepositories.find( + repo => repo.remote.owner === this._compareOwner, + ); + if (this._compareGitHubRepository) { + this._gitHubcontentProvider.gitHubRepository = this._compareGitHubRepository; + } + this.update({ compareOwner: this._compareOwner }); + } + } + + private async getContentProvider(): Promise<ChangesContentProvider> { + if (await this.getCompareHasUpstream()) { + return this.gitHubContentProvider; + } else { + return this.gitContentProvider; + } + } + + public async filesHaveChanges(): Promise<boolean> { + return this.getContentProvider().then(provider => provider.hasChanges()); + } + + public async applyChanges(commitMessage: string): Promise<boolean> { + if (await this.getCompareHasUpstream()) { + return this.gitHubContentProvider.applyChanges(commitMessage, this._compareBranch); + } else { + return this.gitContentProvider.applyChanges(commitMessage); + } + } + + get compareBranch(): string { + return this._compareBranch; + } + + public async setCompareBranch(value: string | undefined): Promise<void> { + const oldUpstreamValue = this._compareHasUpstream; + let changed: boolean = false; + if (value) { + changed = (await this.updateHasUpstream(value)) !== oldUpstreamValue; + } + if (this._compareBranch !== value) { + changed = true; + if (value) { + this._compareBranch = value; + } + } + if (changed) { + this.update({ compareBranch: this._compareBranch }); + } + } + + private async updateHasUpstream(branch: string): Promise<boolean> { + const compareBranch = await this.folderRepositoryManager.repository.getBranch(branch); + this._compareHasUpstream = !!compareBranch.upstream; + // Check that the upstream head matches the local head + if (this._compareHasUpstream) { + const upstream = await this.gitHubRepository?.hasBranch(branch); + this._compareHasUpstream = upstream === compareBranch.commit; + } + return this._compareHasUpstream; + } + + public async getCompareHasUpstream(): Promise<boolean> { + await this._constructed; + return this._compareHasUpstream; + } + + public get gitHubRepository(): GitHubRepository | undefined { + return this._compareGitHubRepository; + } + + private update(changeEvent: CreateModelChangeEvent) { + this._gitLog = undefined; + this._gitFiles = undefined; + this._gitHubLog = undefined; + this._gitHubFiles = undefined; + this.gitContentProvider.editableBranch = (this._compareBranch === this.folderRepositoryManager.repository.state.HEAD?.name) ? this._compareBranch : undefined; + this.gitHubContentProvider.editableBranch = this._compareBranch; + this._onDidChange.fire(changeEvent); + } + + public async gitCommits(): Promise<Commit[]> { + await this._constructed; + if (this._gitLog === undefined) { + const startBase = this._baseBranch; + const startCompare = this._compareBranch; + const result = this.folderRepositoryManager.repository.log({ range: `${this.baseRemoteName}/${this._baseBranch}..${this._compareBranch}` }); + if (startBase !== this._baseBranch || startCompare !== this._compareBranch) { + // The branches have changed while we were waiting for the log. We can use the result, but we shouldn't save it + return result; + } else { + this._gitLog = result; + } + } + return this._gitLog; + } + + public async gitFiles(): Promise<Change[]> { + await this._constructed; + if (this._gitFiles === undefined) { + const startBase = this._baseBranch; + const startCompare = this._compareBranch; + const result = await this.folderRepositoryManager.repository.diffBetween(`${this.baseRemoteName}/${this._baseBranch}`, this._compareBranch); + if (startBase !== this._baseBranch || startCompare !== this._compareBranch) { + Logger.debug(`Branches have changed while getting git diff. Base: ${startBase} -> ${this._baseBranch}, Compare: ${startCompare} -> ${this._compareBranch}`, CreatePullRequestDataModel.ID); + // The branches have changed while we were waiting for the diff. We can use the result, but we shouldn't save it + return result; + } else { + Logger.debug(`Got ${result.length} git file diffs for merging ${this._compareOwner}/${this._compareBranch} in ${this._baseOwner}/${this._baseBranch}`, CreatePullRequestDataModel.ID); + this._gitFiles = result; + } + } + return this._gitFiles; + } + + public async gitHubCommits(): Promise<OctokitCommon.Commit[]> { + await this._constructed; + if (!this._compareGitHubRepository) { + return []; + } + + if (this._gitHubLog === undefined) { + const { octokit, remote } = await this._compareGitHubRepository.ensure(); + const { data } = await octokit.call(octokit.api.repos.compareCommits, { + repo: remote.repositoryName, + owner: remote.owner, + base: `${this._baseOwner}:${this._baseBranch}`, + head: `${this._compareOwner}:${this._compareBranch}`, + }); + this._gitHubLog = data.commits; + this._gitHubFiles = data.files ?? []; + this._gitHubMergeBase = data.merge_base_commit.sha; + } + return this._gitHubLog; + } + + public async gitHubFiles(): Promise<OctokitCommon.CommitFiles> { + await this._constructed; + if (this._gitHubFiles === undefined) { + await this.gitHubCommits(); + } + return this._gitHubFiles!; + } + + public async gitHubMergeBase(): Promise<string> { + await this._constructed; + if (this._gitHubMergeBase === undefined) { + await this.gitHubCommits(); + } + return this._gitHubMergeBase!; + } +} \ No newline at end of file diff --git a/src/view/createPullRequestHelper.ts b/src/view/createPullRequestHelper.ts index cd69961d45..63339033e8 100644 --- a/src/view/createPullRequestHelper.ts +++ b/src/view/createPullRequestHelper.ts @@ -4,81 +4,140 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { CompareChanges } from './compareChangesTreeDataProvider'; +import { CreatePullRequestDataModel } from './createPullRequestDataModel'; import { Repository } from '../api/api'; -import { CreatePullRequestViewProvider } from '../github/createPRViewProvider'; +import { commands } from '../common/executeCommands'; +import { addDisposable, Disposable, disposeAll } from '../common/lifecycle'; +import { ITelemetry } from '../common/telemetry'; +import { BaseCreatePullRequestViewProvider, BasePullRequestDataModel, CreatePullRequestViewProvider } from '../github/createPRViewProvider'; import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; import { PullRequestModel } from '../github/pullRequestModel'; -import { CompareChangesTreeProvider } from './compareChangesTreeDataProvider'; +import { RevertPullRequestViewProvider } from '../github/revertPRViewProvider'; -export class CreatePullRequestHelper { - private _disposables: vscode.Disposable[] = []; - private _createPRViewProvider: CreatePullRequestViewProvider | undefined; - private _treeView: CompareChangesTreeProvider | undefined; +export class CreatePullRequestHelper extends Disposable { + private _currentDisposables: vscode.Disposable[] = []; + private _createPRViewProvider: BaseCreatePullRequestViewProvider | undefined; + private _treeView: CompareChanges | undefined; + private _postCreateCallback: ((pullRequestModel: PullRequestModel | undefined) => Promise<void>) | undefined; + private _activeContext: string | undefined; - private _onDidCreate = new vscode.EventEmitter<PullRequestModel>(); - readonly onDidCreate: vscode.Event<PullRequestModel> = this._onDidCreate.event; + constructor() { + super(); + } - constructor(private readonly repository: Repository) { } + private async setActiveContext(value: boolean) { + if (this._activeContext) { + await commands.setContext(this._activeContext, value); + } + } - private registerListeners(usingCurrentBranchAsCompare: boolean) { - this._disposables.push( + private registerListeners(repository: Repository, usingCurrentBranchAsCompare: boolean) { + addDisposable( this._createPRViewProvider!.onDone(async createdPR => { - vscode.commands.executeCommand('setContext', 'github:createPullRequest', false); + await CreatePullRequestViewProvider.withProgress(async () => { + return this._postCreateCallback?.(createdPR); + }); + this.dispose(); + }), + this._currentDisposables + ); - this._createPRViewProvider?.dispose(); - this._createPRViewProvider = undefined; + addDisposable( + vscode.commands.registerCommand('pr.addAssigneesToNewPr', _ => { + return this._createPRViewProvider?.addAssignees(); - this._treeView?.dispose(); - this._treeView = undefined; + }), + this._currentDisposables + ); - this._disposables.forEach(d => d.dispose()); + addDisposable( + vscode.commands.registerCommand('pr.addReviewersToNewPr', _ => { + return this._createPRViewProvider?.addReviewers(); + }), + this._currentDisposables + ); - if (createdPR) { - this._onDidCreate.fire(createdPR); - } + addDisposable( + vscode.commands.registerCommand('pr.addLabelsToNewPr', _ => { + return this._createPRViewProvider?.addLabels(); }), + this._currentDisposables ); - this._disposables.push( - this._createPRViewProvider!.onDidChangeCompareBranch(compareBranch => { - this._treeView?.updateCompareBranch(compareBranch); + addDisposable( + vscode.commands.registerCommand('pr.addMilestoneToNewPr', _ => { + return this._createPRViewProvider?.addMilestone(); + }), + this._currentDisposables ); - this._disposables.push( - this._createPRViewProvider!.onDidChangeCompareRemote(compareRemote => { - if (this._treeView) { - this._treeView.compareOwner = compareRemote.owner; - } + addDisposable( + vscode.commands.registerCommand('pr.addProjectsToNewPr', _ => { + return this._createPRViewProvider?.addProjects(); + }), + this._currentDisposables ); - this._disposables.push( - this._createPRViewProvider!.onDidChangeBaseBranch(baseBranch => { - this._treeView?.updateBaseBranch(baseBranch); + addDisposable( + vscode.commands.registerCommand('pr.createPrMenuCreate', () => { + this._createPRViewProvider?.createFromCommand(false, false, undefined); + }), + this._currentDisposables ); + addDisposable( + vscode.commands.registerCommand('pr.createPrMenuDraft', () => { + this._createPRViewProvider?.createFromCommand(true, false, undefined); - this._disposables.push( - this._createPRViewProvider!.onDidChangeBaseRemote(remoteInfo => { - this._treeView?.updateBaseOwner(remoteInfo.owner); }), + this._currentDisposables ); + addDisposable( + vscode.commands.registerCommand('pr.createPrMenuMergeWhenReady', () => { + this._createPRViewProvider?.createFromCommand(false, true, undefined, true); - this._disposables.push( - vscode.commands.registerCommand('pr.addLabelsToNewPr', _ => { - return this._createPRViewProvider?.addLabels(); }), + this._currentDisposables + ); + addDisposable( + vscode.commands.registerCommand('pr.createPrMenuMerge', () => { + this._createPRViewProvider?.createFromCommand(false, true, 'merge'); + + }), + this._currentDisposables + ); + addDisposable( + vscode.commands.registerCommand('pr.createPrMenuSquash', () => { + this._createPRViewProvider?.createFromCommand(false, true, 'squash'); + }), + this._currentDisposables + ); + addDisposable( + vscode.commands.registerCommand('pr.createPrMenuRebase', () => { + this._createPRViewProvider?.createFromCommand(false, true, 'rebase'); + }), + this._currentDisposables + ); + addDisposable( + vscode.commands.registerCommand('pr.preReview', () => { + if (this._createPRViewProvider instanceof CreatePullRequestViewProvider) { + this._createPRViewProvider.review(); + } + }), + this._currentDisposables ); if (usingCurrentBranchAsCompare) { - this._disposables.push( - this.repository.state.onDidChange(_ => { - if (this._createPRViewProvider && this.repository.state.HEAD) { - this._createPRViewProvider.defaultCompareBranch = this.repository.state.HEAD; - this._treeView?.updateCompareBranch(); + addDisposable( + repository.state.onDidChange(_ => { + if (this._createPRViewProvider && repository.state.HEAD && this._createPRViewProvider instanceof CreatePullRequestViewProvider) { + this._createPRViewProvider.setDefaultCompareBranch(repository.state.HEAD); } }), + this._currentDisposables ); } } @@ -109,52 +168,124 @@ export class CreatePullRequestHelper { } } + async revert( + telemetry: ITelemetry, + extensionUri: vscode.Uri, + folderRepoManager: FolderRepositoryManager, + pullRequestModel: PullRequestModel, + callback: (pullRequest: PullRequestModel | undefined) => Promise<void>, + ) { + const recreate = !this._createPRViewProvider || !(this._createPRViewProvider instanceof RevertPullRequestViewProvider); + if (recreate) { + this.reset(); + } + + this._postCreateCallback = callback; + await folderRepoManager.loginAndUpdate(); + this._activeContext = 'github:revertPullRequest'; + this.setActiveContext(true); + + if (recreate) { + this._createPRViewProvider?.dispose(); + const model: BasePullRequestDataModel = { + baseOwner: pullRequestModel.remote.owner, + repositoryName: pullRequestModel.remote.repositoryName + }; + this._createPRViewProvider = addDisposable(new RevertPullRequestViewProvider( + telemetry, + model, + extensionUri, + folderRepoManager, + { base: pullRequestModel.base.name, owner: pullRequestModel.remote.owner, repo: pullRequestModel.remote.repositoryName }, + pullRequestModel + ), this._currentDisposables); + + this.registerListeners(folderRepoManager.repository, false); + + addDisposable( + vscode.window.registerWebviewViewProvider( + this._createPRViewProvider.viewType, + this._createPRViewProvider, + ), + this._currentDisposables + ); + } + + this._createPRViewProvider!.show(); + } + async create( + telemetry: ITelemetry, extensionUri: vscode.Uri, folderRepoManager: FolderRepositoryManager, compareBranch: string | undefined, + callback: (pullRequestModel: PullRequestModel | undefined) => Promise<void>, ) { + const recreate = !this._createPRViewProvider || !(this._createPRViewProvider instanceof CreatePullRequestViewProvider); + if (recreate) { + this.reset(); + } + + this._postCreateCallback = callback; await folderRepoManager.loginAndUpdate(); - vscode.commands.executeCommand('setContext', 'github:createPullRequest', true); + this._activeContext = 'github:createPullRequest'; + this.setActiveContext(true); const branch = ((compareBranch ? await folderRepoManager.repository.getBranch(compareBranch) : undefined) ?? - folderRepoManager.repository.state.HEAD)!; + folderRepoManager.repository.state.HEAD?.name ? folderRepoManager.repository.state.HEAD : undefined); - if (!this._createPRViewProvider) { + let createViewProvider: CreatePullRequestViewProvider; + if (recreate) { + this._createPRViewProvider?.dispose(); const pullRequestDefaults = await this.ensureDefaultsAreLocal( folderRepoManager, await folderRepoManager.getPullRequestDefaults(branch), ); - this._createPRViewProvider = new CreatePullRequestViewProvider( + const compareOrigin = await folderRepoManager.getOrigin(branch); + const model = addDisposable(new CreatePullRequestDataModel(folderRepoManager, pullRequestDefaults.owner, pullRequestDefaults.base, compareOrigin.remote.owner, branch?.name ?? pullRequestDefaults.base, compareOrigin.remote.repositoryName), this._currentDisposables); + createViewProvider = this._createPRViewProvider = new CreatePullRequestViewProvider( + telemetry, + model, extensionUri, folderRepoManager, pullRequestDefaults, - branch, ); - const compareOrigin = await folderRepoManager.getOrigin(branch); - this._treeView = new CompareChangesTreeProvider( - this.repository, - pullRequestDefaults.owner, - pullRequestDefaults.base, - compareOrigin.remote.owner, - branch.name!, - !!branch.upstream, + this._treeView = addDisposable(new CompareChanges( folderRepoManager, - ); + model + ), this._currentDisposables); - this.registerListeners(!compareBranch); + this.registerListeners(folderRepoManager.repository, !compareBranch); - this._disposables.push( + addDisposable( vscode.window.registerWebviewViewProvider( this._createPRViewProvider.viewType, this._createPRViewProvider, ), + this._currentDisposables ); + } else { + createViewProvider = this._createPRViewProvider as CreatePullRequestViewProvider; } - this._createPRViewProvider.show(branch); + createViewProvider.show(branch); + } + + private reset() { + this.setActiveContext(false); + disposeAll(this._currentDisposables); + this._createPRViewProvider = undefined; + this._treeView = undefined; + this._postCreateCallback = undefined; + this._activeContext = undefined; + + } + + override dispose() { + this.reset(); + super.dispose(); } } 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<void> { + 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<vscode.CompletionItem[] | vscode.CompletionList> { + // 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 ade2f5c406..06661245b1 100644 --- a/src/view/fileChangeModel.ts +++ b/src/view/fileChangeModel.ts @@ -5,7 +5,7 @@ import * as vscode from 'vscode'; import { ViewedState } from '../common/comment'; -import { DiffHunk, parsePatch } from '../common/diffHunk'; +import { DiffChangeType, DiffHunk, parsePatch } from '../common/diffHunk'; import { GitChangeType, InMemFileChange, SimpleFileChange, SlimFileChange } from '../common/file'; import Logger from '../common/logger'; import { resolvePath, toPRUri, toReviewUri } from '../common/uri'; @@ -13,6 +13,7 @@ import { FolderRepositoryManager } from '../github/folderRepositoryManager'; import { IResolvedPullRequestModel, PullRequestModel } from '../github/pullRequestModel'; export abstract class FileChangeModel { + private static readonly ID = 'FileChangeModel'; protected _filePath: vscode.Uri; get filePath(): vscode.Uri { return this._filePath; @@ -51,7 +52,7 @@ export abstract class FileChangeModel { async diffHunks(): Promise<DiffHunk[]> { let diffHunks: DiffHunk[] = []; - if (this.change instanceof InMemFileChange) { + if (this.change instanceof InMemFileChange && this.change.diffHunks) { return this.change.diffHunks; } else if (this.status !== GitChangeType.RENAME) { try { @@ -59,12 +60,34 @@ export abstract class FileChangeModel { const patch = await this.folderRepoManager.repository.diffBetween(this.pullRequest.base.sha, commit, this.fileName); diffHunks = parsePatch(patch); } catch (e) { - Logger.error(`Failed to parse patch for outdated comments: ${e}`); + Logger.error(`Failed to parse patch for outdated comments: ${e}`, FileChangeModel.ID); } } return diffHunks; } + public async calculateChangedLinesCount(): Promise<{ added: number; removed: number }> { + try { + const diffHunks = await this.diffHunks(); + let added = 0; + let removed = 0; + + for (const hunk of diffHunks) { + for (const line of hunk.diffLines) { + if (line.type === DiffChangeType.Add) { + ++added; + } else if (line.type === DiffChangeType.Delete) { + ++removed; + } + } + } + return { added, removed }; + } catch (error) { + Logger.warn(`Failed to calculate added/removed lines for ${this.fileName}: ${error}`, FileChangeModel.ID); + return { added: 0, removed: 0 }; + } + } + constructor(public readonly pullRequest: PullRequestModel, protected readonly folderRepoManager: FolderRepositoryManager, public readonly change: SimpleFileChange, @@ -78,21 +101,25 @@ export class GitFileChangeModel extends FileChangeModel { change: SimpleFileChange, filePath: vscode.Uri, parentFilePath: vscode.Uri, - public readonly sha: string, + sha: string, preload?: boolean ) { super(pullRequest, folderRepositoryManager, change, sha); this._filePath = filePath; this._parentFilePath = parentFilePath; if (preload) { - this.showBase(); + try { + this.showBase(); + } catch (e) { + Logger.warn(`Unable to preload file content for ${filePath.fsPath} at commit ${sha}`); + } } } - private _show: Promise<string> - async showBase(): Promise<string> { - if (!this._show) { - const commit = ((this.change instanceof InMemFileChange || this.change instanceof SlimFileChange) ? this.change.baseCommit : this.sha); + private _show: Promise<string | undefined>; + async showBase(): Promise<string | undefined> { + if (!this._show && this.change.status !== GitChangeType.ADD) { + const commit = ((this.change instanceof InMemFileChange || this.change instanceof SlimFileChange) ? this.change.baseCommit : this.sha!); const absolutePath = vscode.Uri.joinPath(this.folderRepoManager.repository.rootUri, this.fileName).fsPath; this._show = this.folderRepoManager.repository.show(commit, absolutePath); } @@ -107,40 +134,37 @@ export class InMemFileChangeModel extends FileChangeModel { async isPartial(): Promise<boolean> { let originalFileExist = false; + let fileName: string | undefined = undefined; - switch (this.change.status) { - case GitChangeType.DELETE: - case GitChangeType.MODIFY: - try { - await this.folderRepoManager.repository.getObjectDetails(this.change.baseCommit, this.change.fileName); - originalFileExist = true; - } catch (err) { - /* noop */ - } - break; - case GitChangeType.RENAME: - try { - await this.folderRepoManager.repository.getObjectDetails(this.change.baseCommit, this.change.previousFileName!); - originalFileExist = true; - } catch (err) { - /* noop */ - } - break; + if ((this.change.patch === '') && + ((this.change.status === GitChangeType.MODIFY) || (this.change.status === GitChangeType.RENAME) || (this.change.status === GitChangeType.ADD))) { + return true; } - return !originalFileExist && (this.change.status !== GitChangeType.ADD); + + if ((this.change.status === GitChangeType.DELETE) || (this.change.status === GitChangeType.MODIFY)) { + fileName = this.change.fileName; + } else if (this.change.status === GitChangeType.RENAME) { + fileName = this.change.previousFileName!; + } + + try { + if (fileName) { + await this.folderRepoManager.repository.getObjectDetails(this.change.baseCommit, fileName); + originalFileExist = true; + } + } catch (err) { + /* noop */ + } + return !originalFileExist; } get patch(): string { return this.change.patch; } - async diffHunks(): Promise<DiffHunk[]> { - return this.change.diffHunks; - } - constructor(folderRepositoryManager: FolderRepositoryManager, pullRequest: PullRequestModel & IResolvedPullRequestModel, - public readonly change: InMemFileChange, + public override readonly change: InMemFileChange, isCurrentPR: boolean, mergeBase: string) { super(pullRequest, folderRepositoryManager, change); @@ -189,13 +213,9 @@ export class RemoteFileChangeModel extends FileChangeModel { return this.change.previousFileName; } - get blobUrl(): string { - return this.change.blobUrl; - } - constructor( folderRepositoryManager: FolderRepositoryManager, - public readonly change: SlimFileChange, + public override readonly change: SlimFileChange, pullRequest: PullRequestModel, ) { super(pullRequest, folderRepositoryManager, change); diff --git a/src/view/fileTypeDecorationProvider.ts b/src/view/fileTypeDecorationProvider.ts index 84d7ce83dd..3d6c3283f5 100644 --- a/src/view/fileTypeDecorationProvider.ts +++ b/src/view/fileTypeDecorationProvider.ts @@ -5,78 +5,25 @@ import * as path from 'path'; import * as vscode from 'vscode'; +import { TreeDecorationProvider } from './treeDecorationProviders'; import { GitChangeType } from '../common/file'; -import { FileChangeNodeUriParams, fromFileChangeNodeUri, fromPRUri, PRUriParams, Schemes, toResourceUri } from '../common/uri'; +import { FileChangeNodeUriParams, fromFileChangeNodeUri, fromPRUri, PRUriParams } from '../common/uri'; import { FolderRepositoryManager } from '../github/folderRepositoryManager'; import { PullRequestModel } from '../github/pullRequestModel'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { ReviewManager } from './reviewManager'; -export class FileTypeDecorationProvider implements vscode.FileDecorationProvider { - private _disposables: vscode.Disposable[] = []; - private _gitHubReposListeners: vscode.Disposable[] = []; - private _pullRequestListeners: vscode.Disposable[] = []; - private _fileViewedListeners: vscode.Disposable[] = []; - - _onDidChangeFileDecorations: vscode.EventEmitter<vscode.Uri | vscode.Uri[]> = new vscode.EventEmitter< - vscode.Uri | vscode.Uri[] - >(); - onDidChangeFileDecorations: vscode.Event<vscode.Uri | vscode.Uri[]> = this._onDidChangeFileDecorations.event; - - - constructor(private _repositoriesManager: RepositoriesManager, private _reviewManagers: ReviewManager[]) { - this._disposables.push(vscode.window.registerFileDecorationProvider(this)); - this._registerListeners(); +export class FileTypeDecorationProvider extends TreeDecorationProvider { + constructor() { + super(); } - private _registerFileViewedListeners(folderManager: FolderRepositoryManager, model: PullRequestModel) { + registerPullRequestPropertyChangedListeners(folderManager: FolderRepositoryManager, model: PullRequestModel): vscode.Disposable { return model.onDidChangeFileViewedState(changed => { changed.changed.forEach(change => { - const uri = vscode.Uri.joinPath(folderManager.repository.rootUri, change.fileName); - const fileChange = model.fileChanges.get(change.fileName); - if (fileChange) { - const fileChangeUri = toResourceUri(uri, model.number, change.fileName, fileChange.status, fileChange.previousFileName); - this._onDidChangeFileDecorations.fire(fileChangeUri); - this._onDidChangeFileDecorations.fire(fileChangeUri.with({ scheme: folderManager.repository.rootUri.scheme })); - this._onDidChangeFileDecorations.fire(fileChangeUri.with({ scheme: Schemes.Pr, authority: '' })); - } + this._handlePullRequestPropertyChange(folderManager, model, { path: change.fileName }); }); }); } - private _registerPullRequestAddedListeners(folderManager: FolderRepositoryManager) { - folderManager.gitHubRepositories.forEach(gitHubRepo => { - this._pullRequestListeners.push(gitHubRepo.onDidAddPullRequest(model => { - this._fileViewedListeners.push(this._registerFileViewedListeners(folderManager, model)); - })); - this._fileViewedListeners.push(...Array.from(gitHubRepo.pullRequestModels.values()).map(model => { - return this._registerFileViewedListeners(folderManager, model); - })); - }); - } - - private _registerRepositoriesChangedListeners() { - this._gitHubReposListeners.forEach(disposable => disposable.dispose()); - this._gitHubReposListeners = []; - this._pullRequestListeners.forEach(disposable => disposable.dispose()); - this._pullRequestListeners = []; - this._fileViewedListeners.forEach(disposable => disposable.dispose()); - this._fileViewedListeners = []; - this._repositoriesManager.folderManagers.forEach(folderManager => { - this._gitHubReposListeners.push(folderManager.onDidChangeRepositories(() => { - this._registerPullRequestAddedListeners(folderManager,); - })); - }); - } - - private _registerListeners() { - this._registerRepositoriesChangedListeners(); - this._disposables.push(this._repositoriesManager.onDidChangeFolderRepositories(() => { - this._registerRepositoriesChangedListeners(); - })); - - } - provideFileDecoration( uri: vscode.Uri, _token: vscode.CancellationToken, @@ -173,11 +120,4 @@ export class FileTypeDecorationProvider implements vscode.FileDecorationProvider return `Renamed ${change.previousFileName} to ${path.basename(change.fileName)}`; } } - - dispose() { - this._disposables.forEach(dispose => dispose.dispose()); - this._gitHubReposListeners.forEach(dispose => dispose.dispose()); - this._pullRequestListeners.forEach(dispose => dispose.dispose()); - this._fileViewedListeners.forEach(dispose => dispose.dispose()); - } } diff --git a/src/view/gitContentProvider.ts b/src/view/gitContentProvider.ts index 13a4d0ff1c..be99f25a92 100644 --- a/src/view/gitContentProvider.ts +++ b/src/view/gitContentProvider.ts @@ -6,25 +6,29 @@ 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 { RepositoryFileSystemProvider } from './repositoryFileSystemProvider'; -import { ReviewManager } from './reviewManager'; +import { GitFileChangeNode, RemoteFileChangeNode } from './treeNodes/fileChangeNode'; export class GitContentFileSystemProvider extends RepositoryFileSystemProvider { + private static readonly ID = 'GitContentFileSystemProvider'; private _fallback?: (uri: vscode.Uri) => Promise<string>; - constructor(gitAPI: GitApiImpl, credentialStore: CredentialStore, private readonly reviewManagers: ReviewManager[]) { + constructor(gitAPI: GitApiImpl, credentialStore: CredentialStore, private readonly getReviewManagers: () => ReviewManager[]) { super(gitAPI, credentialStore); } - private getChangeModelForFile(file: vscode.Uri) { - for (const manager of this.reviewManagers) { - for (const change of manager.reviewModel.localFileChanges) { + private getChangeModelForFileAndFilesArray(file: vscode.Uri, getFiles: (manager: ReviewManager) => (GitFileChangeNode | RemoteFileChangeNode)[]) { + for (const manager of this.getReviewManagers()) { + const files = getFiles(manager); + for (const change of files) { if ((change.changeModel.filePath.authority === file.authority) && (change.changeModel.filePath.path === file.path)) { return change.changeModel; } @@ -32,6 +36,14 @@ export class GitContentFileSystemProvider extends RepositoryFileSystemProvider { } } + private getChangeModelForFile(file: vscode.Uri): GitFileChangeModel | undefined { + return this.getChangeModelForFileAndFilesArray(file, manager => manager.reviewModel.localFileChanges) as GitFileChangeModel; + } + + private getOutdatedChangeModelForFile(file: vscode.Uri) { + return this.getChangeModelForFileAndFilesArray(file, manager => manager.reviewModel.obsoleteFileChanges); + } + private async getRepositoryForFile(file: vscode.Uri): Promise<Repository | undefined> { await this.waitForAuth(); if ((this.gitAPI.state !== 'initialized') || (this.gitAPI.repositories.length === 0)) { @@ -61,17 +73,17 @@ export class GitContentFileSystemProvider extends RepositoryFileSystemProvider { const absolutePath = pathLib.join(repository.rootUri.fsPath, path).replace(/\\/g, '/'); let content: string | undefined; try { - Logger.appendLine(`Getting change model (${repository.rootUri}) content for commit ${commit} and path ${absolutePath}`, 'GitContentFileSystemProvider'); + Logger.appendLine(`Getting change model (${repository.rootUri}) content for commit ${commit} and path ${absolutePath}`, GitContentFileSystemProvider.ID); content = await this.getChangeModelForFile(uri)?.showBase(); if (!content) { - Logger.appendLine(`Getting repository (${repository.rootUri}) content for commit ${commit} and path ${absolutePath}`, 'GitContentFileSystemProvider'); + Logger.appendLine(`Getting repository (${repository.rootUri}) content for commit ${commit} and path ${absolutePath}`, GitContentFileSystemProvider.ID); content = await repository.show(commit, absolutePath); } if (!content) { throw new Error(); } } catch (_) { - Logger.appendLine('Using fallback content provider.', 'GitContentFileSystemProvider'); + Logger.appendLine('Using fallback content provider.', GitContentFileSystemProvider.ID); content = await this._fallback(uri); if (!content) { // Content does not exist for the base or modified file for a file deletion or addition. @@ -80,10 +92,15 @@ export class GitContentFileSystemProvider extends RepositoryFileSystemProvider { try { await repository.getCommit(commit); } catch (err) { - Logger.error(err); - vscode.window.showErrorMessage( - `We couldn't find commit ${commit} locally. You may want to sync the branch with remote. Sometimes commits can disappear after a force-push`, - ); + Logger.error(err, GitContentFileSystemProvider.ID); + // Only show the error if we know it's not an outdated commit + if (!this.getOutdatedChangeModelForFile(uri)) { + vscode.window.showErrorMessage( + vscode.l10n.t('We couldn\'t find commit {0} locally. You may want to sync the branch with remote. Sometimes commits can disappear after a force-push.', commit), + ); + } else { + vscode.window.showInformationMessage(vscode.l10n.t('We couldn\'t find commit {0}. Sometimes commits can disappear after a force-push.', commit)); + } } } } diff --git a/src/view/gitHubContentProvider.ts b/src/view/gitHubContentProvider.ts index 3f17955622..ac25a7c481 100644 --- a/src/view/gitHubContentProvider.ts +++ b/src/view/gitHubContentProvider.ts @@ -2,53 +2,198 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as buffer from 'buffer'; -import { fromGitHubURI } from '../common/uri'; + +import * as vscode from 'vscode'; +import { fromGitHubURI, GitHubUriParams } from '../common/uri'; +import { compareIgnoreCase } from '../common/utils'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; import { GitHubRepository } from '../github/githubRepository'; -import { ReadonlyFileSystemProvider } from './readonlyFileSystemProvider'; - -export async function getGitHubFileContent(gitHubRepository: GitHubRepository, fileName: string, branch: string): Promise<Uint8Array> { - const { octokit, remote } = await gitHubRepository.ensure(); - let fileContent: { data: { content: string; encoding: string; sha: string } } = (await octokit.call(octokit.api.repos.getContent, - { - owner: remote.owner, - repo: remote.repositoryName, - path: fileName, - ref: branch, - }, - )) as any; - let contents = fileContent.data.content ?? ''; - - // Empty contents and 'none' encoding indcates that the file has been truncated and we should get the blob. - if (contents === '' && fileContent.data.encoding === 'none') { - const fileSha = fileContent.data.sha; - fileContent = await octokit.call(octokit.api.git.getBlob, { - owner: remote.owner, - repo: remote.repositoryName, - file_sha: fileSha, - }); - contents = fileContent.data.content; - } - - const buff = buffer.Buffer.from(contents, (fileContent.data as any).encoding); - return buff; + +async function getGitFileContent(folderRepoManager: FolderRepositoryManager, fileName: string, branch: string, isEmpty: boolean): Promise<Uint8Array> { + let content = ''; + if (!isEmpty) { + content = await folderRepoManager.repository.show(branch, vscode.Uri.joinPath(folderRepoManager.repository.rootUri, fileName).fsPath); + } + return new TextEncoder().encode(content); +} + +interface FileData { + file: Uint8Array; + modified: boolean; } +export abstract class ChangesContentProvider implements Partial<vscode.FileSystemProvider> { + protected _onDidChangeFile = new vscode.EventEmitter<vscode.FileChangeEvent[]>(); + onDidChangeFile = this._onDidChangeFile.event; + + public readonly changedFiles = new Map<string, FileData>(); // uri key + + protected _editableBranch: string | undefined; + set editableBranch(value: string | undefined) { + this.changedFiles.clear(); + this._editableBranch = value; + } + + hasChanges(): boolean { + return [...this.changedFiles.values()].some(file => file.modified); + } + + abstract applyChanges(commitMessage: string, branch?: string): Promise<boolean>; + + async tryReadFile(uri: vscode.Uri, asParams: GitHubUriParams | undefined): Promise<Uint8Array | undefined> { + if (!this.changedFiles.has(uri.toString())) { + if (!asParams || asParams.isEmpty) { + this.changedFiles.set(uri.toString(), { file: new TextEncoder().encode(''), modified: false }); + + } + } + return this.changedFiles.get(uri.toString())?.file; + } + + writeFile(uri: vscode.Uri, content: Uint8Array, _options: { create: boolean; overwrite: boolean; }): void { + this.changedFiles.set(uri.toString(), { file: content, modified: true }); + } + + watch(_uri: vscode.Uri, _options: { recursive: boolean; excludes: string[]; }): vscode.Disposable { + /** no op */ + return { dispose: () => { } }; + } + + async stat(_uri: any): Promise<vscode.FileStat> { + // const params = fromGitHubURI(uri); + + return { + type: vscode.FileType.File, + ctime: 0, + mtime: 0, + size: 0, + permissions: /* (params?.branch && this._editableBranch === params.branch) ? undefined : */ vscode.FilePermission.Readonly // For now, keep all files readonly. We can address later with https://github.com/microsoft/vscode-pull-request-github/issues/5163 + }; + } + + readDirectory(_uri: vscode.Uri): [string, vscode.FileType][] { + return []; + } + + createDirectory(_uri: vscode.Uri): void { + /** no op */ + } + + delete(_uri: vscode.Uri, _options: { recursive: boolean; }): void { + /** no op */ + } + + rename(_oldUri: vscode.Uri, _newUri: vscode.Uri, _options: { overwrite: boolean; }): void { + /** no op */ + } +} + + /** * Provides file contents for documents with githubpr scheme. Contents are fetched from GitHub based on * information in the document's query string. */ -export class GitHubContentProvider extends ReadonlyFileSystemProvider { - constructor(public gitHubRepository: GitHubRepository) { +export class GitHubContentProvider extends ChangesContentProvider implements vscode.FileSystemProvider { + constructor(private _gitHubRepositories: GitHubRepository[]) { + super(); + } + + private gitHubRepositoryForOwner(owner?: string): GitHubRepository | undefined { + if (!owner) { + return this._gitHubRepositories[0]; + } + return this._gitHubRepositories.find(repository => compareIgnoreCase(repository.remote.owner, owner) === 0); + } + + set gitHubRepository(repository: GitHubRepository) { + this._gitHubRepositories = [repository]; + } + + async readFile(uri: vscode.Uri): Promise<Uint8Array> { + const asParams = fromGitHubURI(uri); + const tryReadFile = await this.tryReadFile(uri, asParams); + if (tryReadFile) { + return tryReadFile; + } + + const repo = this.gitHubRepositoryForOwner(asParams?.owner); + if (!repo) { + throw new Error(`No GitHub repository found for owner ${asParams!.owner}`); + } + const content = await repo.getFile(asParams!.fileName, asParams!.branch); + this.changedFiles.set(uri.toString(), { file: content, modified: false }); + return this.changedFiles.get(uri.toString())!.file; + } + + async applyChanges(commitMessage: string, branch: string): Promise<boolean> { + const changes: Map<string, Uint8Array> = new Map(); + for (const [uri, fileData] of this.changedFiles) { + if (fileData.modified) { + changes.set(vscode.Uri.parse(uri).path, fileData.file); + } + } + return this._gitHubRepositories[0].commit(branch, commitMessage, changes); + } +} + +export class GitContentProvider extends ChangesContentProvider implements vscode.FileSystemProvider { + constructor(public folderRepositoryManager: FolderRepositoryManager) { super(); } - async readFile(uri: any): Promise<Uint8Array> { + override async stat(uri: vscode.Uri): Promise<vscode.FileStat> { + let mtime = 0; + const params = fromGitHubURI(uri); + if (params?.branch) { + const branch = await this.folderRepositoryManager.repository.getBranch(params?.branch); + if (branch.commit) { + const commit = await this.folderRepositoryManager.repository.getCommit(branch.commit); + if (commit) { + mtime = commit.commitDate?.getTime() ?? 0; + } + } + + } + return { + type: vscode.FileType.File, + ctime: 0, + mtime: mtime, + size: 0, + permissions: vscode.FilePermission.Readonly + }; + } + + async readFile(uri: vscode.Uri): Promise<Uint8Array> { const params = fromGitHubURI(uri); if (!params || params.isEmpty) { return new TextEncoder().encode(''); } - return getGitHubFileContent(this.gitHubRepository, params.fileName, params.branch); + const content = await getGitFileContent(this.folderRepositoryManager, params.fileName, params.branch, !!params.isEmpty); + this.changedFiles.set(uri.toString(), { file: content, modified: false }); + return this.changedFiles.get(uri.toString())!.file; + } + + async applyChanges(commitMessage: string): Promise<boolean> { + if (this.folderRepositoryManager.repository.state.indexChanges.length > 0 || this.folderRepositoryManager.repository.state.workingTreeChanges.length > 0) { + vscode.window.showWarningMessage('Please commit or stash your other changes before applying these changes.'); + return false; + } + + const uris: string[] = []; + for (const [uri, fileData] of this.changedFiles) { + if (fileData.modified) { + const fileUri = vscode.Uri.joinPath(this.folderRepositoryManager.repository.rootUri, vscode.Uri.parse(uri).path); + await vscode.workspace.fs.writeFile(fileUri, fileData.file); + uris.push(fileUri.fsPath); + } + } + await this.folderRepositoryManager.repository.add(uris); + await this.folderRepositoryManager.repository.commit(commitMessage); + if (this.folderRepositoryManager.repository.state.HEAD?.upstream) { + await this.folderRepositoryManager.repository.push(this.folderRepositoryManager.repository.state.HEAD.upstream.remote, this.folderRepositoryManager.repository.state.HEAD.upstream.name); + return true; + } + return false; } } \ No newline at end of file diff --git a/src/view/githubFileContentProvider.ts b/src/view/githubFileContentProvider.ts new file mode 100644 index 0000000000..059f113840 --- /dev/null +++ b/src/view/githubFileContentProvider.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { RepositoryFileSystemProvider } from './repositoryFileSystemProvider'; +import { GitApiImpl } from '../api/api1'; +import { fromGitHubCommitUri } from '../common/uri'; +import { CredentialStore } from '../github/credentials'; +import { RepositoriesManager } from '../github/repositoriesManager'; + +export class GitHubCommitFileSystemProvider extends RepositoryFileSystemProvider { + constructor(private readonly repos: RepositoriesManager, gitAPI: GitApiImpl, credentialStore: CredentialStore) { + super(gitAPI, credentialStore); + } + + override async readFile(uri: vscode.Uri): Promise<Uint8Array> { + await this.waitForAuth(); + await this.waitForAnyGitHubRepos(this.repos); + + const params = fromGitHubCommitUri(uri); + if (!params) { + throw new Error(`Invalid GitHub commit URI: ${uri.toString()}`); + } + + const folderManager = this.repos.getManagerForRepository(params.owner, params.repo); + if (!folderManager) { + throw new Error(`Repository not found for owner: ${params.owner}, repo: ${params.repo}`); + } + + const githubRepo = await folderManager.createGitHubRepositoryFromOwnerName(params.owner, params.repo); + if (!githubRepo) { + throw new Error(`GitHub repository not found for owner: ${params.owner}, repo: ${params.repo}`); + } + + return githubRepo.getFile(uri.path, params.commit); + } +} \ No newline at end of file diff --git a/src/view/inMemPRContentProvider.ts b/src/view/inMemPRContentProvider.ts index eb1e1ba536..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,11 +16,9 @@ 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<string> } = {}; + private _prFileChangeContentProviders: { [key: number]: (uri: vscode.Uri) => Promise<string | Uint8Array> } = {}; constructor(private reposManagers: RepositoriesManager, gitAPI: GitApiImpl, credentialStore: CredentialStore) { super(gitAPI, credentialStore); @@ -26,7 +26,7 @@ export class InMemPRFileSystemProvider extends RepositoryFileSystemProvider { registerTextDocumentContentProvider( prNumber: number, - provider: (uri: vscode.Uri) => Promise<string>, + provider: (uri: vscode.Uri) => Promise<string | Uint8Array>, ): vscode.Disposable { this._prFileChangeContentProviders[prNumber] = provider; @@ -119,7 +119,11 @@ export class InMemPRFileSystemProvider extends RepositoryFileSystemProvider { const provider = this._prFileChangeContentProviders[prNumber]; if (provider) { const content = await provider(uri); - return new TextEncoder().encode(content); + if (typeof content === 'string') { + return new TextEncoder().encode(content); + } else { + return content; + } } } @@ -147,7 +151,7 @@ export function getInMemPRFileSystemProvider(initialize?: { reposManager: Reposi return inMemPRFileSystemProvider; } -export async function provideDocumentContentForChangeModel(folderRepoManager: FolderRepositoryManager, pullRequestModel: PullRequestModel, params: PRUriParams, fileChange: FileChangeModel): Promise<string> { +export async function provideDocumentContentForChangeModel(folderRepoManager: FolderRepositoryManager, pullRequestModel: PullRequestModel, params: PRUriParams, fileChange: FileChangeModel): Promise<string | Uint8Array> { if ( (params.isBase && fileChange.status === GitChangeType.ADD) || (!params.isBase && fileChange.status === GitChangeType.DELETE) @@ -155,15 +159,22 @@ export async function provideDocumentContentForChangeModel(folderRepoManager: Fo return ''; } - if ((fileChange instanceof RemoteFileChangeModel) || ((fileChange instanceof InMemFileChangeModel) && await fileChange.isPartial())) { + const diffHunks = await fileChange.diffHunks(); + let inMemNeedsFullFile = false; + if (fileChange instanceof InMemFileChangeModel) { + // Partial or looks like binary. + inMemNeedsFullFile = await fileChange.isPartial(); + } + + if ((fileChange instanceof RemoteFileChangeModel) || ((fileChange instanceof InMemFileChangeModel) && inMemNeedsFullFile)) { try { if (params.isBase) { - return pullRequestModel.getFile( + return pullRequestModel.githubRepository.getFile( fileChange.previousFileName || fileChange.fileName, params.baseCommit, ); } else { - return pullRequestModel.getFile(fileChange.fileName, params.headCommit); + return pullRequestModel.githubRepository.getFile(fileChange.fileName, params.headCommit); } } catch (e) { Logger.error(`Fetching file content failed: ${e}`, 'PR'); @@ -189,7 +200,6 @@ export async function provideDocumentContentForChangeModel(folderRepoManager: Fo if (params.isBase) { // left const left: string[] = []; - const diffHunks = await fileChange.diffHunks(); for (let i = 0; i < diffHunks.length; i++) { for (let j = 0; j < diffHunks[i].diffLines.length; j++) { const diffLine = diffHunks[i].diffLines[j]; @@ -208,7 +218,6 @@ export async function provideDocumentContentForChangeModel(folderRepoManager: Fo return left.join('\n'); } else { const right: string[] = []; - const diffHunks = await fileChange.diffHunks(); for (let i = 0; i < diffHunks.length; i++) { for (let j = 0; j < diffHunks[i].diffLines.length; j++) { const diffLine = diffHunks[i].diffLines[j]; diff --git a/src/view/prChangesTreeDataProvider.ts b/src/view/prChangesTreeDataProvider.ts index 79e3bd0f64..1a6890114b 100644 --- a/src/view/prChangesTreeDataProvider.ts +++ b/src/view/prChangesTreeDataProvider.ts @@ -4,61 +4,59 @@ *--------------------------------------------------------------------------------------------*/ 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'; import Logger, { PR_TREE } from '../common/logger'; -import { FILE_LIST_LAYOUT, GIT, OPEN_DIFF_ON_CLICK } from '../common/settingKeys'; -import { FolderRepositoryManager, SETTINGS_NAMESPACE } from '../github/folderRepositoryManager'; +import { FILE_LIST_LAYOUT, GIT, HIDE_VIEWED_FILES, OPEN_DIFF_ON_CLICK, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; +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 { DescriptionNode } from './treeNodes/descriptionNode'; import { GitFileChangeNode } from './treeNodes/fileChangeNode'; import { RepositoryChangesNode } from './treeNodes/repositoryChangesNode'; import { BaseTreeNode, TreeNode } from './treeNodes/treeNode'; +import { TreeUtils } from './treeNodes/treeUtils'; -export class PullRequestChangesTreeDataProvider extends vscode.Disposable implements vscode.TreeDataProvider<TreeNode>, BaseTreeNode { +export class PullRequestChangesTreeDataProvider extends Disposable implements vscode.TreeDataProvider<TreeNode>, BaseTreeNode { private _onDidChangeTreeData = new vscode.EventEmitter<TreeNode | void>(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; - private _disposables: vscode.Disposable[] = []; private _pullRequestManagerMap: Map<FolderRepositoryManager, RepositoryChangesNode> = new Map(); private _view: vscode.TreeView<TreeNode>; + private _children: TreeNode[] | undefined; public get view(): vscode.TreeView<TreeNode> { return this._view; } - constructor(private _context: vscode.ExtensionContext, private _git: GitApiImpl, private _reposManager: RepositoriesManager) { - super(() => this.dispose()); - this._view = vscode.window.createTreeView('prStatus:github', { + constructor(private _git: GitApiImpl, private _reposManager: RepositoriesManager) { + super(); + this._view = this._register(vscode.window.createTreeView('prStatus:github', { treeDataProvider: this, showCollapseAll: true, - }); - this._context.subscriptions.push(this._view); + canSelectMany: true + })); - this._disposables.push( + this._register( vscode.workspace.onDidChangeConfiguration(async e => { - if (e.affectsConfiguration(`${SETTINGS_NAMESPACE}.${FILE_LIST_LAYOUT}`)) { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${FILE_LIST_LAYOUT}`)) { this._onDidChangeTreeData.fire(); const layout = vscode.workspace - .getConfiguration(`${SETTINGS_NAMESPACE}`) + .getConfiguration(PR_SETTINGS_NAMESPACE) .get<string>(FILE_LIST_LAYOUT); await vscode.commands.executeCommand('setContext', 'fileListLayout:flat', layout === 'flat'); } else if (e.affectsConfiguration(`${GIT}.${OPEN_DIFF_ON_CLICK}`)) { this._onDidChangeTreeData.fire(); + } else if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${HIDE_VIEWED_FILES}`)) { + this._onDidChangeTreeData.fire(); } }), ); - this._disposables.push(this._view.onDidChangeCheckboxState(checkboxUpdates => { - checkboxUpdates.items.forEach(checkboxUpdate => { - const node = checkboxUpdate[0]; - const newState = checkboxUpdate[1]; - node.updateCheckbox(newState); - }); - })); + this._register(this._view.onDidChangeCheckboxState(e => TreeUtils.processCheckboxUpdates(e, this._view.selection))); } refresh(treeNode?: TreeNode) { @@ -117,14 +115,14 @@ export class PullRequestChangesTreeDataProvider extends vscode.Disposable implem private async setReviewModeContexts() { await commands.setContext(contexts.IN_REVIEW_MODE, this._pullRequestManagerMap.size > 0); - const rootUrisNotInReviewMode: string[] = []; - const rootUrisInReviewMode: string[] = []; + const rootUrisNotInReviewMode: vscode.Uri[] = []; + const rootUrisInReviewMode: vscode.Uri[] = []; this._git.repositories.forEach(repo => { const folderManager = this._reposManager.getManagerForFile(repo.rootUri); if (folderManager && !this._pullRequestManagerMap.has(folderManager)) { - rootUrisNotInReviewMode.push(repo.rootUri.toString()); + rootUrisNotInReviewMode.push(repo.rootUri); } else if (folderManager) { - rootUrisInReviewMode.push(repo.rootUri.toString()); + rootUrisInReviewMode.push(repo.rootUri); } }); await commands.setContext(contexts.REPOS_NOT_IN_REVIEW_MODE, rootUrisNotInReviewMode); @@ -163,21 +161,52 @@ export class PullRequestChangesTreeDataProvider extends vscode.Disposable implem } } + get children(): TreeNode[] | undefined { + return this._children; + } + + private sortMap() { + const workspaceFolders = vscode.workspace.workspaceFolders; + const compareFolders = (a: vscode.Uri, b: vscode.Uri) => { + const aFolder = a.fsPath.toLowerCase(); + const bFolder = b.fsPath.toLowerCase(); + + return isDescendant(aFolder, bFolder) || isDescendant(bFolder, aFolder); + }; + + return Array.from(this._pullRequestManagerMap.entries()).sort((a, b) => { + if (a[0].repository.rootUri.toString() === b[0].repository.rootUri.toString()) { + return 0; + } + if (!workspaceFolders) return 0; + const aIndex = workspaceFolders.findIndex(folder => compareFolders(folder.uri, a[0].repository.rootUri)); + const bIndex = workspaceFolders.findIndex(folder => compareFolders(folder.uri, b[0].repository.rootUri)); + if (aIndex === -1) { + return 1; + } + if (bIndex === -1) { + return -1; + } + return aIndex - bIndex; + }).map(([_, value]) => value); + } + async getChildren(element?: TreeNode): Promise<TreeNode[]> { if (!element) { - const result: TreeNode[] = []; + this._children = []; if (this._pullRequestManagerMap.size >= 1) { - for (const item of this._pullRequestManagerMap.values()) { - result.push(item); + const sortedValues = this.sortMap(); + for (const item of sortedValues) { + this._children.push(item); } } - return result; + return this._children; } else { return await element.getChildren(); } } - getDescriptionNode(folderRepoManager: FolderRepositoryManager): DescriptionNode | undefined { + getDescriptionNode(folderRepoManager: FolderRepositoryManager): RepositoryChangesNode | undefined { return this._pullRequestManagerMap.get(folderRepoManager); } @@ -187,8 +216,4 @@ export class PullRequestChangesTreeDataProvider extends vscode.Disposable implem } return element; } - - dispose() { - this._disposables.forEach(disposable => disposable.dispose()); - } } diff --git a/src/view/prNotificationDecorationProvider.ts b/src/view/prNotificationDecorationProvider.ts deleted file mode 100644 index 7fac6fb3c9..0000000000 --- a/src/view/prNotificationDecorationProvider.ts +++ /dev/null @@ -1,52 +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 { fromPRNodeUri } from '../common/uri'; -import { NotificationProvider } from '../github/notifications'; - -export class PRNotificationDecorationProvider implements vscode.FileDecorationProvider { - private _disposables: vscode.Disposable[] = []; - - private _onDidChangeFileDecorations: vscode.EventEmitter<vscode.Uri | vscode.Uri[]> = new vscode.EventEmitter< - vscode.Uri | vscode.Uri[] - >(); - onDidChangeFileDecorations: vscode.Event<vscode.Uri | vscode.Uri[]> = this._onDidChangeFileDecorations.event; - - - constructor(private readonly _notificationProvider: NotificationProvider) { - this._disposables.push(vscode.window.registerFileDecorationProvider(this)); - this._disposables.push( - this._notificationProvider.onDidChangeNotifications(PRNodeUris => this._onDidChangeFileDecorations.fire(PRNodeUris)) - ); - } - - provideFileDecoration( - uri: vscode.Uri, - _token: vscode.CancellationToken, - ): vscode.ProviderResult<vscode.FileDecoration> { - 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; - } - - - dispose() { - this._disposables.forEach(dispose => dispose.dispose()); - } -} diff --git a/src/view/prStatusDecorationProvider.ts b/src/view/prStatusDecorationProvider.ts index 2388cda84c..7e423dff2d 100644 --- a/src/view/prStatusDecorationProvider.ts +++ b/src/view/prStatusDecorationProvider.ts @@ -4,31 +4,86 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { createPRNodeUri, fromPRNodeUri, Schemes } from '../common/uri'; -import { dispose } from '../common/utils'; -import { PrsTreeModel, UnsatisfiedChecks } from './prsTreeModel'; +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 { getStatusDecoration } from '../github/markdownUtils'; +import { PullRequestModel } from '../github/pullRequestModel'; +import { NotificationsManager } from '../notifications/notificationsManager'; -export class PRStatusDecorationProvider implements vscode.FileDecorationProvider, vscode.Disposable { - private _disposables: vscode.Disposable[] = []; +export class PRStatusDecorationProvider extends Disposable implements vscode.FileDecorationProvider { private _onDidChangeFileDecorations: vscode.EventEmitter<vscode.Uri | vscode.Uri[]> = new vscode.EventEmitter< vscode.Uri | vscode.Uri[] >(); onDidChangeFileDecorations: vscode.Event<vscode.Uri | vscode.Uri[]> = this._onDidChangeFileDecorations.event; - constructor(private readonly _prsTreeModel: PrsTreeModel) { - this._disposables.push(vscode.window.registerFileDecorationProvider(this)); - this._disposables.push( + constructor(private readonly _prsTreeModel: PrsTreeModel, private readonly _notificationProvider: NotificationsManager) { + super(); + this._register(vscode.window.registerFileDecorationProvider(this)); + this._register( this._prsTreeModel.onDidChangePrStatus(identifiers => { this._onDidChangeFileDecorations.fire(identifiers.map(id => createPRNodeUri(id))); }) ); + + this._register(this._prsTreeModel.onDidChangeCopilotNotifications(items => { + const repoItems = new Set<string>(); + const uris: vscode.Uri[] = []; + for (const item of items) { + const queryUri = toQueryUri({ remote: { owner: item.remote.owner, repositoryName: item.remote.repositoryName }, isCopilot: true }); + if (!repoItems.has(queryUri.toString())) { + repoItems.add(queryUri.toString()); + uris.push(queryUri); + } + 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( uri: vscode.Uri, _token: vscode.CancellationToken, ): vscode.ProviderResult<vscode.FileDecoration> { + if (uri.scheme === Schemes.PRQuery) { + return this._queryDecoration(uri); + } + if (uri.scheme !== Schemes.PRNode) { return; } @@ -36,57 +91,80 @@ export class PRStatusDecorationProvider implements vscode.FileDecorationProvider if (!params) { return; } + + const copilotDecoration = this._getCopilotDecoration(params); + if (copilotDecoration) { + return copilotDecoration; + } + + const notificationDecoration = this._getNotificationDecoration(params); + if (notificationDecoration) { + return notificationDecoration; + } + const status = this._prsTreeModel.cachedPRStatus(params.prIdentifier); if (!status) { return; } - return this._getDecoration(status.status) as vscode.FileDecoration; + const decoration = getStatusDecoration(status.status) as vscode.FileDecoration; + return decoration; } - private _getDecoration(status: UnsatisfiedChecks): vscode.FileDecoration2 | undefined { - if ((status & UnsatisfiedChecks.CIFailed) && (status & UnsatisfiedChecks.ReviewRequired)) { - return { - propagate: false, - badge: new vscode.ThemeIcon('close', new vscode.ThemeColor('list.errorForeground')), - tooltip: 'Review required and some checks have failed' - }; - } else if (status & UnsatisfiedChecks.CIFailed) { - return { - propagate: false, - badge: new vscode.ThemeIcon('close', new vscode.ThemeColor('list.errorForeground')), - tooltip: 'Some checks have failed' - }; - } else if (status & UnsatisfiedChecks.ChangesRequested) { - return { - propagate: false, - badge: new vscode.ThemeIcon('request-changes', new vscode.ThemeColor('list.errorForeground')), - tooltip: 'Changes requested' - }; - } else if (status & UnsatisfiedChecks.CIPending) { - return { - propagate: false, - badge: new vscode.ThemeIcon('sync', new vscode.ThemeColor('list.warningForeground')), - tooltip: 'Checks pending' - }; - } else if (status & UnsatisfiedChecks.ReviewRequired) { + 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._prsTreeModel.hasCopilotNotification(protocol.owner, protocol.repositoryName, idParts.prNumber)) { return { - propagate: false, - badge: new vscode.ThemeIcon('circle-filled', new vscode.ThemeColor('list.warningForeground')), - tooltip: 'Review required' + badge: new vscode.ThemeIcon('copilot') as unknown as string, + color: new vscode.ThemeColor('pullRequests.notification') }; - } else if (status === UnsatisfiedChecks.None) { + } + } + + private _queryDecoration(uri: vscode.Uri): vscode.ProviderResult<vscode.FileDecoration> { + const params = fromQueryUri(uri); + if (!params?.isCopilot || !params.remote) { + return; + } + 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 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, - badge: new vscode.ThemeIcon('check-all', new vscode.ThemeColor('issues.open')), - tooltip: 'All checks passed' + color: new vscode.ThemeColor('pullRequests.notification'), + badge: '●', + tooltip: 'unread notification' }; } - } - dispose() { - dispose(this._disposables); + private notificationSettingValue(): boolean { + return vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<NotificationVariants>(NOTIFICATION_SETTING, 'off') === 'pullRequests'; } - } \ No newline at end of file diff --git a/src/view/prsTreeDataProvider.ts b/src/view/prsTreeDataProvider.ts index 011249126a..40f2d680c6 100644 --- a/src/view/prsTreeDataProvider.ts +++ b/src/view/prsTreeDataProvider.ts @@ -4,127 +4,249 @@ *--------------------------------------------------------------------------------------------*/ 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 { FILE_LIST_LAYOUT, QUERIES } from '../common/settingKeys'; +import { Disposable } from '../common/lifecycle'; +import Logger from '../common/logger'; +import { FILE_LIST_LAYOUT, PR_SETTINGS_NAMESPACE, QUERIES, REMOTES } from '../common/settingKeys'; import { ITelemetry } from '../common/telemetry'; +import { createPRNodeIdentifier } from '../common/uri'; import { EXTENSION_ID } from '../constants'; -import { CredentialStore } from '../github/credentials'; -import { REMOTES_SETTING, ReposManagerState, SETTINGS_NAMESPACE } from '../github/folderRepositoryManager'; -import { NotificationProvider } from '../github/notifications'; +import { FolderRepositoryManager, ReposManagerState } from '../github/folderRepositoryManager'; +import { PullRequestChangeEvent } from '../github/githubRepository'; +import { PRType } from '../github/interface'; +import { issueMarkdown } from '../github/markdownUtils'; +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 { DecorationProvider } from './treeDecorationProvider'; import { CategoryTreeNode, PRCategoryActionNode, PRCategoryActionType } from './treeNodes/categoryNode'; import { InMemFileChangeNode } from './treeNodes/fileChangeNode'; -import { BaseTreeNode, EXPANDED_QUERIES_STATE, TreeNode } from './treeNodes/treeNode'; +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 implements vscode.TreeDataProvider<TreeNode>, BaseTreeNode, vscode.Disposable { - private _onDidChangeTreeData = new vscode.EventEmitter<TreeNode | void>(); +export class PullRequestsTreeDataProvider extends Disposable implements vscode.TreeDataProvider<TreeNode>, BaseTreeNode { + private _onDidChangeTreeData = new vscode.EventEmitter<TreeNode[] | TreeNode | void>(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; private _onDidChange = new vscode.EventEmitter<vscode.Uri>(); get onDidChange(): vscode.Event<vscode.Uri> { return this._onDidChange.event; } - private _disposables: vscode.Disposable[]; - private _childrenDisposables: TreeNode[]; - private _view: vscode.TreeView<TreeNode>; - private _reposManager: RepositoriesManager | undefined; + private _children: WorkspaceFolderNode[] | CategoryTreeNode[]; + get children() { + return this._children; + } + private readonly _view: vscode.TreeView<TreeNode>; private _initialized: boolean = false; - public notificationProvider: NotificationProvider; - private _prsTreeModel: PrsTreeModel; + private _notificationsProvider?: NotificationsManager; + private _notificationClearTimeout: NodeJS.Timeout | undefined; get view(): vscode.TreeView<TreeNode> { return this._view; } - constructor(private _telemetry: ITelemetry, private readonly _context: vscode.ExtensionContext) { - this._disposables = []; - this._prsTreeModel = new PrsTreeModel(this._telemetry); - this._disposables.push(new PRStatusDecorationProvider(this._prsTreeModel)); - this._disposables.push(vscode.window.registerFileDecorationProvider(DecorationProvider)); - this._disposables.push( - vscode.commands.registerCommand('pr.refreshList', _ => { - this._onDidChangeTreeData.fire(); - }), - ); - - this._disposables.push( - vscode.commands.registerCommand('pr.loadMore', (node: CategoryTreeNode) => { - node.fetchNextPage = true; - this._onDidChangeTreeData.fire(node); - }), - ); - - this._view = vscode.window.createTreeView('pr:github', { + constructor(public readonly prsTreeModel: PrsTreeModel, private readonly _telemetry: ITelemetry, private readonly _context: vscode.ExtensionContext, private readonly _reposManager: RepositoriesManager) { + super(); + this._register(this.prsTreeModel.onDidChangeData(e => { + if (e instanceof FolderRepositoryManager) { + this.refreshRepo(e); + } else if (Array.isArray(e)) { + this.refreshPullRequests(e); + } else { + this.refreshAllQueryResults(true); + } + })); + this._register(vscode.commands.registerCommand('pr.refreshList', _ => { + this.prsTreeModel.forceClearCache(); + this.refreshAllQueryResults(true); + })); + + this._register(vscode.commands.registerCommand('pr.loadMore', (node: CategoryTreeNode) => { + node.fetchNextPage = true; + this.refresh(node); + })); + + this._view = this._register(vscode.window.createTreeView('pr:github', { treeDataProvider: this, showCollapseAll: true, - }); + })); - this._disposables.push(this._view); - this._childrenDisposables = []; - - this._disposables.push( - vscode.commands.registerCommand('pr.configurePRViewlet', async () => { - const configuration = await vscode.window.showQuickPick([ - 'Configure Remotes...', - 'Configure Queries...' - ]); - - switch (configuration) { - case 'Configure Queries...': - return vscode.commands.executeCommand( - 'workbench.action.openSettings', - `@ext:${EXTENSION_ID} queries`, - ); - case 'Configure Remotes...': - return vscode.commands.executeCommand( - 'workbench.action.openSettings', - `@ext:${EXTENSION_ID} remotes`, - ); - default: - return; + this._register(this._view.onDidChangeVisibility(e => { + if (e.visible) { + // Sync with currently active PR when view becomes visible + const currentPR = PullRequestOverviewPanel.getCurrentPullRequest(); + if (currentPR) { + this.syncWithActivePullRequest(currentPR); } - }), - ); + } + })); - this._disposables.push( - vscode.workspace.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(`${SETTINGS_NAMESPACE}.${FILE_LIST_LAYOUT}`)) { - this._onDidChangeTreeData.fire(); + this._register({ + dispose: () => { + if (this._notificationClearTimeout) { + clearTimeout(this._notificationClearTimeout); + this._notificationClearTimeout = undefined; } - }), - ); - - this._disposables.push(this._view.onDidChangeCheckboxState(checkboxUpdates => { - checkboxUpdates.items.forEach(checkboxUpdate => { - const node = checkboxUpdate[0]; - const newState = checkboxUpdate[1]; - node.updateCheckbox(newState); - }); + } + }); + + this._register(this.prsTreeModel.onDidChangeCopilotStates(() => { + this.refreshAllQueryResults(); + })); + + this._register(this.prsTreeModel.onDidChangeCopilotNotifications(() => { + this.updateBadge(); + })); + this.updateBadge(); + + // Listen for PR overview panel changes to sync the tree view + this._register(PullRequestOverviewPanel.onVisible(pullRequest => { + // Only sync if view is already visible (don't open the view) + if (this._view.visible) { + this.syncWithActivePullRequest(pullRequest); + } + })); + + this._children = []; + + this._register(vscode.commands.registerCommand('pr.configurePRViewlet', async () => { + const configuration = await vscode.window.showQuickPick([ + 'Configure Remotes...', + 'Configure Queries...', + 'Configure All Pull Request Settings...' + ]); + + switch (configuration) { + case 'Configure Queries...': + return vscode.commands.executeCommand( + 'workbench.action.openSettings', + `@ext:${EXTENSION_ID} pull request queries`, + ); + case 'Configure Remotes...': + return vscode.commands.executeCommand( + 'workbench.action.openSettings', + `@ext:${EXTENSION_ID} remotes`, + ); + case 'Configure All Pull Request Settings...': + return vscode.commands.executeCommand( + 'workbench.action.openSettings', + `@ext:${EXTENSION_ID} pull request`, + ); + default: + return; + } })); - this._disposables.push(this._view.onDidExpandElement(expanded => { - this._updateExpandedQueries(expanded.element, true); + this._register(vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${FILE_LIST_LAYOUT}`)) { + this.refreshAll(); + } })); - this._disposables.push(this._view.onDidCollapseElement(collapsed => { - this._updateExpandedQueries(collapsed.element, false); + + this._register(this._view.onDidChangeCheckboxState(e => TreeUtils.processCheckboxUpdates(e, []))); + + this._register(this._view.onDidExpandElement(expanded => { + this.prsTreeModel.updateExpandedQueries(expanded.element, true); })); + this._register(this._view.onDidCollapseElement(collapsed => { + this.prsTreeModel.updateExpandedQueries(collapsed.element, false); + })); + } + + 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 _updateExpandedQueries(element: TreeNode, isExpanded: boolean) { - if (element instanceof CategoryTreeNode) { - const expandedQueries = new Set<string>(this._context.workspaceState.get(EXPANDED_QUERIES_STATE, []) as string[]); - if (isExpanded) { - expandedQueries.add(element.id); + 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 { - expandedQueries.delete(element.id); + 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(); + } + for (const child of this._children) { + if (child instanceof WorkspaceFolderNode) { + if (await child.expandPullRequest(pullRequest)) { + return; + } + } else if (child.type === PRType.All) { + if (await child.expandPullRequest(pullRequest)) { + return; + } } - this._context.workspaceState.update(EXPANDED_QUERIES_STATE, Array.from(expandedQueries.keys())); } } @@ -132,61 +254,251 @@ export class PullRequestsTreeDataProvider implements vscode.TreeDataProvider<Tre return this._view.reveal(element, options); } - initialize(reposManager: RepositoriesManager, reviewModels: ReviewModel[], credentialStore: CredentialStore) { + /** + * Sync the tree view with the currently active PR overview + */ + private async syncWithActivePullRequest(pullRequest: PullRequestModel): Promise<void> { + const alreadySelected = this._view.selection.find(child => child instanceof PRNode && (child.pullRequestModel.number === pullRequest.number) && (child.pullRequestModel.remote.owner === pullRequest.remote.owner) && (child.pullRequestModel.remote.repositoryName === pullRequest.remote.repositoryName)); + if (alreadySelected) { + return; + } + try { + // Find the PR node in the tree and reveal it + const prNode = await this.findPRNode(pullRequest); + if (prNode) { + await this.reveal(prNode, { select: true, focus: false, expand: false }); + } + } catch (error) { + // Silently ignore errors to avoid disrupting the user experience + Logger.warn(`Failed to sync tree view with active PR: ${error}`); + } + } + + /** + * Find a PR node in the tree structure + */ + private async findPRNode(pullRequest: PullRequestModel): Promise<PRNode | undefined> { + if (this._children.length === 0) { + await this.getChildren(); + } + + for (const child of this._children) { + if (child instanceof WorkspaceFolderNode) { + const found = await this.findPRNodeInWorkspaceFolder(child, pullRequest); + if (found) return found; + } else if (child instanceof CategoryTreeNode) { + const found = await this.findPRNodeInCategory(child, pullRequest); + if (found) return found; + } + } + return undefined; + } + + /** + * Search for PR node within a workspace folder node + */ + private async findPRNodeInWorkspaceFolder(workspaceNode: WorkspaceFolderNode, pullRequest: PullRequestModel): Promise<PRNode | undefined> { + const children = await workspaceNode.getChildren(false); + for (const child of children) { + if (child instanceof CategoryTreeNode) { + const found = await this.findPRNodeInCategory(child, pullRequest); + if (found) return found; + } + } + return undefined; + } + + /** + * Search for PR node within a category node + */ + private async findPRNodeInCategory(categoryNode: CategoryTreeNode, pullRequest: PullRequestModel): Promise<PRNode | undefined> { + if (categoryNode.collapsibleState !== vscode.TreeItemCollapsibleState.Expanded) { + return; + } + const children = await categoryNode.getChildren(false); + for (const child of children) { + if (child instanceof PRNode && (child.pullRequestModel.number === pullRequest.number) && (child.pullRequestModel.remote.owner === pullRequest.remote.owner) && (child.pullRequestModel.remote.repositoryName === pullRequest.remote.repositoryName)) { + return child; + } + } + return undefined; + } + + initialize(reviewModels: ReviewModel[], notificationsManager: NotificationsManager) { if (this._initialized) { throw new Error('Tree has already been initialized!'); } this._initialized = true; - this._reposManager = reposManager; - this._disposables.push( - this._reposManager.onDidChangeState(() => { - this._onDidChangeTreeData.fire(); - }), - ); - this._disposables.push( - ...this._reposManager.folderManagers.map(manager => { - return manager.onDidChangeRepositories(() => { - this._onDidChangeTreeData.fire(); - }); - }), - ); - this._disposables.push( - ...reviewModels.map(model => { - return model.onDidChangeLocalFileChanges(_ => { this.refresh(); }); - }), - ); - - this.notificationProvider = new NotificationProvider(this, credentialStore, this._reposManager); - this._disposables.push(this.notificationProvider); + this._register(this._reposManager.onDidChangeState(() => { + this.refreshAll(); + })); + this._register(this._reposManager.onDidLoadAnyRepositories(() => { + this.refreshAll(); + })); + + for (const model of reviewModels) { + this._register(model.onDidChangeLocalFileChanges(_ => { this.refreshAllQueryResults(); })); + } + + this._notificationsProvider = notificationsManager; + this._register(this._notificationsProvider.onDidChangeNotifications(() => { + this.updateBadge(); + })); + this._register(new PRStatusDecorationProvider(this.prsTreeModel, this._notificationsProvider)); this.initializeCategories(); - this.refresh(); + this.refreshAll(); } private async initializeCategories() { - this._disposables.push( - vscode.workspace.onDidChangeConfiguration(async e => { - if (e.affectsConfiguration(`${SETTINGS_NAMESPACE}.${QUERIES}`)) { - this.refresh(); - } - }), - ); + this._register(vscode.workspace.onDidChangeConfiguration(async e => { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${QUERIES}`)) { + this.refreshAll(); + } + })); + } + + refreshAll(reset?: boolean) { + this.tryReset(!!reset); + this._onDidChangeTreeData.fire(); + } + + private tryReset(reset: boolean) { + if (reset) { + this.prsTreeModel.clearCache(true); + } + } + + private refreshAllQueryResults(reset?: boolean) { + this.tryReset(!!reset); + + if (!this._children || this._children.length === 0) { + this._onDidChangeTreeData.fire(); + return; + } + + if (this._children[0] instanceof WorkspaceFolderNode) { + (this._children as WorkspaceFolderNode[]).forEach(folderNode => this.refreshQueryResultsForFolder(folderNode)); + return; + } + this.refreshQueryResultsForFolder(); } - refresh(node?: TreeNode): void { - return node ? this._onDidChangeTreeData.fire(node) : this._onDidChangeTreeData.fire(); + private refreshQueryResultsForFolder(manager?: WorkspaceFolderNode, reset?: boolean) { + if (!manager && this._children[0] instanceof WorkspaceFolderNode) { + // Not permitted. There're multiple folder nodes, therefore must specify which one to refresh + throw new Error('Must specify a folder node to refresh when there are multiple folder nodes'); + } + + if (!this._children || this._children.length === 0) { + this._onDidChangeTreeData.fire(); + return; + } + const queries = manager?.children ?? this._children; + this.tryReset(!!reset); + + this._onDidChangeTreeData.fire([...queries]); + } + + refresh(node: TreeNode, reset?: boolean): void { + this.tryReset(!!reset); + return this._onDidChangeTreeData.fire(node); + } + + private refreshRepo(manager: FolderRepositoryManager): void { + if ((this._children.length === 0) || (this._children[0] instanceof CategoryTreeNode && this._children[0].folderRepoManager === manager)) { + return this.refreshQueryResultsForFolder(undefined, true); + } + if (this._children[0] instanceof WorkspaceFolderNode) { + const children: WorkspaceFolderNode[] = this._children as WorkspaceFolderNode[]; + const node = children.find(node => node.folderManager === manager); + if (node) { + this.refreshQueryResultsForFolder(node); + return; + } + } } - getTreeItem(element: TreeNode): vscode.TreeItem { + private refreshPullRequests(pullRequests: PullRequestChangeEvent[]): void { + if (!this._children?.length || !pullRequests?.length) { + return; + } + const prNodesToRefresh: TreeNode[] = []; + const prsWithStateChange = new Set(); + const prNumbers = new Set(); + + for (const prChange of pullRequests) { + prNumbers.add(prChange.model.number); + if (prChange.event.state) { + prsWithStateChange.add(prChange.model.number); + } + } + + const hasPRNode = (node: TreeNode) => { + const prNodes = node.children ?? []; + for (const prNode of prNodes) { + if (prNode instanceof PRNode && prsWithStateChange.has(prNode.pullRequestModel.number)) { + return true; + } + } + return false; + }; + + const categoriesToRefresh: Set<CategoryTreeNode> = new Set(); + // First find the categories to refresh, since if we refresh a category we don't need to specifically refresh its children + for (const child of this._children) { + if (child instanceof WorkspaceFolderNode) { + const categories = child.children ?? []; + for (const category of categories) { + if (category instanceof CategoryTreeNode && !categoriesToRefresh.has(category) && hasPRNode(category)) { + categoriesToRefresh.add(category); + } + } + } else if (child instanceof CategoryTreeNode && !categoriesToRefresh.has(child) && hasPRNode(child)) { + categoriesToRefresh.add(child); + } + } + + // Yes, multiple PRs can exist in different repos with the same number, but at worst we'll refresh all the duplicate numbers, which shouldn't be many. + const collectPRNodes = (node: TreeNode) => { + const prNodes = node.children ?? []; + for (const prNode of prNodes) { + if (prNode instanceof PRNode && prNumbers.has(prNode.pullRequestModel.number)) { + prNodesToRefresh.push(prNode); + } + } + }; + + for (const child of this._children) { + if (child instanceof WorkspaceFolderNode) { + const categories = child.children ?? []; + for (const category of categories) { + if (category instanceof CategoryTreeNode && !categoriesToRefresh.has(category)) { + collectPRNodes(category); + } + } + } else if (child instanceof CategoryTreeNode && !categoriesToRefresh.has(child)) { + collectPRNodes(child); + } + } + if (prNodesToRefresh.length || categoriesToRefresh.size > 0) { + this._onDidChangeTreeData.fire([...Array.from(categoriesToRefresh), ...prNodesToRefresh]); + } + } + + getTreeItem(element: TreeNode): vscode.TreeItem | Promise<vscode.TreeItem> { return element.getTreeItem(); } async resolveTreeItem(item: vscode.TreeItem, element: TreeNode): Promise<vscode.TreeItem> { if (element instanceof InMemFileChangeNode) { await element.resolve(); + item = element.getTreeItem(); + } else if (element instanceof PRNode) { + item.tooltip = await issueMarkdown(element.pullRequestModel, this._context, this._reposManager, undefined, this.prsTreeModel.cachedPRStatus(createPRNodeIdentifier(element.pullRequestModel))?.status); } - return element; + return item; } private async needsRemotes() { @@ -194,7 +506,7 @@ export class PullRequestsTreeDataProvider implements vscode.TreeDataProvider<Tre return []; } - const remotesSetting = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get<string[]>(REMOTES_SETTING); + const remotesSetting = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<string[]>(REMOTES); let actions: PRCategoryActionNode[]; if (remotesSetting) { actions = [ @@ -207,16 +519,16 @@ export class PullRequestsTreeDataProvider implements vscode.TreeDataProvider<Tre } const { enterpriseRemotes } = this._reposManager ? await findDotComAndEnterpriseRemotes(this._reposManager?.folderManagers) : { enterpriseRemotes: [] }; - if ((enterpriseRemotes.length > 0) && !this._reposManager?.credentialStore.isAuthenticated(AuthProvider['github-enterprise'])) { + if ((enterpriseRemotes.length > 0) && !this._reposManager?.credentialStore.isAuthenticated(AuthProvider.githubEnterprise)) { actions.push(new PRCategoryActionNode(this, PRCategoryActionType.LoginEnterprise)); } return actions; } - async cachedChildren(element?: TreeNode): Promise<TreeNode[]> { + async cachedChildren(element?: WorkspaceFolderNode | CategoryTreeNode): Promise<TreeNode[]> { if (!element) { - return this._childrenDisposables; + return this._children; } return element.cachedChildren(); } @@ -236,43 +548,43 @@ export class PullRequestsTreeDataProvider implements vscode.TreeDataProvider<Tre return this.needsRemotes(); } + const gitHubFolderManagers = this._reposManager.folderManagers.filter(manager => manager.gitHubRepositories.length > 0); if (!element) { - if (this._childrenDisposables && this._childrenDisposables.length) { - this._childrenDisposables.forEach(dispose => dispose.dispose()); + if (this._children && this._children.length) { + this._children.forEach(dispose => dispose.dispose()); } - let result: TreeNode[]; - if (this._reposManager.folderManagers.length === 1) { - result = WorkspaceFolderNode.getCategoryTreeNodes( - this._reposManager.folderManagers[0], + let result: WorkspaceFolderNode[] | CategoryTreeNode[]; + if (gitHubFolderManagers.length === 1) { + result = await WorkspaceFolderNode.getCategoryTreeNodes( + gitHubFolderManagers[0], this._telemetry, this, - this.notificationProvider, + this._notificationsProvider!, this._context, - this._prsTreeModel + this.prsTreeModel, ); } else { - result = this._reposManager.folderManagers.map( + result = gitHubFolderManagers.map( folderManager => new WorkspaceFolderNode( this, folderManager.repository.rootUri, folderManager, this._telemetry, - this.notificationProvider, + this._notificationsProvider!, this._context, - this._prsTreeModel + this.prsTreeModel ), ); } - this._childrenDisposables = result; - return Promise.resolve(result); + this._children = result; + return result; } if ( - this._reposManager.folderManagers.filter(manager => manager.repository.state.remotes.length > 0).length === - 0 + gitHubFolderManagers.filter(manager => manager.repository.state.remotes.length > 0).length === 0 ) { return Promise.resolve([new PRCategoryActionNode(this, PRCategoryActionType.Empty)]); } @@ -280,11 +592,7 @@ export class PullRequestsTreeDataProvider implements vscode.TreeDataProvider<Tre return element.getChildren(); } - async getParent(element: TreeNode): Promise<TreeNode | undefined> { + async getParent(element: TreeNode) { return element.getParent(); } - - dispose() { - this._disposables.forEach(dispose => dispose.dispose()); - } } diff --git a/src/view/prsTreeModel.ts b/src/view/prsTreeModel.ts index adce23b0a6..e4efddff2e 100644 --- a/src/view/prsTreeModel.ts +++ b/src/view/prsTreeModel.ts @@ -4,64 +4,252 @@ *--------------------------------------------------------------------------------------------*/ 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 { DEV_MODE, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { getReviewMode } from '../common/settingsUtils'; import { ITelemetry } from '../common/telemetry'; import { createPRNodeIdentifier } from '../common/uri'; -import { dispose } from '../common/utils'; import { FolderRepositoryManager, ItemsResponseResult } from '../github/folderRepositoryManager'; -import { CheckState, PRType } from '../github/interface'; -import { PullRequestModel, REVIEW_REQUIRED_CHECK_ID } from '../github/pullRequestModel'; - -export enum UnsatisfiedChecks { - None = 0, - ReviewRequired = 1 << 0, - ChangesRequested = 1 << 1, - CIFailed = 1 << 2, - CIPending = 1 << 3 -} +import { PullRequestChangeEvent } from '../github/githubRepository'; +import { CheckState, PRType, PullRequestChecks, PullRequestReviewRequirement } from '../github/interface'; +import { PullRequestModel } from '../github/pullRequestModel'; +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; } -export class PrsTreeModel implements vscode.Disposable { - private readonly _disposables: vscode.Disposable[] = []; - private readonly _onDidChangePrStatus: vscode.EventEmitter<string[]> = new vscode.EventEmitter(); +interface CachedPRs { + clearRequested: boolean; + maxKnownPR: number | undefined; // used to determine if there have been new PRs created since last query + items: ItemsResponseResult<PullRequestModel>; +} + +export class PrsTreeModel extends Disposable { + private static readonly ID = 'PrsTreeModel'; + + private _activePRDisposables: Map<FolderRepositoryManager, vscode.Disposable[]> = new Map(); + private readonly _onDidChangePrStatus: vscode.EventEmitter<string[]> = this._register(new vscode.EventEmitter<string[]>()); public readonly onDidChangePrStatus = this._onDidChangePrStatus.event; + private readonly _onDidChangeData: vscode.EventEmitter<PullRequestChangeEvent[] | FolderRepositoryManager | void> = this._register(new vscode.EventEmitter<PullRequestChangeEvent[] | FolderRepositoryManager | void>()); + public readonly onDidChangeData = this._onDidChangeData.event; + private _expandedQueries: Set<string> | undefined; + private _hasLoaded: boolean = false; + private _onLoaded: vscode.EventEmitter<void> = this._register(new vscode.EventEmitter<void>()); + public readonly onLoaded = this._onLoaded.event; // Key is identifier from createPRNodeUri private readonly _queriedPullRequests: Map<string, PRStatusChange> = new Map(); - constructor(private _telemetry: ITelemetry) { + private _cachedPRs: Map<FolderRepositoryManager, Map<string | PRType.LocalPullRequest | PRType.All, CachedPRs>> = new Map(); + // For ease of finding which PRs we know about + private _allCachedPRs: Set<PullRequestModel> = new Set(); + + private readonly _repoEvents: Map<FolderRepositoryManager, vscode.Disposable[]> = new Map(); + private _getPullRequestsForQueryLock: Promise<void> = Promise.resolve(); + private _sentNoRepoTelemetry: boolean = false; + + public readonly copilotStateModel: CopilotStateModel; + private readonly _onDidChangeCopilotStates = this._register(new vscode.EventEmitter<void>()); + readonly onDidChangeCopilotStates = this._onDidChangeCopilotStates.event; + private readonly _onDidChangeCopilotNotifications = this._register(new vscode.EventEmitter<PullRequestModel[]>()); + 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)!); + } else { + this._repoEvents.set(manager, []); + } + + this._repoEvents.get(manager)!.push(manager.onDidChangeActivePullRequest(e => { + const prs: PullRequestChangeEvent[] = []; + if (e.old) { + prs.push({ model: e.old, event: {} }); + } + if (e.new) { + prs.push({ model: e.new, event: {} }); + } + this._onDidChangeData.fire(prs); + + if (this._activePRDisposables.has(manager)) { + disposeAll(this._activePRDisposables.get(manager)!); + this._activePRDisposables.delete(manager); + } + if (manager.activePullRequest) { + this._activePRDisposables.set(manager, [ + manager.activePullRequest.onDidChange(e => { + if (e.comments && manager.activePullRequest) { + this._onDidChangeData.fire([{ model: manager.activePullRequest, event: e }]); + } + })]); + } + })); + }; + this._register({ dispose: () => this._repoEvents.forEach((disposables) => disposeAll(disposables)) }); + + for (const manager of this._reposManager.folderManagers) { + repoEvents(manager); + } + + this._register(this._reposManager.onDidChangeAnyPullRequests((prs) => { + 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); + })); + + this._register(this._reposManager.onDidAddPullRequest(() => { + if (this._hasLoaded) { + this._onDidChangeData.fire(); + } + })); + + this._register(this._reposManager.onDidChangeFolderRepositories((changed) => { + if (changed.added) { + repoEvents(changed.added); + this._onDidChangeData.fire(changed.added); + } + })); + + 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); + } + } + + public updateExpandedQueries(element: TreeNode, isExpanded: boolean) { + if (!this._expandedQueries) { + this._expandedQueries = new Set(); + } + if ((element instanceof CategoryTreeNode) && element.id) { + if (isExpanded) { + this._expandedQueries.add(element.id); + } else { + this._expandedQueries.delete(element.id); + } + this._context.workspaceState.update(EXPANDED_QUERIES_STATE, Array.from(this._expandedQueries.keys())); + } + } + + get expandedQueries(): Set<string> | undefined { + if (this._reposManager.folderManagers.length > 3 && this._expandedQueries && this._expandedQueries.size > 0) { + return new Set(); + } + return this._expandedQueries; + } + + get hasLoaded(): boolean { + return this._hasLoaded; + } + private set hasLoaded(value: boolean) { + this._hasLoaded = value; + this._onLoaded.fire(); } public cachedPRStatus(identifier: string): PRStatusChange | undefined { 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; + } + + // Instead of clearing the entire cache, mark each cached query as requiring refresh. + for (const queries of this._cachedPRs.values()) { + for (const [, cachedPRs] of queries.entries()) { + if (cachedPRs) { + cachedPRs.clearRequested = true; + } + } + } + + if (!silent) { + this._onDidChangeData.fire(); + } + } + + 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[]) { - const checks = await Promise.all(pullRequests.map(pullRequest => { - return pullRequest.getStatusChecks(); - })); + // 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, + // chunk them into 100s. + let checks: [PullRequestChecks | null, PullRequestReviewRequirement | null][] = []; + for (let i = 0; i < pullRequests.length; i += 100) { + const sliceEnd = (i + 100 < pullRequests.length) ? i + 100 : pullRequests.length; + checks.push(...await Promise.all(pullRequests.slice(i, sliceEnd).map(pullRequest => { + return pullRequest.getStatusChecks(); + }))); + } const changedStatuses: string[] = []; for (let i = 0; i < pullRequests.length; i++) { const pullRequest = pullRequests[i]; - const check = checks[i]; + const [check, reviewRequirement] = checks[i]; let newStatus: UnsatisfiedChecks = UnsatisfiedChecks.None; + + if (reviewRequirement) { + if (reviewRequirement.state === CheckState.Failure) { + newStatus |= UnsatisfiedChecks.ReviewRequired; + } else if (reviewRequirement.state == CheckState.Pending) { + newStatus |= UnsatisfiedChecks.ChangesRequested; + } + } + if (!check || check.state === CheckState.Unknown) { continue; } if (check.state !== CheckState.Success) { for (const status of check.statuses) { - // We add the review required check in first if it exists. - if (status.id === REVIEW_REQUIRED_CHECK_ID) { - if (status.state === CheckState.Failure) { - newStatus |= UnsatisfiedChecks.ChangesRequested; - } - newStatus |= UnsatisfiedChecks.ReviewRequired; - } else if (status.state === CheckState.Failure) { + if (status.state === CheckState.Failure) { newStatus |= UnsatisfiedChecks.CIFailed; } else if (status.state === CheckState.Pending) { newStatus |= UnsatisfiedChecks.CIPending; @@ -82,37 +270,149 @@ export class PrsTreeModel implements vscode.Disposable { this._onDidChangePrStatus.fire(changedStatuses); } - async getLocalPullRequests(folderRepoManager: FolderRepositoryManager) { - const prs = await folderRepoManager.getLocalPullRequests(); + private getFolderCache(folderRepoManager: FolderRepositoryManager): Map<string | PRType.LocalPullRequest | PRType.All, CachedPRs> { + let cache = this._cachedPRs.get(folderRepoManager); + if (!cache) { + cache = new Map(); + this._cachedPRs.set(folderRepoManager, cache); + } + return cache; + } + + async getLocalPullRequests(folderRepoManager: FolderRepositoryManager, update?: boolean): Promise<ItemsResponseResult<PullRequestModel>> { + const cache = this.getFolderCache(folderRepoManager); + if (!update && cache.has(PRType.LocalPullRequest)) { + return cache.get(PRType.LocalPullRequest)!.items; + } + + const useReviewConfiguration = getReviewMode(); + + const prs = (await folderRepoManager.getLocalPullRequests()) + .filter(pr => pr.isOpen || (pr.isClosed && useReviewConfiguration.closed) || (pr.isMerged && useReviewConfiguration.merged)); + const toCache: CachedPRs = { + clearRequested: false, + maxKnownPR: undefined, + 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" : {} */ this._telemetry.sendTelemetryEvent('pr.expand.local'); // Don't await this._getChecks. It fires an event that will be listened to. this._getChecks(prs); - return prs; + this.hasLoaded = true; + return { hasMorePages: false, hasUnsearchedRepositories: false, items: prs }; } - async getPullRequestsForQuery(folderRepoManager: FolderRepositoryManager, fetchNextPage: boolean, query: string): Promise<ItemsResponseResult<PullRequestModel>> { - const prs = await folderRepoManager.getPullRequests( - PRType.Query, - { fetchNextPage }, - query, - ); - /* __GDPR__ - "pr.expand.query" : {} - */ - this._telemetry.sendTelemetryEvent('pr.expand.query'); - // Don't await this._getChecks. It fires an event that will be listened to. - this._getChecks(prs.items); - return prs; + private async _testIfRefreshNeeded(cached: CachedPRs, query: string, folderManager: FolderRepositoryManager): Promise<boolean> { + if (!cached.clearRequested) { + return false; + } + + const repoInfo = await extractRepoFromQuery(folderManager, query); + if (!repoInfo) { + // Query doesn't specify a repo or org, so always refresh + // Send telemetry once indicating we couldn't find a repo in the query. + if (!this._sentNoRepoTelemetry) { + /* __GDPR__ + "pr.expand.noRepo" : {} + */ + this._telemetry.sendTelemetryEvent('pr.expand.noRepo'); + this._sentNoRepoTelemetry = true; + } + return true; + } + + const currentMax = await this._getMaxKnownPR(repoInfo); + if (currentMax !== cached.maxKnownPR) { + cached.maxKnownPR = currentMax; + return true; + } + return false; + } + + private async _getMaxKnownPR(repoInfo: RemoteInfo): Promise<number | undefined> { + const manager = this._reposManager.getManagerForRepository(repoInfo.owner, repoInfo.repositoryName); + if (!manager) { + return; + } + const repo = manager.findExistingGitHubRepository({ owner: repoInfo.owner, repositoryName: repoInfo.repositoryName }); + if (!repo) { + return; + } + return repo.getMaxPullRequest(); + } + + async getPullRequestsForQuery(folderRepoManager: FolderRepositoryManager, fetchNextPage: boolean, query: string, fetchOnePagePerRepo: boolean = false): Promise<ItemsResponseResult<PullRequestModel>> { + let release: () => void; + const lock = new Promise<void>(resolve => { release = resolve; }); + const prev = this._getPullRequestsForQueryLock; + this._getPullRequestsForQueryLock = prev.then(() => lock); + await prev; + + 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); + maxKnownPR = cachedPRs.maxKnownPR; + if (!shouldRefresh) { + cachedPRs.clearRequested = false; + return cachedPRs.items; + } + } + + if (!maxKnownPR) { + const repoInfo = await extractRepoFromQuery(folderRepoManager, query); + if (repoInfo) { + maxKnownPR = await this._getMaxKnownPR(repoInfo); + } + } + + const prs = await folderRepoManager.getPullRequests( + PRType.Query, + { 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" : {} + */ + this._telemetry.sendTelemetryEvent('pr.expand.query'); + // Don't await this._getChecks. It fires an event that will be listened to. + this._getChecks(prs.items); + this.hasLoaded = true; + return prs; + } finally { + release!(); + } } - async getAllPullRequests(folderRepoManager: FolderRepositoryManager, fetchNextPage: boolean): Promise<ItemsResponseResult<PullRequestModel>> { + async getAllPullRequests(folderRepoManager: FolderRepositoryManager, fetchNextPage: boolean, update?: boolean): Promise<ItemsResponseResult<PullRequestModel>> { + const cache = this.getFolderCache(folderRepoManager); + const allCache = cache.get(PRType.All); + if (!update && allCache && !allCache.clearRequested && !fetchNextPage) { + return allCache.items; + } + const prs = await folderRepoManager.getPullRequests( 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" : {} @@ -120,11 +420,169 @@ export class PrsTreeModel implements vscode.Disposable { this._telemetry.sendTelemetryEvent('pr.expand.all'); // Don't await this._getChecks. It fires an event that will be listened to. this._getChecks(prs.items); + this.hasLoaded = true; return prs; } - dispose() { - dispose(this._disposables); + private forceClearQueriesContainingPullRequests(pullRequests: PullRequestChangeEvent[]): void { + const withStateChange = pullRequests.filter(prChange => prChange.event.state); + if (!withStateChange || withStateChange.length === 0) { + return; + } + for (const [, queries] of this._cachedPRs.entries()) { + for (const [queryKey, cachedPRs] of queries.entries()) { + if (!cachedPRs || !cachedPRs.items.items || cachedPRs.items.items.length === 0) { + continue; + } + const hasPR = withStateChange.some(prChange => + cachedPRs.items.items.some(item => item === prChange.model) + ); + if (hasPR) { + 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<boolean> | undefined; + async refreshCopilotStateChanges(clearCache: boolean = false): Promise<boolean> { + // Skip Copilot PR status fetching if dev mode is enabled + const devMode = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<boolean>(DEV_MODE, false); + if (devMode) { + return false; + } + + // 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<string> = 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(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<CodingAgentPRAndStatus[]> { + if (clearCache) { + this.clearCopilotCaches(); + } + + await this.refreshCopilotStateChanges(clearCache); + return this.copilotStateModel.all; + } + + override dispose() { + super.dispose(); + disposeAll(Array.from(this._activePRDisposables.values()).flat()); } } \ No newline at end of file diff --git a/src/view/pullRequestCommentController.ts b/src/view/pullRequestCommentController.ts index 7bfb7cbb8b..ee06622277 100644 --- a/src/view/pullRequestCommentController.ts +++ b/src/view/pullRequestCommentController.ts @@ -3,20 +3,26 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as path from 'path'; import { v4 as uuid } from 'uuid'; import * as vscode from 'vscode'; import { CommentHandler, registerCommentHandler, unregisterCommentHandler } from '../commentHandlerResolver'; -import { DiffSide, IComment } from '../common/comment'; +import { CommentControllerBase } from './commentControllBase'; +import { DiffSide, IComment, SubjectType } from '../common/comment'; +import { disposeAll } from '../common/lifecycle'; +import Logger from '../common/logger'; +import { ITelemetry } from '../common/telemetry'; import { fromPRUri, Schemes } from '../common/uri'; -import { groupBy } from '../common/utils'; +import { formatError, groupBy } from '../common/utils'; +import { PULL_REQUEST_OVERVIEW_VIEW_TYPE } from '../common/webview'; import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { GitHubRepository } from '../github/githubRepository'; import { GHPRComment, GHPRCommentThread, TemporaryComment } from '../github/prComment'; import { PullRequestModel, ReviewThreadChangeEvent } from '../github/pullRequestModel'; import { PullRequestOverviewPanel } from '../github/pullRequestOverview'; import { CommentReactionHandler, createVSCodeCommentThreadForReviewThread, + setReplyAuthor, threadRange, updateCommentReviewState, updateCommentThreadLabel, @@ -24,25 +30,27 @@ import { updateThreadWithRange, } from '../github/utils'; -export class PullRequestCommentController implements CommentHandler, CommentReactionHandler { +export class PullRequestCommentController extends CommentControllerBase implements CommentHandler, CommentReactionHandler { + private static ID = 'PullRequestCommentController'; + static readonly PREFIX = 'github-browse'; + private _pendingCommentThreadAdds: GHPRCommentThread[] = []; private _commentHandlerId: string; private _commentThreadCache: { [key: string]: GHPRCommentThread[] } = {}; - private _openPREditors: vscode.TextEditor[] = []; - /** - * Cached threads belong to editors that are closed, but that we keep cached because they were recently used. - * This prevents comment replies that haven't been submitted from getting deleted too easily. - */ - private _closedEditorCachedThreads: Set<string> = new Set(); - private _disposables: vscode.Disposable[] = []; + private readonly _context: vscode.ExtensionContext; + private readonly _githubRepositories: GitHubRepository[]; constructor( - private pullRequestModel: PullRequestModel, - private _folderReposManager: FolderRepositoryManager, - private _commentController: vscode.CommentController, + private readonly pullRequestModel: PullRequestModel, + folderRepoManager: FolderRepositoryManager, + commentController: vscode.CommentController, + telemetry: ITelemetry ) { + super(folderRepoManager, telemetry); + this._commentController = commentController; + this._context = folderRepoManager.context; this._commentHandlerId = uuid(); - registerCommentHandler(this._commentHandlerId, this); + registerCommentHandler(this._commentHandlerId, this, folderRepoManager.repository); if (this.pullRequestModel.reviewThreadsCacheReady) { this.initializeThreadsInOpenEditors().then(() => { @@ -55,18 +63,19 @@ export class PullRequestCommentController implements CommentHandler, CommentReac this.registerListeners(); }); } + this._githubRepositories = this.githubReposForPullRequest(pullRequestModel); } private registerListeners(): void { - this._disposables.push(this.pullRequestModel.onDidChangeReviewThreads(e => this.onDidChangeReviewThreads(e))); + this._register(this.pullRequestModel.onDidChangeReviewThreads(e => this.onDidChangeReviewThreads(e))); - this._disposables.push( - vscode.window.onDidChangeVisibleTextEditors(async e => { - return this.onDidChangeOpenEditors(e); - }), + this._register( + vscode.window.tabGroups.onDidChangeTabs(async e => { + return this.onDidChangeOpenTabs(e); + }) ); - this._disposables.push( + this._register( this.pullRequestModel.onDidChangePendingReviewState(newDraftMode => { for (const key in this._commentThreadCache) { this._commentThreadCache[key].forEach(thread => { @@ -76,7 +85,7 @@ export class PullRequestCommentController implements CommentHandler, CommentReac }), ); - this._disposables.push( + this._register( vscode.window.onDidChangeActiveTextEditor(e => { this.refreshContextKey(e); }), @@ -101,51 +110,49 @@ export class PullRequestCommentController implements CommentHandler, CommentReac this.setContextKey(this.pullRequestModel.hasPendingReview); } - private getPREditors(editors: readonly vscode.TextEditor[]): vscode.TextEditor[] { - return editors.filter(editor => { - if (editor.document.uri.scheme !== Schemes.Pr) { - return false; + private async getPREditors(editors: readonly vscode.TextEditor[] | readonly (vscode.TabInputText | vscode.TabInputTextDiff)[]): Promise<vscode.TextDocument[]> { + const prDocuments: Promise<vscode.TextDocument>[] = []; + const isPrEditor = (potentialEditor: { uri: vscode.Uri, editor?: vscode.TextEditor }): Thenable<vscode.TextDocument> | undefined => { + const params = fromPRUri(potentialEditor.uri); + if (params && params.prNumber === this.pullRequestModel.number) { + if (potentialEditor.editor) { + return Promise.resolve(potentialEditor.editor.document); + } else { + Logger.trace(`Opening text document for PR editor ${potentialEditor.uri.toString()}`, PullRequestCommentController.ID); + return vscode.workspace.openTextDocument(potentialEditor.uri); + } } - - const params = fromPRUri(editor.document.uri); - - if (!params || params.prNumber !== this.pullRequestModel.number) { - return false; + }; + for (const editor of editors) { + const testUris: { uri: vscode.Uri, editor?: vscode.TextEditor }[] = []; + if (editor instanceof vscode.TabInputText) { + testUris.push({ uri: editor.uri }); + } else if (editor instanceof vscode.TabInputTextDiff) { + testUris.push({ uri: editor.original }, { uri: editor.modified }); + } else { + testUris.push({ uri: editor.document.uri, editor }); } - - return true; - }); + prDocuments.push(...testUris.map(isPrEditor).filter<Promise<vscode.TextDocument>>((doc): doc is Promise<vscode.TextDocument> => !!doc)); + } + return Promise.all(prDocuments); } private getCommentThreadCacheKey(fileName: string, isBase: boolean): string { return `${fileName}-${isBase ? 'original' : 'modified'}`; } - private tryUsedCachedEditor(editors: vscode.TextEditor[]): vscode.TextEditor[] { - const uncachedEditors: vscode.TextEditor[] = []; - editors.forEach(editor => { - const { fileName, isBase } = fromPRUri(editor.document.uri)!; - const key = this.getCommentThreadCacheKey(fileName, isBase); - if (this._closedEditorCachedThreads.has(key)) { - // Update position in cache - this._closedEditorCachedThreads.delete(key); - this._closedEditorCachedThreads.add(key); - } else { - uncachedEditors.push(editor); - } - }); - return uncachedEditors; - } - - private async addThreadsForEditors(newEditors: vscode.TextEditor[]): Promise<void> { - const editors = this.tryUsedCachedEditor(newEditors); + private async addThreadsForEditors(documents: vscode.TextDocument[]): Promise<void> { const reviewThreads = this.pullRequestModel.reviewThreadsCache; const threadsByPath = groupBy(reviewThreads, thread => thread.path); - const currentUser = await this._folderReposManager.getCurrentUser(); - editors.forEach(editor => { - const { fileName, isBase } = fromPRUri(editor.document.uri)!; + const currentUser = await this._folderRepoManager.getCurrentUser(); + for (const document of documents) { + const { fileName, isBase } = fromPRUri(document.uri)!; + const cacheKey = this.getCommentThreadCacheKey(fileName, isBase); + if (this._commentThreadCache[cacheKey]) { + continue; + } if (threadsByPath[fileName]) { - this._commentThreadCache[this.getCommentThreadCacheKey(fileName, isBase)] = threadsByPath[fileName] + this._commentThreadCache[cacheKey] = threadsByPath[fileName] .filter( thread => ((thread.diffSide === DiffSide.LEFT && isBase) || @@ -154,76 +161,78 @@ export class PullRequestCommentController implements CommentHandler, CommentReac ) .map(thread => { const endLine = thread.endLine - 1; - const range = threadRange(thread.startLine - 1, endLine, editor.document.lineAt(endLine).range.end.character); + const range = thread.subjectType === SubjectType.FILE ? undefined : threadRange(thread.startLine - 1, endLine, document.lineAt(endLine).range.end.character); return createVSCodeCommentThreadForReviewThread( - editor.document.uri, + this._context, + document.uri, range, thread, this._commentController, - currentUser.login, - this.pullRequestModel.githubRepository + currentUser, + this._githubRepositories ); }); } - }); + } } private async initializeThreadsInOpenEditors(): Promise<void> { - const prEditors = this.getPREditors(vscode.window.visibleTextEditors); - this._openPREditors = prEditors; + const prEditors = await this.getPREditors(vscode.window.visibleTextEditors); return this.addThreadsForEditors(prEditors); } - private cleanCachedEditors() { - // Keep the most recent 8 editors (4 diffs) around and clean up the rest. - if (this._closedEditorCachedThreads.size > 8) { - const keys = Array.from(this._closedEditorCachedThreads.keys()); - for (let i = 0; i < this._closedEditorCachedThreads.size - 4; i++) { - const key = keys[i]; - this.cleanCachedEditor(key); - this._closedEditorCachedThreads.delete(key); - } - } + private allTabs(): (vscode.TabInputText | vscode.TabInputTextDiff)[] { + return this.filterTabsToPrTabs(vscode.window.tabGroups.all.map(group => group.tabs).flat()); } - private cleanCachedEditor(key: string) { - const threads = this._commentThreadCache[key] || []; - threads.forEach(t => t.dispose()); - delete this._commentThreadCache[key]; + private filterTabsToPrTabs(tabs: readonly vscode.Tab[]): (vscode.TabInputText | vscode.TabInputTextDiff)[] { + return tabs.filter(tab => tab.input instanceof vscode.TabInputText || tab.input instanceof vscode.TabInputTextDiff).map(tab => tab.input as vscode.TabInputText | vscode.TabInputTextDiff); } - private addCachedEditors(editors: vscode.TextEditor[]) { - editors.forEach(editor => { - const { fileName, isBase } = fromPRUri(editor.document.uri)!; - const key = this.getCommentThreadCacheKey(fileName, isBase); - if (this._closedEditorCachedThreads.has(key)) { - // Delete to update position in the cache - this._closedEditorCachedThreads.delete(key); - } - this._closedEditorCachedThreads.add(key); - }); + private prDescriptionOpened(tabs: readonly vscode.Tab[]): boolean { + return tabs.some(tab => tab.input instanceof vscode.TabInputWebview && tab.label.includes(`#${this.pullRequestModel.number}`) && tab.input.viewType.includes(PULL_REQUEST_OVERVIEW_VIEW_TYPE)); } - private async onDidChangeOpenEditors(editors: readonly vscode.TextEditor[]): Promise<void> { - const prEditors = this.getPREditors(editors); - const removed = this._openPREditors.filter(x => !prEditors.includes(x)); - this.addCachedEditors(removed); - this.cleanCachedEditors(); + private async cleanClosedPrs() { + // Remove comments for which no editors belonging to the same PR are open + const allPrEditors = await this.getPREditors(this.allTabs()); + const prDescriptionOpened = this.prDescriptionOpened(vscode.window.tabGroups.all.map(group => group.tabs).flat()); + if (allPrEditors.length === 0 && !prDescriptionOpened) { + this.removeAllCommentsThreads(); + } + } - const added = prEditors.filter(x => !this._openPREditors.includes(x)); - this._openPREditors = prEditors; + private async openAllTextDocuments(): Promise<vscode.TextDocument[]> { + const files = await PullRequestModel.getChangeModels(this._folderRepoManager, this.pullRequestModel); + const textDocuments: vscode.TextDocument[] = []; + for (const file of files) { + textDocuments.push(await vscode.workspace.openTextDocument(file.filePath)); + } + return textDocuments; + } + + private async onDidChangeOpenTabs(e: vscode.TabChangeEvent): Promise<void> { + const added = await this.getPREditors(this.filterTabsToPrTabs(e.opened)); if (added.length) { await this.addThreadsForEditors(added); + } else if (this.prDescriptionOpened(e.opened)) { + const textDocuments = await this.openAllTextDocuments(); + await this.addThreadsForEditors(textDocuments); + } + if (e.closed.length > 0) { + // Delay cleaning closed editors to handle the case where a preview tab is replaced + await new Promise(resolve => setTimeout(resolve, 100)); + await this.cleanClosedPrs(); } } - private onDidChangeReviewThreads(e: ReviewThreadChangeEvent): void { - e.added.forEach(async (thread) => { + private async onDidChangeReviewThreads(e: ReviewThreadChangeEvent): Promise<void> { + for (const thread of e.added) { const fileName = thread.path; const index = this._pendingCommentThreadAdds.findIndex(t => { - const samePath = this.gitRelativeRootPath(t.uri.path) === thread.path; - const sameLine = t.range.end.line + 1 === thread.endLine; + const samePath = this._folderRepoManager.gitRelativeRootPath(t.uri.path) === thread.path; + const sameLine = (t.range === undefined && thread.subjectType === SubjectType.FILE) || (t.range && t.range.end.line + 1 === thread.endLine); return samePath && sameLine; }); @@ -231,13 +240,13 @@ export class PullRequestCommentController implements CommentHandler, CommentReac if (index > -1) { newThread = this._pendingCommentThreadAdds[index]; newThread.gitHubThreadId = thread.id; - newThread.comments = thread.comments.map(c => new GHPRComment(c, newThread!, this.pullRequestModel.githubRepository)); - updateThreadWithRange(newThread, thread, this.pullRequestModel.githubRepository); + newThread.comments = thread.comments.map(c => new GHPRComment(this._context, c, newThread!, this._githubRepositories)); + updateThreadWithRange(this._context, newThread, thread, this._githubRepositories, undefined, true); this._pendingCommentThreadAdds.splice(index, 1); } else { - const openPREditors = this.getPREditors(vscode.window.visibleTextEditors); + const openPREditors = await this.getPREditors(vscode.window.visibleTextEditors); const matchingEditor = openPREditors.find(editor => { - const query = fromPRUri(editor.document.uri); + const query = fromPRUri(editor.uri); const sameSide = (thread.diffSide === DiffSide.RIGHT && !query?.isBase) || (thread.diffSide === DiffSide.LEFT && query?.isBase); @@ -246,15 +255,16 @@ export class PullRequestCommentController implements CommentHandler, CommentReac if (matchingEditor) { const endLine = thread.endLine - 1; - const range = threadRange(thread.startLine - 1, endLine, matchingEditor.document.lineAt(endLine).range.end.character); + const range = thread.subjectType === SubjectType.FILE ? undefined : threadRange(thread.startLine - 1, endLine, matchingEditor.lineAt(endLine).range.end.character); newThread = createVSCodeCommentThreadForReviewThread( - matchingEditor.document.uri, + this._context, + matchingEditor.uri, range, thread, this._commentController, - (await this._folderReposManager.getCurrentUser()).login, - this.pullRequestModel.githubRepository + (await this._folderRepoManager.getCurrentUser()), + this._githubRepositories ); } } @@ -268,18 +278,18 @@ export class PullRequestCommentController implements CommentHandler, CommentReac } 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(matchingThread, thread, this.pullRequestModel.githubRepository); + 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) { @@ -287,7 +297,23 @@ export class PullRequestCommentController implements CommentHandler, CommentReac this._commentThreadCache[key].splice(index, 1); matchingThread.dispose(); } - }); + } + } + + protected override onDidChangeActiveTextEditor(editor: vscode.TextEditor | undefined) { + const activeTab = vscode.window.tabGroups.activeTabGroup.activeTab; + const activeUri = activeTab?.input instanceof vscode.TabInputText ? activeTab.input.uri : (activeTab?.input instanceof vscode.TabInputTextDiff ? activeTab.input.original : undefined); + + if (editor === undefined || !editor.document.uri.authority.startsWith(PullRequestCommentController.PREFIX) || !activeUri || (activeUri.scheme !== Schemes.Pr)) { + return; + } + + const params = fromPRUri(activeUri); + if (!params || params.prNumber !== this.pullRequestModel.number) { + return; + } + + return this.tryAddCopilotMention(editor, this.pullRequestModel); } hasCommentThread(thread: GHPRCommentThread): boolean { @@ -327,17 +353,18 @@ export class PullRequestCommentController implements CommentHandler, CommentReac if (hasExistingComments) { await this.reply(thread, input, isSingleComment); } else { - const fileName = this.gitRelativeRootPath(thread.uri.path); + const fileName = this._folderRepoManager.gitRelativeRootPath(thread.uri.path); const side = this.getCommentSide(thread); this._pendingCommentThreadAdds.push(thread); - await this.pullRequestModel.createReviewThread( + await Promise.all([this.pullRequestModel.createReviewThread( input, fileName, - thread.range.start.line + 1, - thread.range.end.line + 1, + thread.range ? (thread.range.start.line + 1) : undefined, + thread.range ? (thread.range.end.line + 1) : undefined, side, isSingleComment, - ); + ), + setReplyAuthor(thread, await this._folderRepoManager.getCurrentUser(this.pullRequestModel.githubRepository), this._context)]); } if (isSingleComment) { @@ -347,7 +374,7 @@ export class PullRequestCommentController implements CommentHandler, CommentReac if (e.graphQLErrors?.length && e.graphQLErrors[0].type === 'NOT_FOUND') { vscode.window.showWarningMessage('The comment that you\'re replying to was deleted. Refresh to update.', 'Refresh').then(result => { if (result === 'Refresh') { - this.pullRequestModel.invalidate(); + this.pullRequestModel.githubRepository.getPullRequest(this.pullRequestModel.number); } }); } else { @@ -374,7 +401,7 @@ export class PullRequestCommentController implements CommentHandler, CommentReac } private async optimisticallyEditComment(thread: GHPRCommentThread, comment: GHPRComment): Promise<number> { - const currentUser = await this._folderReposManager.getCurrentUser(this.pullRequestModel.githubRepository); + const currentUser = await this._folderRepoManager.getCurrentUser(this.pullRequestModel.githubRepository); const temporaryComment = new TemporaryComment( thread, comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, @@ -406,7 +433,7 @@ export class PullRequestCommentController implements CommentHandler, CommentReac thread.comments = thread.comments.map(c => { if (c instanceof TemporaryComment && c.id === temporaryCommentId) { - return new GHPRComment(comment.rawComment, thread); + return new GHPRComment(this._context, comment.rawComment, thread); } return c; @@ -432,29 +459,25 @@ export class PullRequestCommentController implements CommentHandler, CommentReac } // #endregion - private gitRelativeRootPath(comparePath: string) { - // get path relative to git root directory. Handles windows path by converting it to unix path. - return path.relative(this._folderReposManager.repository.rootUri.path, comparePath).replace(/\\/g, '/'); - } - // #region Review public async startReview(thread: GHPRCommentThread, input: string): Promise<void> { const hasExistingComments = thread.comments.length; - const temporaryCommentId = await this.optimisticallyAddComment(thread, input, true); + let temporaryCommentId: number | undefined = undefined; try { + temporaryCommentId = await this.optimisticallyAddComment(thread, input, true); if (!hasExistingComments) { - const fileName = this.gitRelativeRootPath(thread.uri.path); + const fileName = this._folderRepoManager.gitRelativeRootPath(thread.uri.path); const side = this.getCommentSide(thread); this._pendingCommentThreadAdds.push(thread); - await this.pullRequestModel.createReviewThread(input, fileName, thread.range.start.line + 1, thread.range.end.line + 1, side); + await this.pullRequestModel.createReviewThread(input, fileName, thread.range ? (thread.range.start.line + 1) : undefined, thread.range ? (thread.range.end.line + 1) : undefined, side); } else { await this.reply(thread, input, false); } this.setContextKey(true); } catch (e) { - vscode.window.showErrorMessage(`Starting a review failed: ${e}`); + vscode.window.showErrorMessage(`Starting review failed. Any review comments may be lost.`, { modal: true, detail: e?.message ?? e }); thread.comments = thread.comments.map(c => { if (c instanceof TemporaryComment && c.id === temporaryCommentId) { @@ -468,17 +491,22 @@ export class PullRequestCommentController implements CommentHandler, CommentReac public async openReview(): Promise<void> { - await PullRequestOverviewPanel.createOrShow(this._folderReposManager.context.extensionUri, this._folderReposManager, this.pullRequestModel); + const identity = { + owner: this.pullRequestModel.remote.owner, + repo: this.pullRequestModel.remote.repositoryName, + number: this.pullRequestModel.number + }; + await PullRequestOverviewPanel.createOrShow(this._telemetry, this._folderRepoManager.context.extensionUri, this._folderRepoManager, identity, this.pullRequestModel); PullRequestOverviewPanel.scrollToReview(); /* __GDPR__ "pr.openDescription" : {} */ - this._folderReposManager.telemetry.sendTelemetryEvent('pr.openDescription'); + this._folderRepoManager.telemetry.sendTelemetryEvent('pr.openDescription'); } private async optimisticallyAddComment(thread: GHPRCommentThread, input: string, inDraft: boolean): Promise<number> { - const currentUser = await this._folderReposManager.getCurrentUser(this.pullRequestModel.githubRepository); + const currentUser = await this._folderRepoManager.getCurrentUser(this.pullRequestModel.githubRepository); const comment = new TemporaryComment(thread, input, inDraft, currentUser); this.updateCommentThreadComments(thread, [...thread.comments, comment]); return comment.id; @@ -523,14 +551,25 @@ export class PullRequestCommentController implements CommentHandler, CommentReac return; } - if ( - comment.reactions && - !comment.reactions.find(ret => ret.label === reaction.label && !!ret.authorHasReacted) - ) { - // add reaction - await this.pullRequestModel.addCommentReaction(comment.rawComment.graphNodeId, reaction); - } else { - await this.pullRequestModel.deleteCommentReaction(comment.rawComment.graphNodeId, reaction); + try { + if ( + comment.reactions && + !comment.reactions.find(ret => ret.label === reaction.label && !!ret.authorHasReacted) + ) { + // add reaction + await this.pullRequestModel.addCommentReaction(comment.rawComment.graphNodeId, reaction); + } else { + await this.pullRequestModel.deleteCommentReaction(comment.rawComment.graphNodeId, reaction); + } + } catch (e) { + // Ignore permission errors when removing reactions due to race conditions + // See: https://github.com/microsoft/vscode/issues/69321 + const errorMessage = formatError(e); + if (errorMessage.includes('does not have the correct permissions to execute `RemoveReaction`')) { + // Silently ignore this error - it occurs when quickly toggling reactions + return; + } + throw new Error(errorMessage); } } @@ -538,13 +577,16 @@ export class PullRequestCommentController implements CommentHandler, CommentReac vscode.commands.executeCommand('setContext', 'prInDraft', inDraftMode); } - dispose() { + private removeAllCommentsThreads(): void { Object.keys(this._commentThreadCache).forEach(key => { - this._commentThreadCache[key].forEach(thread => thread.dispose()); + disposeAll(this._commentThreadCache[key]); + delete this._commentThreadCache[key]; }); + } + override dispose() { + super.dispose(); + this.removeAllCommentsThreads(); unregisterCommentHandler(this._commentHandlerId); - - this._disposables.forEach(d => d.dispose()); } } diff --git a/src/view/pullRequestCommentControllerRegistry.ts b/src/view/pullRequestCommentControllerRegistry.ts index f5eda0f2ad..e4eeb6ad12 100644 --- a/src/view/pullRequestCommentControllerRegistry.ts +++ b/src/view/pullRequestCommentControllerRegistry.ts @@ -5,24 +5,33 @@ 'use strict'; import * as vscode from 'vscode'; -import { fromPRUri } from '../common/uri'; +import { PullRequestCommentController } from './pullRequestCommentController'; +import { Disposable } from '../common/lifecycle'; +import { ITelemetry } from '../common/telemetry'; +import { fromPRUri, Schemes } from '../common/uri'; 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; } -export class PRCommentControllerRegistry implements vscode.CommentingRangeProvider, CommentReactionHandler, vscode.Disposable { +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.CommentingRangeProvider } = {}; + private _prCommentingRangeProviders: { [key: number]: PRCommentingRangeProviderInfo } = {}; + private readonly _activeChangeListeners: Map<FolderRepositoryManager, vscode.Disposable> = new Map(); + public readonly resourceHints = { schemes: [Schemes.Pr] }; - constructor(public commentsController: vscode.CommentController) { + constructor(public readonly commentsController: vscode.CommentController, private readonly _telemetry: ITelemetry) { + super(); this.commentsController.commentingRangeProvider = this; this.commentsController.reactionHandler = this.toggleReaction.bind(this); } @@ -35,8 +44,8 @@ export class PRCommentControllerRegistry implements vscode.CommentingRangeProvid 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); @@ -61,13 +70,28 @@ export class PRCommentControllerRegistry implements vscode.CommentingRangeProvid return toggleReaction(comment, reaction); } + public unregisterCommentController(prNumber: number): void { + if (this._prCommentHandlers[prNumber]) { + this._prCommentHandlers[prNumber].dispose(); + delete this._prCommentHandlers[prNumber]; + } + } + public registerCommentController(prNumber: number, pullRequestModel: PullRequestModel, folderRepositoryManager: FolderRepositoryManager): vscode.Disposable { if (this._prCommentHandlers[prNumber]) { this._prCommentHandlers[prNumber].refCount += 1; return this._prCommentHandlers[prNumber]; } - const handler = new PullRequestCommentController(pullRequestModel, folderRepositoryManager, this.commentsController); + if (!this._activeChangeListeners.has(folderRepositoryManager)) { + this._activeChangeListeners.set(folderRepositoryManager, folderRepositoryManager.onDidChangeActivePullRequest(e => { + if (e.old) { + this._prCommentHandlers[e.old.number]?.dispose(); + } + })); + } + + const handler = new PullRequestCommentController(pullRequestModel, folderRepositoryManager, this.commentsController, this._telemetry); this._prCommentHandlers[prNumber] = { handler, refCount: 1, @@ -87,17 +111,32 @@ export class PRCommentControllerRegistry implements vscode.CommentingRangeProvid return this._prCommentHandlers[prNumber]; } - public registerCommentingRangeProvider(prNumber: number, provider: vscode.CommentingRangeProvider): vscode.Disposable { - this._prCommentingRangeProviders[prNumber] = provider; + public registerCommentingRangeProvider(prNumber: number, provider: vscode.CommentingRangeProvider2): vscode.Disposable { + 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]; } - dispose() { + override dispose() { + super.dispose(); Object.keys(this._prCommentHandlers).forEach(key => { this._prCommentHandlers[key].handler.dispose(); }); diff --git a/src/view/readonlyFileSystemProvider.ts b/src/view/readonlyFileSystemProvider.ts index 1bb271b349..d98192b691 100644 --- a/src/view/readonlyFileSystemProvider.ts +++ b/src/view/readonlyFileSystemProvider.ts @@ -9,11 +9,11 @@ export abstract class ReadonlyFileSystemProvider implements vscode.FileSystemPro protected _onDidChangeFile = new vscode.EventEmitter<vscode.FileChangeEvent[]>(); onDidChangeFile = this._onDidChangeFile.event; - constructor() {} + constructor() { } watch(_uri: vscode.Uri, _options: { recursive: boolean; excludes: string[]; }): vscode.Disposable { /** no op */ - return { dispose: () => {} }; + return { dispose: () => { } }; } stat(_uri: any): vscode.FileStat { @@ -21,7 +21,7 @@ export abstract class ReadonlyFileSystemProvider implements vscode.FileSystemPro return { type: vscode.FileType.File, ctime: 0, - mtime: 0, + mtime: new Date().getTime(), size: 0 }; } diff --git a/src/view/repositoryFileSystemProvider.ts b/src/view/repositoryFileSystemProvider.ts index 8647cbe59b..78ab8a849a 100644 --- a/src/view/repositoryFileSystemProvider.ts +++ b/src/view/repositoryFileSystemProvider.ts @@ -4,10 +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 { ReadonlyFileSystemProvider } from './readonlyFileSystemProvider'; +import { RepositoriesManager } from '../github/repositoriesManager'; export abstract class RepositoryFileSystemProvider extends ReadonlyFileSystemProvider { constructor(protected gitAPI: GitApiImpl, protected credentialStore: CredentialStore) { @@ -37,7 +38,7 @@ export abstract class RepositoryFileSystemProvider extends ReadonlyFileSystemPro clearTimeout(timeout); } if (eventDisposable) { - eventDisposable!.dispose(); + (eventDisposable as vscode.Disposable).dispose(); } } @@ -47,4 +48,20 @@ export abstract class RepositoryFileSystemProvider extends ReadonlyFileSystemPro } return new Promise(resolve => this.credentialStore.onDidGetSession(() => resolve())); } + + protected async waitForAnyGitHubRepos(reposManager: RepositoriesManager): Promise<void> { + // Check if any folder manager already has GitHub repositories + if (reposManager.folderManagers.some(manager => manager.gitHubRepositories.length > 0)) { + return; + } + + Logger.appendLine('Waiting for GitHub repositories.', 'RepositoryFileSystemProvider'); + return new Promise(resolve => { + const disposable = reposManager.onDidChangeAnyGitHubRepository(() => { + Logger.appendLine('Found GitHub repositories.', 'RepositoryFileSystemProvider'); + disposable.dispose(); + resolve(); + }); + }); + } } \ No newline at end of file diff --git a/src/view/reviewCommentController.ts b/src/view/reviewCommentController.ts index 52c81ec609..9aee06e00a 100644 --- a/src/view/reviewCommentController.ts +++ b/src/view/reviewCommentController.ts @@ -7,45 +7,51 @@ import * as nodePath from 'path'; import { v4 as uuid } from 'uuid'; import * as vscode from 'vscode'; import { Repository } from '../api/api'; +import { GitApiImpl } from '../api/api1'; import { CommentHandler, registerCommentHandler, unregisterCommentHandler } from '../commentHandlerResolver'; -import { DiffSide, IReviewThread } from '../common/comment'; +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'; -import { GitChangeType } from '../common/file'; +import { commands, contexts } from '../common/executeCommands'; +import { GitChangeType, InMemFileChange } from '../common/file'; +import { disposeAll } from '../common/lifecycle'; 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 { dispose, 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 { PullRequestModel } from '../github/pullRequestModel'; import { PullRequestOverviewPanel } from '../github/pullRequestOverview'; import { CommentReactionHandler, createVSCodeCommentThreadForReviewThread, + getRepositoryForFile, isFileInRepo, + setReplyAuthor, threadRange, updateCommentReviewState, updateCommentThreadLabel, updateThread, updateThreadWithRange, } from '../github/utils'; -import { RemoteFileChangeModel } from './fileChangeModel'; -import { ReviewManager } from './reviewManager'; -import { ReviewModel } from './reviewModel'; import { GitFileChangeNode, gitFileChangeNodeFilter, RemoteFileChangeNode } from './treeNodes/fileChangeNode'; -export class ReviewCommentController - implements vscode.Disposable, CommentHandler, vscode.CommentingRangeProvider, CommentReactionHandler { +export interface SuggestionInformation { + originalStartLine: number; + originalLineLength: number; + suggestionContent: string; +} + +export class ReviewCommentController extends CommentControllerBase implements CommentHandler, vscode.CommentingRangeProvider2, CommentReactionHandler { private static readonly ID = 'ReviewCommentController'; - private _localToDispose: vscode.Disposable[] = []; + private static readonly PREFIX = 'github-review'; private _commentHandlerId: string; - private _commentController: vscode.CommentController; - - public get commentController(): vscode.CommentController | undefined { - return this._commentController; - } - // Note: marked as protected so that tests can verify caches have been updated correctly without breaking type safety protected _workspaceFileChangeCommentThreads: { [key: string]: GHPRCommentThread[] } = {}; protected _reviewSchemeFileChangeCommentThreads: { [key: string]: GHPRCommentThread[] } = {}; @@ -54,22 +60,29 @@ export class ReviewCommentController protected _visibleNormalTextEditors: vscode.TextEditor[] = []; private _pendingCommentThreadAdds: GHPRCommentThread[] = []; + private readonly _context: vscode.ExtensionContext; + public readonly resourceHints = { schemes: [Schemes.Review] }; constructor( private _reviewManager: ReviewManager, - private _reposManager: FolderRepositoryManager, + folderRepoManager: FolderRepositoryManager, private _repository: Repository, private _reviewModel: ReviewModel, + private _gitApi: GitApiImpl, + telemetry: ITelemetry ) { - this._commentController = vscode.comments.createCommentController( - `github-review-${_reposManager.activePullRequest!.number}`, - _reposManager.activePullRequest!.title, - ); - this._commentController.commentingRangeProvider = this; + super(folderRepoManager, telemetry); + this._context = this._folderRepoManager.context; + this._commentController = this._register(vscode.comments.createCommentController( + `${ReviewCommentController.PREFIX}-${folderRepoManager.activePullRequest?.remote.owner}-${folderRepoManager.activePullRequest?.remote.owner}-${folderRepoManager.activePullRequest!.number}`, + vscode.l10n.t('Pull Request ({0})', folderRepoManager.activePullRequest!.title), + )); + this._commentController.commentingRangeProvider = this as vscode.CommentingRangeProvider; this._commentController.reactionHandler = this.toggleReaction.bind(this); - this._localToDispose.push(this._commentController); + this.updateResourcesWithCommentingRanges(); + this._register(this._folderRepoManager.onDidChangeActivePullRequest(() => this.updateResourcesWithCommentingRanges())); this._commentHandlerId = uuid(); - registerCommentHandler(this._commentHandlerId, this); + registerCommentHandler(this._commentHandlerId, this, _repository); } // #region initialize @@ -77,7 +90,7 @@ export class ReviewCommentController this._visibleNormalTextEditors = vscode.window.visibleTextEditors.filter( ed => ed.document.uri.scheme !== 'comment', ); - await this._reposManager.activePullRequest!.validateDraftMode(); + await this._folderRepoManager.activePullRequest!.validateDraftMode(); await this.initializeCommentThreads(); await this.registerListeners(); } @@ -101,8 +114,8 @@ export class ReviewCommentController this._repository.rootUri, ); - const range = threadRange(thread.originalStartLine - 1, thread.originalEndLine - 1); - return createVSCodeCommentThreadForReviewThread(reviewUri, range, thread, this._commentController, (await this._reposManager.getCurrentUser()).login, this._reposManager.activePullRequest?.githubRepository); + const range = thread.subjectType === SubjectType.FILE ? undefined : threadRange(thread.originalStartLine - 1, thread.originalEndLine - 1); + return createVSCodeCommentThreadForReviewThread(this._context, reviewUri, range, thread, this._commentController, (await this._folderRepoManager.getCurrentUser()), this.githubReposForPullRequest(this._folderRepoManager.activePullRequest)); } /** @@ -126,8 +139,16 @@ export class ReviewCommentController endLine = mapOldPositionToNew(localDiff, endLine); } - const range = threadRange(startLine - 1, endLine - 1); - return createVSCodeCommentThreadForReviewThread(uri, range, thread, this._commentController, (await this._reposManager.getCurrentUser()).login, this._reposManager.activePullRequest?.githubRepository); + let range: vscode.Range | undefined; + if (thread.subjectType !== SubjectType.FILE) { + const adjustedStartLine = startLine - 1; + const adjustedEndLine = endLine - 1; + if (adjustedStartLine < 0 || adjustedEndLine < 0) { + Logger.error(`Mapped new position for workspace comment thread is invalid. Original: (${thread.startLine}, ${thread.endLine}) New: (${adjustedStartLine}, ${adjustedEndLine})`, ReviewCommentController.ID); + } + range = threadRange(adjustedStartLine, adjustedEndLine); + } + return createVSCodeCommentThreadForReviewThread(this._context, uri, range, thread, this._commentController, (await this._folderRepoManager.getCurrentUser()), this.githubReposForPullRequest(this._folderRepoManager.activePullRequest)); } /** @@ -139,35 +160,35 @@ export class ReviewCommentController * @returns A GHPRCommentThread that has been created on an editor. */ private async createReviewCommentThread(uri: vscode.Uri, path: string, thread: IReviewThread): Promise<GHPRCommentThread> { - if (!this._reposManager.activePullRequest?.mergeBase) { + if (!this._folderRepoManager.activePullRequest?.mergeBase) { throw new Error('Cannot create review comment thread without an active pull request base.'); } const reviewUri = toReviewUri( uri, path, undefined, - this._reposManager.activePullRequest.mergeBase, + this._folderRepoManager.activePullRequest.mergeBase, false, { base: true }, this._repository.rootUri, ); - const range = threadRange(thread.startLine - 1, thread.endLine - 1); - return createVSCodeCommentThreadForReviewThread(reviewUri, range, thread, this._commentController, (await this._reposManager.getCurrentUser()).login, this._reposManager.activePullRequest?.githubRepository); + const range = thread.subjectType === SubjectType.FILE ? undefined : threadRange(thread.startLine - 1, thread.endLine - 1); + return createVSCodeCommentThreadForReviewThread(this._context, reviewUri, range, thread, this._commentController, (await this._folderRepoManager.getCurrentUser()), this.githubReposForPullRequest(this._folderRepoManager.activePullRequest)); } private async doInitializeCommentThreads(reviewThreads: IReviewThread[]): Promise<void> { // First clean up all the old comments. for (const key in this._workspaceFileChangeCommentThreads) { - dispose(this._workspaceFileChangeCommentThreads[key]); + disposeAll(this._workspaceFileChangeCommentThreads[key]); } this._workspaceFileChangeCommentThreads = {}; for (const key in this._reviewSchemeFileChangeCommentThreads) { - dispose(this._reviewSchemeFileChangeCommentThreads[key]); + disposeAll(this._reviewSchemeFileChangeCommentThreads[key]); } this._reviewSchemeFileChangeCommentThreads = {}; for (const key in this._obsoleteFileChangeCommentThreads) { - dispose(this._obsoleteFileChangeCommentThreads[key]); + disposeAll(this._obsoleteFileChangeCommentThreads[key]); } this._obsoleteFileChangeCommentThreads = {}; @@ -203,10 +224,27 @@ export class ReviewCommentController this._obsoleteFileChangeCommentThreads[path] = outdatedCommentThreads; } }); + this.updateResourcesWithCommentingRanges(); + } + + /** + * Causes pre-fetching of commenting ranges to occur for all files in the active PR + */ + private updateResourcesWithCommentingRanges(): void { + // only prefetch for small PRs + if (this._folderRepoManager.activePullRequest && this._folderRepoManager.activePullRequest.fileChanges.size < 30) { + for (const [file, change] of (this._folderRepoManager.activePullRequest?.fileChanges.entries() ?? [])) { + if (change.status !== GitChangeType.DELETE) { + const uri = vscode.Uri.joinPath(this._folderRepoManager.repository.rootUri, file); + Logger.trace(`Prefetching commenting ranges for ${uri.toString()}`, ReviewCommentController.ID); + vscode.workspace.openTextDocument(uri); + } + } + } } private async initializeCommentThreads(): Promise<void> { - const activePullRequest = this._reposManager.activePullRequest; + const activePullRequest = this._folderRepoManager.activePullRequest; if (!activePullRequest || !activePullRequest.isResolved()) { return; } @@ -214,12 +252,12 @@ export class ReviewCommentController } private async registerListeners(): Promise<void> { - const activePullRequest = this._reposManager.activePullRequest; + const activePullRequest = this._folderRepoManager.activePullRequest; if (!activePullRequest) { return; } - this._localToDispose.push( + this._register( activePullRequest.onDidChangePendingReviewState(newDraftMode => { [ this._workspaceFileChangeCommentThreads, @@ -236,19 +274,20 @@ export class ReviewCommentController }), ); - this._localToDispose.push( - activePullRequest.onDidChangeReviewThreads(e => { - e.added.forEach(async thread => { + this._register( + activePullRequest.onDidChangeReviewThreads(async e => { + const githubRepositories = this.githubReposForPullRequest(this._folderRepoManager.activePullRequest); + for (const thread of e.added) { const { path } = thread; - const index = this._pendingCommentThreadAdds.findIndex(async t => { - const fileName = this.gitRelativeRootPath(t.uri.path); + const index = await arrayFindIndexAsync(this._pendingCommentThreadAdds, async t => { + const fileName = this._folderRepoManager.gitRelativeRootPath(t.uri.path); if (fileName !== thread.path) { return false; } const diff = await this.getContentDiff(t.uri, fileName); - const line = mapNewPositionToOld(diff, t.range.end.line); + const line = t.range ? mapNewPositionToOld(diff, t.range.end.line) : 0; const sameLine = line + 1 === thread.endLine; return sameLine; }); @@ -257,8 +296,8 @@ export class ReviewCommentController if (index > -1) { newThread = this._pendingCommentThreadAdds[index]; newThread.gitHubThreadId = thread.id; - newThread.comments = thread.comments.map(c => new GHPRComment(c, newThread, activePullRequest.githubRepository)); - updateThreadWithRange(newThread, thread, activePullRequest.githubRepository); + newThread.comments = thread.comments.map(c => new GHPRComment(this._context, c, newThread, githubRepositories)); + updateThreadWithRange(this._context, newThread, thread, githubRepositories, undefined, true); this._pendingCommentThreadAdds.splice(index, 1); } else { const fullPath = nodePath.join(this._repository.rootUri.path, path).replace(/\\/g, '/'); @@ -285,47 +324,92 @@ export class ReviewCommentController } else { threadMap[path] = [newThread]; } - }); - - e.changed.forEach(thread => { - const threadMap = thread.isOutdated - ? this._obsoleteFileChangeCommentThreads - : thread.diffSide === DiffSide.RIGHT - ? this._workspaceFileChangeCommentThreads - : this._reviewSchemeFileChangeCommentThreads; + } - const index = threadMap[thread.path].findIndex(t => t.gitHubThreadId === thread.id); - if (index > -1) { - const matchingThread = threadMap[thread.path][index]; - updateThread(matchingThread, thread, activePullRequest.githubRepository); + 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 => { - const threadMap = thread.isOutdated - ? this._obsoleteFileChangeCommentThreads - : thread.diffSide === DiffSide.RIGHT - ? this._workspaceFileChangeCommentThreads - : this._reviewSchemeFileChangeCommentThreads; + } - const index = threadMap[thread.path].findIndex(t => t.gitHubThreadId === thread.id); - if (index > -1) { - const matchingThread = threadMap[thread.path][index]; - threadMap[thread.path].splice(index, 1); + 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(); }), ); } + private _findMatchingThread(thread: IReviewThread): { threadMap: { [key: string]: GHPRCommentThread[] }, index: number } { + const threadMap = thread.isOutdated + ? this._obsoleteFileChangeCommentThreads + : thread.diffSide === DiffSide.RIGHT + ? this._workspaceFileChangeCommentThreads + : this._reviewSchemeFileChangeCommentThreads; + + let index = threadMap[thread.path]?.findIndex(t => t.gitHubThreadId === thread.id) ?? -1; + if ((index === -1) && thread.isOutdated) { + // The thread has become outdated and needs to be moved to the obsolete threads. + index = this._workspaceFileChangeCommentThreads[thread.path]?.findIndex(t => t.gitHubThreadId === thread.id) ?? -1; + if (index > -1) { + const matchingThread = this._workspaceFileChangeCommentThreads[thread.path]!.splice(index, 1)[0]; + if (!this._obsoleteFileChangeCommentThreads[thread.path]) { + this._obsoleteFileChangeCommentThreads[thread.path] = []; + } + this._obsoleteFileChangeCommentThreads[thread.path]!.push(matchingThread); + } + } + return { threadMap, index }; + } + + private _commentContentChangedListener: vscode.Disposable | undefined; + protected onDidChangeActiveTextEditor(editor: vscode.TextEditor | undefined) { + this._commentContentChangedListener?.dispose(); + this._commentContentChangedListener = undefined; + + const activeTab = vscode.window.tabGroups.activeTabGroup.activeTab; + const activeUri = activeTab?.input instanceof vscode.TabInputText ? activeTab.input.uri : (activeTab?.input instanceof vscode.TabInputTextDiff ? activeTab.input.modified : undefined); + + if (editor && activeUri && editor.document.uri.authority.startsWith(ReviewCommentController.PREFIX) && (activeUri.scheme === Schemes.File)) { + if (this._folderRepoManager.activePullRequest && activeUri.toString().startsWith(this._repository.rootUri.toString())) { + this.tryAddCopilotMention(editor, this._folderRepoManager.activePullRequest); + } + } + + if (editor?.document.uri.scheme !== Schemes.Comment) { + return; + } + const updateHasSuggestion = () => { + if (editor.document.getText().includes('```suggestion')) { + commands.setContext(contexts.ACTIVE_COMMENT_HAS_SUGGESTION, true); + } else { + commands.setContext(contexts.ACTIVE_COMMENT_HAS_SUGGESTION, false); + } + }; + this._commentContentChangedListener = vscode.workspace.onDidChangeTextDocument(e => { + if (e.document.uri.toString() !== editor.document.uri.toString()) { + return; + } + updateHasSuggestion(); + }); + updateHasSuggestion(); + } + public updateCommentExpandState(expand: boolean) { - const activePullRequest = this._reposManager.activePullRequest; + const activePullRequest = this._folderRepoManager.activePullRequest; if (!activePullRequest) { return undefined; } - - function updateThreads(activePullRequest: PullRequestModel, threads: { [key: string]: GHPRCommentThread[] }, reviewThreads: Map<string, Map<string, IReviewThread>>) { + const githubRepositories = this.githubReposForPullRequest(activePullRequest); + function updateThreads(threads: { [key: string]: GHPRCommentThread[] }, reviewThreads: Map<string, Map<string, IReviewThread>>) { if (reviewThreads.size === 0) { return; } @@ -334,7 +418,7 @@ export class ReviewCommentController const commentThreads = threads[path]; for (const commentThread of commentThreads) { const reviewThread = reviewThreadsForPath.get(commentThread.gitHubThreadId)!; - updateThread(commentThread, reviewThread, activePullRequest.githubRepository, expand); + updateThread(this._context, commentThread, reviewThread, githubRepositories, expand); } } } @@ -358,9 +442,9 @@ export class ReviewCommentController } mapToUse.get(reviewThread.path)!.set(reviewThread.id, reviewThread); } - updateThreads(activePullRequest, this._obsoleteFileChangeCommentThreads, obsoleteReviewThreads); - updateThreads(activePullRequest, this._reviewSchemeFileChangeCommentThreads, reviewSchemeReviewThreads); - updateThreads(activePullRequest, this._workspaceFileChangeCommentThreads, workspaceFileReviewThreads); + updateThreads(this._obsoleteFileChangeCommentThreads, obsoleteReviewThreads); + updateThreads(this._reviewSchemeFileChangeCommentThreads, reviewSchemeReviewThreads); + updateThreads(this._workspaceFileChangeCommentThreads, workspaceFileReviewThreads); } private visibleEditorsEqual(a: vscode.TextEditor[], b: vscode.TextEditor[]): boolean { @@ -387,7 +471,7 @@ export class ReviewCommentController // #endregion - hasCommentThread(thread: vscode.CommentThread): boolean { + hasCommentThread(thread: vscode.CommentThread2): boolean { if (thread.uri.scheme === Schemes.Review) { return true; } @@ -404,10 +488,7 @@ export class ReviewCommentController return false; } - async provideCommentingRanges( - document: vscode.TextDocument, - _token: vscode.CancellationToken, - ): Promise<vscode.Range[] | undefined> { + async provideCommentingRanges(document: vscode.TextDocument, _token: vscode.CancellationToken): Promise<vscode.Range[] | { enableFileComments: boolean; ranges?: vscode.Range[] } | undefined> { let query: ReviewUriParams | undefined = (document.uri.query && document.uri.query !== '') ? fromReviewUri(document.uri.query) : undefined; @@ -416,11 +497,12 @@ export class ReviewCommentController if (matchedFile) { Logger.debug('Found matched file for commenting ranges.', ReviewCommentController.ID); - return getCommentingRanges(await matchedFile.changeModel.diffHunks(), query.base, ReviewCommentController.ID); + return { ranges: getCommentingRanges(await matchedFile.changeModel.diffHunks(), query.base, ReviewCommentController.ID), enableFileComments: true }; } } - if (!isFileInRepo(this._repository, document.uri)) { + const bestRepoForFile = getRepositoryForFile(this._gitApi, document.uri); + if (bestRepoForFile?.rootUri.toString() !== this._repository.rootUri.toString()) { if (document.uri.scheme !== 'output') { Logger.debug('No commenting ranges: File is not in the current repository.', ReviewCommentController.ID); } @@ -428,12 +510,12 @@ export class ReviewCommentController } if (document.uri.scheme === this._repository.rootUri.scheme) { - if (!this._reposManager.activePullRequest!.isResolved()) { + if (!this._folderRepoManager.activePullRequest!.isResolved()) { Logger.debug('No commenting ranges: Active PR has not been resolved.', ReviewCommentController.ID); return; } - const fileName = this.gitRelativeRootPath(document.uri.path); + const fileName = this._folderRepoManager.gitRelativeRootPath(document.uri.path); const matchedFile = gitFileChangeNodeFilter(this._reviewModel.localFileChanges).find( fileChange => fileChange.fileName === fileName, ); @@ -443,15 +525,15 @@ export class ReviewCommentController const diffHunks = await matchedFile.changeModel.diffHunks(); if ((matchedFile.status === GitChangeType.RENAME) && (diffHunks.length === 0)) { Logger.debug('No commenting ranges: File was renamed with no diffs.', ReviewCommentController.ID); - return []; + return { ranges: [], enableFileComments: true }; } const contentDiff = await this.getContentDiff(document.uri, matchedFile.fileName); for (let i = 0; i < diffHunks.length; i++) { const diffHunk = diffHunks[i]; - const start = mapOldPositionToNew(contentDiff, diffHunk.newLineNumber); - const end = mapOldPositionToNew(contentDiff, diffHunk.newLineNumber + diffHunk.newLength - 1); + const start = mapOldPositionToNew(contentDiff, diffHunk.newLineNumber, document.lineCount); + const end = mapOldPositionToNew(contentDiff, diffHunk.newLineNumber + diffHunk.newLength - 1, document.lineCount); if (start > 0 && end > 0) { ranges.push(new vscode.Range(start - 1, 0, end - 1, 0)); } @@ -465,7 +547,7 @@ export class ReviewCommentController } Logger.debug(`Providing ${ranges.length} commenting ranges for ${nodePath.basename(document.uri.fsPath)}.`, ReviewCommentController.ID); - return ranges; + return { ranges, enableFileComments: ranges.length > 0 }; } else { Logger.debug('No commenting ranges: File scheme differs from repository scheme.', ReviewCommentController.ID); } @@ -475,20 +557,20 @@ export class ReviewCommentController // #endregion - private async getContentDiff(uri: vscode.Uri, fileName: string): Promise<string> { + private async getContentDiff(uri: vscode.Uri, fileName: string, retry: boolean = true): Promise<string> { const matchedEditor = vscode.window.visibleTextEditors.find( editor => editor.document.uri.toString() === uri.toString(), ); - if (!this._reposManager.activePullRequest?.head) { - Logger.error('Failed to get content diff. Cannot get content diff without an active pull request head.'); + if (!this._folderRepoManager.activePullRequest?.head) { + Logger.error('Failed to get content diff. Cannot get content diff without an active pull request head.', ReviewCommentController.ID); throw new Error('Cannot get content diff without an active pull request head.'); } try { - if (matchedEditor && matchedEditor.document.isDirty) { + if (matchedEditor && matchedEditor.document.isDirty && vscode.workspace.getConfiguration('files', matchedEditor.document.uri).get('autoSave') !== 'afterDelay') { const documentText = matchedEditor.document.getText(); const details = await this._repository.getObjectDetails( - this._reposManager.activePullRequest.head.sha, + this._folderRepoManager.activePullRequest.head.sha, fileName, ); const idAtLastCommit = details.object; @@ -497,10 +579,31 @@ export class ReviewCommentController // git diff <blobid> <blobid> return await this._repository.diffBlobs(idAtLastCommit, idOfCurrentText); } else { - return await this._repository.diffWith(this._reposManager.activePullRequest.head.sha, fileName); + return await this._repository.diffWith(this._folderRepoManager.activePullRequest.head.sha, fileName); } } catch (e) { - Logger.error(`Failed to get content diff. ${formatError(e)}`); + Logger.error(`Failed to get content diff. ${formatError(e)}`, ReviewCommentController.ID); + if ((e.stderr as string | undefined)?.includes('bad object')) { + if (this._repository.state.HEAD?.upstream && retry) { + const pullBeforeCheckoutSetting = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<PullPRBranchVariants>(PULL_PR_BRANCH_BEFORE_CHECKOUT, 'pull'); + const pullSetting = (pullBeforeCheckoutSetting === 'pull' || pullBeforeCheckoutSetting === 'pullAndMergeBase' || pullBeforeCheckoutSetting === 'pullAndUpdateBase' || pullBeforeCheckoutSetting === true) + && (vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'never' | 'prompt' | 'always'>(PULL_BRANCH, 'prompt') === 'always'); + if (pullSetting) { + try { + await this._repository.pull(); + return this.getContentDiff(uri, fileName, false); + } catch (e) { + // No remote branch + } + } else if (this._repository.state.HEAD?.commit) { + return this._repository.diffWith(this._repository.state.HEAD.commit, fileName); + } + } + if (this._folderRepoManager.activePullRequest.isOpen) { + vscode.window.showErrorMessage(vscode.l10n.t('Unable to get comment locations for commit {0}. This commit is not available locally and there is no remote branch.', this._folderRepoManager.activePullRequest.head.sha)); + } + Logger.warn(`Unable to get comment locations for commit ${this._folderRepoManager.activePullRequest.head.sha}. This commit is not available locally and there is no remote branch.`, ReviewCommentController.ID); + } throw e; } } @@ -517,7 +620,9 @@ export class ReviewCommentController } if (fileChange.fileName !== query.path) { - return false; + if (!((fileChange.change instanceof InMemFileChange) && fileChange.change.previousFileName === query.path)) { + return false; + } } if (fileChange.filePath.scheme !== 'review') { @@ -549,11 +654,6 @@ export class ReviewCommentController return undefined; } - private gitRelativeRootPath(path: string) { - // get path relative to git root directory. Handles windows path by converting it to unix path. - return nodePath.relative(this._repository.rootUri.path, path).replace(/\\/g, '/'); - } - // #endregion // #region Review @@ -568,32 +668,38 @@ export class ReviewCommentController public async startReview(thread: GHPRCommentThread, input: string): Promise<void> { const hasExistingComments = thread.comments.length; - const temporaryCommentId = await this.optimisticallyAddComment(thread, input, true); - + let temporaryCommentId: number | undefined = undefined; try { + temporaryCommentId = await this.optimisticallyAddComment(thread, input, true); if (!hasExistingComments) { - const fileName = this.gitRelativeRootPath(thread.uri.path); + const fileName = this._folderRepoManager.gitRelativeRootPath(thread.uri.path); const side = this.getCommentSide(thread); this._pendingCommentThreadAdds.push(thread); // If the thread is on the workspace file, make sure the position // is properly adjusted to account for any local changes. - let startLine: number; - let endLine: number; - if (side === DiffSide.RIGHT) { - const diff = await this.getContentDiff(thread.uri, fileName); - startLine = mapNewPositionToOld(diff, thread.range.start.line); - endLine = mapNewPositionToOld(diff, thread.range.end.line); - } else { - startLine = thread.range.start.line; - endLine = thread.range.end.line; + let startLine: number | undefined = undefined; + let endLine: number | undefined = undefined; + if (thread.range) { + if (side === DiffSide.RIGHT) { + const diff = await this.getContentDiff(thread.uri, fileName); + startLine = mapNewPositionToOld(diff, thread.range.start.line); + endLine = mapNewPositionToOld(diff, thread.range.end.line); + } else { + startLine = thread.range.start.line; + endLine = thread.range.end.line; + } + startLine++; + endLine++; } - await this._reposManager.activePullRequest!.createReviewThread(input, fileName, startLine + 1, endLine + 1, side); + await Promise.all([this._folderRepoManager.activePullRequest!.createReviewThread(input, fileName, startLine, endLine, side), + setReplyAuthor(thread, await this._folderRepoManager.getCurrentUser(this._folderRepoManager.activePullRequest!.githubRepository), this._context) + ]); } else { const comment = thread.comments[0]; if (comment instanceof GHPRComment) { - await this._reposManager.activePullRequest!.createCommentReply( + await this._folderRepoManager.activePullRequest!.createCommentReply( input, comment.rawComment.graphNodeId, false, @@ -603,7 +709,7 @@ export class ReviewCommentController } } } catch (e) { - vscode.window.showErrorMessage(`Starting review failed: ${e}`); + vscode.window.showErrorMessage(`Starting review failed. Any review comments may be lost.`, { modal: true, detail: e?.message ?? e }); thread.comments = thread.comments.map(c => { if (c instanceof TemporaryComment && c.id === temporaryCommentId) { @@ -622,7 +728,7 @@ export class ReviewCommentController // #endregion private async optimisticallyAddComment(thread: GHPRCommentThread, input: string, inDraft: boolean): Promise<number> { - const currentUser = await this._reposManager.getCurrentUser(); + const currentUser = await this._folderRepoManager.getCurrentUser(); const comment = new TemporaryComment(thread, input, inDraft, currentUser); this.updateCommentThreadComments(thread, [...thread.comments, comment]); return comment.id; @@ -634,7 +740,7 @@ export class ReviewCommentController } private async optimisticallyEditComment(thread: GHPRCommentThread, comment: GHPRComment): Promise<number> { - const currentUser = await this._reposManager.getCurrentUser(); + const currentUser = await this._folderRepoManager.getCurrentUser(); const temporaryComment = new TemporaryComment( thread, comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, @@ -660,7 +766,7 @@ export class ReviewCommentController isSingleComment: boolean, inDraft?: boolean, ): Promise<void> { - if (!this._reposManager.activePullRequest) { + if (!this._folderRepoManager.activePullRequest) { throw new Error('Cannot create comment without an active pull request.'); } @@ -669,39 +775,46 @@ export class ReviewCommentController ? false : inDraft !== undefined ? inDraft - : this._reposManager.activePullRequest.hasPendingReview; + : this._folderRepoManager.activePullRequest.hasPendingReview; const temporaryCommentId = await this.optimisticallyAddComment(thread, input, isDraft); try { if (!hasExistingComments) { - const fileName = this.gitRelativeRootPath(thread.uri.path); + const fileName = this._folderRepoManager.gitRelativeRootPath(thread.uri.path); this._pendingCommentThreadAdds.push(thread); const side = this.getCommentSide(thread); // If the thread is on the workspace file, make sure the position // is properly adjusted to account for any local changes. - let startLine: number; - let endLine: number; - if (side === DiffSide.RIGHT) { - const diff = await this.getContentDiff(thread.uri, fileName); - startLine = mapNewPositionToOld(diff, thread.range.start.line); - endLine = mapNewPositionToOld(diff, thread.range.end.line); - } else { - startLine = thread.range.start.line; - endLine = thread.range.end.line; + let startLine: number | undefined = undefined; + let endLine: number | undefined = undefined; + if (thread.range) { + if (side === DiffSide.RIGHT) { + const diff = await this.getContentDiff(thread.uri, fileName); + startLine = mapNewPositionToOld(diff, thread.range.start.line); + endLine = mapNewPositionToOld(diff, thread.range.end.line); + } else { + startLine = thread.range.start.line; + endLine = thread.range.end.line; + } + startLine++; + endLine++; } - await this._reposManager.activePullRequest.createReviewThread( - input, - fileName, - startLine + 1, - endLine + 1, - side, - isSingleComment, - ); + await Promise.all([ + this._folderRepoManager.activePullRequest.createReviewThread( + input, + fileName, + startLine, + endLine, + side, + isSingleComment, + ), + setReplyAuthor(thread, await this._folderRepoManager.getCurrentUser(this._folderRepoManager.activePullRequest.githubRepository), this._context) + ]); } else { const comment = thread.comments[0]; if (comment instanceof GHPRComment) { - await this._reposManager.activePullRequest.createCommentReply( + await this._folderRepoManager.activePullRequest.createCommentReply( input, comment.rawComment.graphNodeId, isSingleComment, @@ -712,7 +825,7 @@ export class ReviewCommentController } if (isSingleComment) { - await this._reposManager.activePullRequest.submitReview(); + await this._folderRepoManager.activePullRequest.submitReview(); } } catch (e) { if (e.graphQLErrors?.length && e.graphQLErrors[0].type === 'NOT_FOUND') { @@ -735,11 +848,31 @@ export class ReviewCommentController } } + async createSuggestionsFromChanges(file: vscode.Uri, suggestionInformation: SuggestionInformation): Promise<void> { + const activePr = this._folderRepoManager.activePullRequest; + if (!activePr) { + return; + } + + const path = this._folderRepoManager.gitRelativeRootPath(file.path); + const body = `\`\`\`suggestion +${suggestionInformation.suggestionContent} +\`\`\``; + await activePr.createReviewThread( + body, + path, + suggestionInformation.originalStartLine, + suggestionInformation.originalStartLine + suggestionInformation.originalLineLength - 1, + DiffSide.RIGHT, + false, + ); + } + private async createCommentOnResolve(thread: GHPRCommentThread, input: string): Promise<void> { - if (!this._reposManager.activePullRequest) { + if (!this._folderRepoManager.activePullRequest) { throw new Error('Cannot create comment on resolve without an active pull request.'); } - const pendingReviewId = await this._reposManager.activePullRequest.getPendingReviewId(); + const pendingReviewId = await this._folderRepoManager.activePullRequest.getPendingReviewId(); await this.createOrReplyComment(thread, input, !pendingReviewId); } @@ -749,7 +882,7 @@ export class ReviewCommentController await this.createCommentOnResolve(thread, input); } - await this._reposManager.activePullRequest!.resolveReviewThread(thread.gitHubThreadId); + await this._folderRepoManager.activePullRequest!.resolveReviewThread(thread.gitHubThreadId); } catch (e) { vscode.window.showErrorMessage(`Resolving conversation failed: ${e}`); } @@ -761,21 +894,21 @@ export class ReviewCommentController await this.createCommentOnResolve(thread, input); } - await this._reposManager.activePullRequest!.unresolveReviewThread(thread.gitHubThreadId); + await this._folderRepoManager.activePullRequest!.unresolveReviewThread(thread.gitHubThreadId); } catch (e) { vscode.window.showErrorMessage(`Unresolving conversation failed: ${e}`); } } - async editComment(thread: GHPRCommentThread, comment: GHPRComment | TemporaryComment): Promise<void> { + async editComment(thread: GHPRCommentThread, comment: GHPRComment): Promise<void> { if (comment instanceof GHPRComment) { const temporaryCommentId = await this.optimisticallyEditComment(thread, comment); try { - if (!this._reposManager.activePullRequest) { + if (!this._folderRepoManager.activePullRequest) { throw new Error('Unable to find active pull request'); } - await this._reposManager.activePullRequest.editReviewComment( + await this._folderRepoManager.activePullRequest.editReviewComment( comment.rawComment, comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, ); @@ -784,29 +917,23 @@ export class ReviewCommentController thread.comments = thread.comments.map(c => { if (c instanceof TemporaryComment && c.id === temporaryCommentId) { - return new GHPRComment(comment.rawComment, thread); + return new GHPRComment(this._context, comment.rawComment, thread); } return c; }); } - } else { - this.createOrReplyComment( - thread, - comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, - false, - ); } } async deleteComment(thread: GHPRCommentThread, comment: GHPRComment | TemporaryComment): Promise<void> { try { - if (!this._reposManager.activePullRequest) { + if (!this._folderRepoManager.activePullRequest) { throw new Error('Unable to find active pull request'); } if (comment instanceof GHPRComment) { - await this._reposManager.activePullRequest.deleteReviewComment(comment.commentId); + await this._folderRepoManager.activePullRequest.deleteReviewComment(comment.commentId); } else { thread.comments = thread.comments.filter(c => !(c instanceof TemporaryComment && c.id === comment.id)); } @@ -817,9 +944,9 @@ export class ReviewCommentController updateCommentThreadLabel(thread); } - const inDraftMode = await this._reposManager.activePullRequest.validateDraftMode(); - if (inDraftMode !== this._reposManager.activePullRequest.hasPendingReview) { - this._reposManager.activePullRequest.hasPendingReview = inDraftMode; + const inDraftMode = await this._folderRepoManager.activePullRequest.validateDraftMode(); + if (inDraftMode !== this._folderRepoManager.activePullRequest.hasPendingReview) { + this._folderRepoManager.activePullRequest.hasPendingReview = inDraftMode; } this.update(); @@ -832,14 +959,14 @@ export class ReviewCommentController // #region Incremental update comments public async update(): Promise<void> { - await this._reposManager.activePullRequest!.validateDraftMode(); + await this._folderRepoManager.activePullRequest!.validateDraftMode(); } // #endregion // #region Reactions async toggleReaction(comment: GHPRComment, reaction: vscode.CommentReaction): Promise<void> { try { - if (!this._reposManager.activePullRequest) { + if (!this._folderRepoManager.activePullRequest) { throw new Error('Unable to find active pull request'); } @@ -847,40 +974,48 @@ export class ReviewCommentController comment.reactions && !comment.reactions.find(ret => ret.label === reaction.label && !!ret.authorHasReacted) ) { - await this._reposManager.activePullRequest.addCommentReaction( + await this._folderRepoManager.activePullRequest.addCommentReaction( comment.rawComment.graphNodeId, reaction, ); } else { - await this._reposManager.activePullRequest.deleteCommentReaction( + await this._folderRepoManager.activePullRequest.deleteCommentReaction( comment.rawComment.graphNodeId, reaction, ); } } catch (e) { - throw new Error(formatError(e)); + // Ignore permission errors when removing reactions due to race conditions + // See: https://github.com/microsoft/vscode/issues/69321 + const errorMessage = formatError(e); + if (errorMessage.includes('does not have the correct permissions to execute `RemoveReaction`')) { + // Silently ignore this error - it occurs when quickly toggling reactions + return; + } + throw new Error(errorMessage); } } // #endregion async applySuggestion(comment: GHPRComment) { + const range = comment.parent.range; const suggestion = comment.suggestion; - if (!suggestion) { + if ((suggestion === undefined) || !range) { throw new Error('Comment doesn\'t contain a suggestion'); } - const range = comment.parent.range; + const editor = vscode.window.visibleTextEditors.find(editor => comment.parent.uri.toString() === editor.document.uri.toString()); if (!editor) { throw new Error('Cannot find the editor to apply the suggestion to.'); } await editor.edit(builder => { - builder.replace(range.with(undefined, new vscode.Position(range.end.line + 1, 0)), suggestion); + builder.replace(range.with(undefined, editor.document.lineAt(range.end.line).range.end), suggestion); }); } - public dispose() { + public override dispose() { + super.dispose(); unregisterCommentHandler(this._commentHandlerId); - this._localToDispose.forEach(d => d.dispose()); } } diff --git a/src/view/reviewManager.ts b/src/view/reviewManager.ts index 67a3259a42..4d8b62e040 100644 --- a/src/view/reviewManager.ts +++ b/src/view/reviewManager.ts @@ -5,46 +5,59 @@ import * as nodePath from 'path'; import * as vscode from 'vscode'; -import type { Branch, Repository } from '../api/api'; -import { GitErrorCodes } from '../api/api1'; +import type { Branch, Change, Repository } from '../api/api'; +import { GitApiImpl, GitErrorCodes, Status } from '../api/api1'; import { openDescription } from '../commands'; -import { DiffChangeType } from '../common/diffHunk'; +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'; +import { Disposable, disposeAll, toDisposable } from '../common/lifecycle'; import Logger from '../common/logger'; import { parseRepositoryRemotes, Remote } from '../common/remote'; -import { FOCUSED_MODE, IGNORE_PR_BRANCHES, POST_CREATE, PR_SETTINGS_NAMESPACE, QUICK_DIFF, USE_REVIEW_MODE } from '../common/settingKeys'; +import { + COMMENTS, + FOCUSED_MODE, + IGNORE_PR_BRANCHES, + NEVER_IGNORE_DEFAULT_BRANCH, + OPEN_VIEW, + POST_CREATE, + PR_SETTINGS_NAMESPACE, + PULL_PR_BRANCH_BEFORE_CHECKOUT, + PullPRBranchVariants, + QUICK_DIFF, +} from '../common/settingKeys'; +import { getReviewMode } from '../common/settingsUtils'; import { ITelemetry } from '../common/telemetry'; import { fromPRUri, fromReviewUri, KnownMediaExtensions, PRUriParams, Schemes, toReviewUri } from '../common/uri'; import { formatError, groupBy, onceEvent } from '../common/utils'; import { FOCUS_REVIEW_MODE } from '../constants'; import { GitHubCreatePullRequestLinkProvider } from '../github/createPRLinkProvider'; -import { FolderRepositoryManager, SETTINGS_NAMESPACE } from '../github/folderRepositoryManager'; -import { GitHubRepository, ViewerPermission } from '../github/githubRepository'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +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 } from './fileChangeModel'; -import { getGitHubFileContent } from './gitHubContentProvider'; -import { PullRequestChangesTreeDataProvider } from './prChangesTreeDataProvider'; -import { ProgressHelper } from './progress'; -import { RemoteQuickPickItem } from './quickpick'; -import { ReviewCommentController } from './reviewCommentController'; -import { ReviewModel } from './reviewModel'; import { GitFileChangeNode, gitFileChangeNodeFilter, RemoteFileChangeNode } from './treeNodes/fileChangeNode'; import { WebviewViewCoordinator } from './webviewViewCoordinator'; -export class ReviewManager { +export class ReviewManager extends Disposable { public static ID = 'Review'; - private _localToDispose: vscode.Disposable[] = []; - private _disposables: vscode.Disposable[]; + private readonly _localToDispose: vscode.Disposable[] = []; - private _reviewModel: ReviewModel = new ReviewModel(); + private readonly _reviewModel: ReviewModel = new ReviewModel(); private _lastCommitSha?: string; - private _updateMessageShown: boolean = false; private _validateStatusInProgress?: Promise<void>; private _reviewCommentController: ReviewCommentController | undefined; + private _quickDiffProvider: vscode.Disposable | undefined; + private _inMemGitHubContentProvider: vscode.Disposable | undefined; private _statusBarItem: vscode.StatusBarItem; private _prNumber?: number; @@ -54,8 +67,6 @@ export class ReviewManager { remotes: Remote[]; }; - private _createPullRequestHelper: CreatePullRequestHelper | undefined; - private _switchingToReviewMode: boolean; private _changesSinceLastReviewProgress: ProgressHelper = new ProgressHelper(); /** @@ -64,6 +75,16 @@ export class ReviewManager { * 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; + /** + * Track whether this repository is currently selected in the UI. + * Used to show/hide the status bar item based on repository selection. + */ + private _isRepositorySelected!: boolean; public get switchingToReviewMode(): boolean { return this._switchingToReviewMode; @@ -72,118 +93,152 @@ export class ReviewManager { public set switchingToReviewMode(newState: boolean) { this._switchingToReviewMode = newState; if (!newState) { - this.updateState(); + this.updateState(true); } } private _isFirstLoad = true; constructor( + private _id: number, private _context: vscode.ExtensionContext, private readonly _repository: Repository, private _folderRepoManager: FolderRepositoryManager, private _telemetry: ITelemetry, public changesInPrDataProvider: PullRequestChangesTreeDataProvider, + private _pullRequestsTree: PullRequestsTreeDataProvider, private _showPullRequest: ShowPullRequest, - private readonly _activePrViewCoordinator: WebviewViewCoordinator + private readonly _activePrViewCoordinator: WebviewViewCoordinator, + private _createPullRequestHelper: CreatePullRequestHelper, + private _gitApi: GitApiImpl ) { + super(); this._switchingToReviewMode = false; - this._disposables = []; + this._isRepositorySelected = _repository.ui.selected; this._previousRepositoryState = { HEAD: _repository.state.HEAD, remotes: parseRepositoryRemotes(this._repository), }; + this._register(toDisposable(() => disposeAll(this._localToDispose))); this.registerListeners(); - this.updateState(true); - this.pollForStatusChange(); - this.registerQuickDiff(); + if (_gitApi.state === 'initialized') { + this.updateState(true); + } } private registerListeners(): void { - this._disposables.push( - this._repository.state.onDidChange(_ => { - const oldHead = this._previousRepositoryState.HEAD; - const newHead = this._repository.state.HEAD; - - if (!oldHead && !newHead) { - // both oldHead and newHead are undefined - return; - } + this._register(this._repository.state.onDidChange(_ => { + const oldHead = this._previousRepositoryState.HEAD; + const newHead = this._repository.state.HEAD; - let sameUpstream; + if (!oldHead && !newHead) { + // both oldHead and newHead are undefined + return; + } - if (!oldHead || !newHead) { - sameUpstream = false; - } else { - sameUpstream = !!oldHead.upstream - ? newHead.upstream && - oldHead.upstream.name === newHead.upstream.name && - oldHead.upstream.remote === newHead.upstream.remote - : !newHead.upstream; - } + let sameUpstream: boolean | undefined; - const sameHead = - sameUpstream && // falsy if oldHead or newHead is undefined. - oldHead!.ahead === newHead!.ahead && - oldHead!.behind === newHead!.behind && - oldHead!.commit === newHead!.commit && - oldHead!.name === newHead!.name && - oldHead!.remote === newHead!.remote && - oldHead!.type === newHead!.type; - - const remotes = parseRepositoryRemotes(this._repository); - const sameRemotes = - this._previousRepositoryState.remotes.length === remotes.length && - this._previousRepositoryState.remotes.every(remote => remotes.some(r => remote.equals(r))); - - if (!sameHead || !sameRemotes) { - this._previousRepositoryState = { - HEAD: this._repository.state.HEAD, - remotes: remotes, - }; - - // The first time this event occurs we do want to do visible updates. - // The first time, oldHead will be undefined. - // For subsequent changes, we don't want to make visible updates. - // This occurs on branch changes. - // Note that the visible changes will occur when checking out a PR. - this.updateState(true); - } - }), - ); + if (!oldHead || !newHead) { + sameUpstream = false; + } else { + sameUpstream = !!oldHead.upstream + ? newHead.upstream && + oldHead.upstream.name === newHead.upstream.name && + oldHead.upstream.remote === newHead.upstream.remote + : !newHead.upstream; + } - this._disposables.push( - vscode.workspace.onDidChangeConfiguration(e => { - this.updateFocusedViewMode(); - if (e.affectsConfiguration(`${SETTINGS_NAMESPACE}.${IGNORE_PR_BRANCHES}`)) { - this.validateState(true, false); - } - }), - ); + const sameHead = + sameUpstream && // falsy if oldHead or newHead is undefined. + oldHead!.ahead === newHead!.ahead && + oldHead!.behind === newHead!.behind && + oldHead!.commit === newHead!.commit && + oldHead!.name === newHead!.name && + oldHead!.remote === newHead!.remote && + oldHead!.type === newHead!.type; + + const remotes = parseRepositoryRemotes(this._repository); + const sameRemotes = + this._previousRepositoryState.remotes.length === remotes.length && + this._previousRepositoryState.remotes.every(remote => remotes.some(r => remote.equals(r))); + + if (!sameHead || !sameRemotes) { + this._previousRepositoryState = { + HEAD: this._repository.state.HEAD, + remotes: remotes, + }; + + // The first time this event occurs we do want to do visible updates. + // The first time, oldHead will be undefined. + // For subsequent changes, we don't want to make visible updates. + // This occurs on branch changes. + // Note that the visible changes will occur when checking out a PR. + this.updateState(true); + } + + if (oldHead && newHead) { + this.updateBaseBranchMetadata(oldHead, newHead); + } + })); + + this._register(vscode.workspace.onDidChangeConfiguration(e => { + this.updateFocusedViewMode(); + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${IGNORE_PR_BRANCHES}`)) { + this.validateStateAndResetPromise(true, false); + } + })); - this._disposables.push(this._folderRepoManager.onDidChangeActivePullRequest(_ => { + this._register(this._folderRepoManager.onDidChangeActivePullRequest(_ => { this.updateFocusedViewMode(); + this.registerQuickDiff(); })); - GitHubCreatePullRequestLinkProvider.registerProvider(this._disposables, this, this._folderRepoManager); + this._register(this._repository.ui.onDidChange(() => { + this._isRepositorySelected = this._repository.ui.selected; + this.updateStatusBarVisibility(); + })); + + this._register(GitHubCreatePullRequestLinkProvider.registerProvider(this, this._folderRepoManager)); + } + + private async updateBaseBranchMetadata(oldHead: Branch, newHead: Branch) { + if (!oldHead.commit || (oldHead.commit !== newHead.commit) || !newHead.name || !oldHead.name || (oldHead.name === newHead.name)) { + return; + } + + let githubRepository = this._folderRepoManager.gitHubRepositories.find(repo => repo.remote.remoteName === oldHead.upstream?.remote); + if (githubRepository) { + const metadata = await githubRepository.getMetadata(); + if (metadata.fork && oldHead.name === metadata.default_branch) { + // For forks, we use the upstream repo if it's available. Otherwise, fallback to the fork. + githubRepository = this._folderRepoManager.gitHubRepositories.find(repo => repo.remote.owner === metadata.parent?.owner?.login && repo.remote.repositoryName === metadata.parent?.name) ?? githubRepository; + } + return PullRequestGitHelper.associateBaseBranchWithBranch(this.repository, newHead.name, { owner: githubRepository.remote.owner, repo: githubRepository.remote.repositoryName, branch: oldHead.name }); + } } private registerQuickDiff() { - if (vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get<boolean>(QUICK_DIFF)) { - this._disposables.push(vscode.window.registerQuickDiffProvider({ scheme: 'file' }, { + if (vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<boolean>(QUICK_DIFF)) { + if (this._quickDiffProvider) { + this._quickDiffProvider.dispose(); + this._quickDiffProvider = undefined; + } + const label = this._folderRepoManager.activePullRequest ? vscode.l10n.t('GitHub pull request #{0}', this._folderRepoManager.activePullRequest.number) : vscode.l10n.t('GitHub pull request'); + this._register(this._quickDiffProvider = vscode.window.registerQuickDiffProvider({ scheme: 'file' }, { provideOriginalResource: (uri: vscode.Uri) => { const changeNode = this.reviewModel.localFileChanges.find(changeNode => changeNode.changeModel.filePath.toString() === uri.toString()); if (changeNode) { return changeNode.changeModel.parentFilePath; } } - }, 'GitHub Pull Request', this.repository.rootUri)); + }, 'github-pr', label, this.repository.rootUri)); } } + get statusBarItem() { if (!this._statusBarItem) { this._statusBarItem = vscode.window.createStatusBarItem('github.pullrequest.status', vscode.StatusBarAlignment.Left); @@ -193,6 +248,30 @@ export class ReviewManager { return this._statusBarItem; } + /** + * Updates the status bar visibility based on whether this repository is selected. + * If there's an active PR (or switching to review mode) and the repository is selected, show the status bar. + * Otherwise, hide it. + */ + private updateStatusBarVisibility() { + if (this._statusBarItem) { + if (this._isRepositorySelected && (this._folderRepoManager.activePullRequest || this._switchingToReviewMode)) { + this._statusBarItem.show(); + } else { + this._statusBarItem.hide(); + } + } + } + + /** + * Shows the status bar item only if this repository is currently selected. + */ + private showStatusBarIfSelected() { + if (this._isRepositorySelected) { + this.statusBarItem.show(); + } + } + get repository(): Repository { return this._repository; } @@ -201,13 +280,8 @@ export class ReviewManager { return this._reviewModel; } - private pollForStatusChange() { - setTimeout(async () => { - if (!this._validateStatusInProgress) { - await this.updateComments(); - } - this.pollForStatusChange(); - }, 1000 * 60 * 5); + private get id(): string { + return `${ReviewManager.ID}+${this._id}`; } public async updateState(silent: boolean = false, updateLayout: boolean = true) { @@ -215,22 +289,52 @@ export class ReviewManager { return; } if (!this._validateStatusInProgress) { - Logger.appendLine('Validate state in progress', ReviewManager.ID); - this._validateStatusInProgress = this.validateStatueAndSetContext(silent, updateLayout); + Logger.appendLine('Validate state in progress', this.id); + this._validateStatusInProgress = this.validateStatusAndSetContext(silent, updateLayout); return this._validateStatusInProgress; } else { - Logger.appendLine('Queuing additional validate state', ReviewManager.ID); + Logger.appendLine('Queuing additional validate state', this.id); this._validateStatusInProgress = this._validateStatusInProgress.then(async _ => { - return await this.validateStatueAndSetContext(silent, updateLayout); + return await this.validateStatusAndSetContext(silent, updateLayout); }); return this._validateStatusInProgress; } } - private async validateStatueAndSetContext(silent: boolean, updateLayout: boolean) { - await this.validateState(silent, updateLayout); - await vscode.commands.executeCommand('setContext', 'github:stateValidated', true); + private async validateStatusAndSetContext(silent: boolean, updateLayout: boolean) { + // Network errors can cause one of the GitHub API calls in validateState to never return. + let timeout: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise<void>(resolve => { + timeout = setTimeout(() => { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + Logger.error('Timeout occurred while validating state.', this.id); + /* __GDPR__ + "pr.checkout" : { + "version" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" } + } + */ + this._telemetry.sendTelemetryErrorEvent('pr.validateStateTimeout', { version: this._context.extension.packageJSON.version }); + } + resolve(); + }, 1000 * 60 * 2); + }); + + const validatePromise = new Promise<void>(resolve => { + this.validateStateAndResetPromise(silent, updateLayout).then(() => { + vscode.commands.executeCommand('setContext', 'github:stateValidated', true).then(() => { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + resolve(); + }); + }); + }); + + return Promise.race([validatePromise, timeoutPromise]); } private async offerIgnoreBranch(currentBranchName): Promise<boolean> { @@ -258,8 +362,8 @@ export class ReviewManager { ignore, dontShow); if (offerResult === ignore) { - Logger.appendLine(`Branch ${currentBranchName} will now be ignored in ${IGNORE_PR_BRANCHES}.`, ReviewManager.ID); - const settingNamespace = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE); + Logger.appendLine(`Branch ${currentBranchName} will now be ignored in ${IGNORE_PR_BRANCHES}.`, this.id); + const settingNamespace = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE); const setting = settingNamespace.get<string[]>(IGNORE_PR_BRANCHES, []); setting.push(currentBranchName); await settingNamespace.update(IGNORE_PR_BRANCHES, setting); @@ -271,40 +375,85 @@ export class ReviewManager { return false; } - private async checkGitHubForPrBranch(branchName: string): Promise<(PullRequestMetadata & { model: PullRequestModel }) | undefined> { - Logger.appendLine(`Review> no matching pull request metadata found for current branch ${branchName}`); - const metadataFromGithub = await this._folderRepoManager.getMatchingPullRequestMetadataFromGitHub(this.repository.state.HEAD?.upstream?.remote, this._repository.state.HEAD?.upstream?.name); - if (metadataFromGithub) { - await PullRequestGitHelper.associateBranchWithPullRequest( - this._repository, - metadataFromGithub.model, - branchName, - ); - return metadataFromGithub; + private async getUpstreamUrlAndName(branch: Branch): Promise<{ remoteUrl: string | undefined, upstreamBranchName: string | undefined, remoteName: string | undefined }> { + if (branch.upstream) { + Logger.debug(`Upstream for branch ${branch.name} is ${branch.upstream.remote}/${branch.upstream.name}`, this.id); + return { remoteName: branch.upstream.remote, upstreamBranchName: branch.upstream.name, remoteUrl: undefined }; + } else { + try { + const remoteUrl = await this.repository.getConfig(`branch.${branch.name}.remote`); + const upstreamBranch = await this.repository.getConfig(`branch.${branch.name}.merge`); + let upstreamBranchName: string | undefined; + if (upstreamBranch) { + upstreamBranchName = upstreamBranch.substring('refs/heads/'.length); + } + Logger.debug(`Upstream for branch ${branch.name} is ${upstreamBranchName} at ${remoteUrl}`, this.id); + return { remoteUrl: remoteUrl, upstreamBranchName, remoteName: undefined }; + } catch (e) { + Logger.appendLine(`Failed to get upstream for branch ${branch.name} from git config.`, this.id); + return { remoteUrl: undefined, upstreamBranchName: undefined, remoteName: undefined }; + } } } - private async resolvePullRequest(metadata: PullRequestMetadata): Promise<(PullRequestModel & IResolvedPullRequestModel) | undefined> { - this._prNumber = metadata.prNumber; + private async checkGitHubForPrBranch(branch: Branch): Promise<(PullRequestMetadata & { model: PullRequestModel }) | undefined> { + try { + let branchToCheck: Branch; + if (this._repository.state.HEAD && (branch.name === this._repository.state.HEAD.name)) { + branchToCheck = this._repository.state.HEAD; + } else { + branchToCheck = branch; + } + const { remoteUrl: url, upstreamBranchName, remoteName } = await this.getUpstreamUrlAndName(branchToCheck); + const metadataFromGithub = await this._folderRepoManager.getMatchingPullRequestMetadataFromGitHub(branchToCheck, remoteName, url, upstreamBranchName); + if (metadataFromGithub) { + Logger.appendLine(`Found matching pull request metadata on GitHub for current branch ${branch.name}. Repo: ${metadataFromGithub.owner}/${metadataFromGithub.repositoryName} PR: ${metadataFromGithub.prNumber}`, this.id); + await PullRequestGitHelper.associateBranchWithPullRequest( + this._repository, + metadataFromGithub.model, + branch.name!, + ); + return metadataFromGithub; + } + } catch (e) { + Logger.warn(`Failed to check GitHub for PR branch: ${e.message}`, this.id); + return undefined; + } + } - const { owner, repositoryName } = metadata; - Logger.appendLine('Review> Resolving pull request'); - const pr = await this._folderRepoManager.resolvePullRequest(owner, repositoryName, metadata.prNumber); + private async resolvePullRequest(metadata: PullRequestMetadata, useCache: boolean): Promise<(PullRequestModel & IResolvedPullRequestModel) | undefined> { + try { + this._prNumber = metadata.prNumber; - if (!pr || !pr.isResolved()) { - await this.clear(true); - this._prNumber = undefined; - Logger.appendLine('Review> This PR is no longer valid'); - return; + const { owner, repositoryName } = metadata; + Logger.appendLine('Resolving pull request', this.id); + 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); + this._prNumber = undefined; + Logger.appendLine('This PR is no longer valid', this.id); + return; + } + return pr; + } catch (e) { + Logger.appendLine(`Pull request cannot be resolved: ${e.message}`, this.id); } - return pr; + } + + private async validateStateAndResetPromise(silent: boolean, updateLayout: boolean): Promise<void> { + return this.validateState(silent, updateLayout).then(() => { + this._validateStatusInProgress = undefined; + }); } private async validateState(silent: boolean, updateLayout: boolean) { - Logger.appendLine('Validating state...', ReviewManager.ID); + Logger.appendLine('Validating state...', this.id); const oldLastCommitSha = this._lastCommitSha; this._lastCommitSha = undefined; - await this._folderRepoManager.updateRepositories(false); + if (!(await this._folderRepoManager.updateRepositories(false))) { + return; + } if (!this._repository.state.HEAD) { await this.clear(true); @@ -312,9 +461,10 @@ export class ReviewManager { } const branch = this._repository.state.HEAD; - const ignoreBranches = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get<string[]>(IGNORE_PR_BRANCHES); - if (ignoreBranches?.find(value => value === branch.name)) { - Logger.appendLine(`Branch ${branch.name} is ignored in ${IGNORE_PR_BRANCHES}.`, ReviewManager.ID); + const ignoreBranches = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<string[]>(IGNORE_PR_BRANCHES); + const remoteName = branch.remote ?? branch.upstream?.remote; + if (ignoreBranches?.find(value => value === branch.name) && ((remoteName === 'origin') || !(await this._folderRepoManager.gitHubRepositories.find(repo => repo.remote.remoteName === remoteName)?.getMetadata())?.fork)) { + Logger.appendLine(`Branch ${branch.name} is ignored in ${IGNORE_PR_BRANCHES}.`, this.id); await this.clear(true); return; } @@ -322,39 +472,38 @@ export class ReviewManager { let matchingPullRequestMetadata = await this._folderRepoManager.getMatchingPullRequestMetadataForBranch(); if (!matchingPullRequestMetadata) { - matchingPullRequestMetadata = await this.checkGitHubForPrBranch(branch.name!); + Logger.appendLine(`No matching pull request metadata found locally for current branch ${branch.name}`, this.id); + matchingPullRequestMetadata = await this.checkGitHubForPrBranch(branch); } if (!matchingPullRequestMetadata) { Logger.appendLine( - `No matching pull request metadata found on GitHub for current branch ${branch.name}`, ReviewManager.ID + `No matching pull request metadata found on GitHub for current branch ${branch.name}`, this.id ); await this.clear(true); return; } - - const remote = branch.upstream ? branch.upstream.remote : null; - if (!remote) { - Logger.appendLine(`Current branch ${this._repository.state.HEAD.name} hasn't setup remote yet`, ReviewManager.ID); - await this.clear(true); - return; - } + Logger.appendLine(`Found matching pull request metadata for current branch ${branch.name}. Repo: ${matchingPullRequestMetadata.owner}/${matchingPullRequestMetadata.repositoryName} PR: ${matchingPullRequestMetadata.prNumber}`, this.id); // we switch to another PR, let's clean up first. Logger.appendLine( - `current branch ${this._repository.state.HEAD.name} is associated with pull request #${matchingPullRequestMetadata.prNumber}`, ReviewManager.ID + `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; } + Logger.appendLine(`Resolved PR #${matchingPullRequestMetadata.prNumber}, state is ${pr.state}`, this.id); // Check if the PR is open, if not, check if there's another PR from the same branch on GitHub if (pr.state !== GithubItemStateEnum.Open) { - const metadataFromGithub = await this.checkGitHubForPrBranch(branch.name!); + 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; } @@ -362,30 +511,36 @@ export class ReviewManager { } const hasPushedChanges = branch.commit !== oldLastCommitSha && branch.ahead === 0 && branch.behind === 0; - if (previousPrNumber === pr.number && !hasPushedChanges && (this._isShowingLastReviewChanges === pr.showChangesSinceReview)) { - vscode.commands.executeCommand('pr.refreshList'); - this._validateStatusInProgress = undefined; + if (!this.justSwitchedToReviewMode && (previousPrNumber === pr.number) && !hasPushedChanges && (this._isShowingLastReviewChanges === pr.showChangesSinceReview)) { return; } this._isShowingLastReviewChanges = pr.showChangesSinceReview; + if (previousPrNumber !== pr.number) { + this.clear(false); + } - const useReviewConfiguration = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE) - .get<{ merged: boolean, closed: boolean }>(USE_REVIEW_MODE, { merged: true, closed: false }); + 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); - Logger.appendLine('This PR is closed', ReviewManager.ID); return; } - if (pr.isMerged && !useReviewConfiguration.merged) { + if (pr.isMerged && !useReviewConfiguration.merged && !isSwitchedToPullRequest) { + Logger.appendLine('This PR is merged', this.id); await this.clear(true); - Logger.appendLine('This PR is merged', ReviewManager.ID); return; } - // Do not await the result of offering to ignore the branch. - this.offerIgnoreBranch(branch.name); + const neverIgnoreDefaultBranch = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<boolean>(NEVER_IGNORE_DEFAULT_BRANCH, false); + if (!neverIgnoreDefaultBranch) { + // Do not await the result of offering to ignore the branch. + this.offerIgnoreBranch(branch.name); + } const previousActive = this._folderRepoManager.activePullRequest; this._folderRepoManager.activePullRequest = pr; @@ -396,7 +551,7 @@ export class ReviewManager { this._folderRepoManager.checkBranchUpToDate(pr, true); } - Logger.appendLine('Fetching pull request data', ReviewManager.ID); + Logger.appendLine('Fetching pull request data', this.id); if (!silent) { onceEvent(this._reviewModel.onDidChangeLocalFileChanges)(() => { if (pr) { @@ -413,9 +568,8 @@ export class ReviewManager { this.justSwitchedToReviewMode, this._changesSinceLastReviewProgress ); - this.justSwitchedToReviewMode = false; - Logger.appendLine(`Register comments provider`, ReviewManager.ID); + Logger.appendLine(`Register comments provider`, this.id); await this.registerCommentController(); this._activePrViewCoordinator.setPullRequest(pr, this._folderRepoManager, this, previousActive); @@ -425,9 +579,16 @@ export class ReviewManager { this.changesInPrDataProvider.refresh(); await this.updateComments(); await this.reopenNewReviewDiffs(); + if (pr) { + PullRequestModel.openChanges(this._folderRepoManager, pr); + } this._changesSinceLastReviewProgress.endProgress(); }) ); + Logger.appendLine(`Register in memory content provider`, this.id); + if (previousPrNumber !== pr.number) { + await this.registerGitHubInMemContentProvider(); + } this.statusBarItem.text = '$(git-pull-request) ' + vscode.l10n.t('Pull Request #{0}', pr.number); this.statusBarItem.command = { @@ -435,27 +596,25 @@ export class ReviewManager { title: vscode.l10n.t('View Pull Request Description'), arguments: [pr], }; - Logger.appendLine(`Display pull request status bar indicator and refresh pull request tree view.`, ReviewManager.ID); - this.statusBarItem.show(); - vscode.commands.executeCommand('pr.refreshList'); + Logger.appendLine(`Display pull request status bar indicator.`, this.id); + this.showStatusBarIfSelected(); - this.layout(pr, updateLayout, silent); - - this._validateStatusInProgress = undefined; + this.layout(pr, updateLayout, this.justSwitchedToReviewMode ? false : silent); + this.justSwitchedToReviewMode = false; } private layout(pr: PullRequestModel, updateLayout: boolean, silent: boolean) { - const isFocusMode = this._context.workspaceState.get(FOCUS_REVIEW_MODE); + const isFocusMode = this._context.workspaceState.get<boolean>(FOCUS_REVIEW_MODE); - Logger.appendLine(`Using focus mode = ${isFocusMode}.`, ReviewManager.ID); - Logger.appendLine(`State validation silent = ${silent}.`, ReviewManager.ID); - Logger.appendLine(`PR show should show = ${this._showPullRequest.shouldShow}.`, ReviewManager.ID); + Logger.appendLine(`Using focus mode = ${isFocusMode}.`, this.id); + Logger.appendLine(`State validation silent = ${silent}.`, this.id); + Logger.appendLine(`PR show should show = ${this._showPullRequest.shouldShow}.`, this.id); if ((!silent || this._showPullRequest.shouldShow) && isFocusMode) { this._doFocusShow(pr, updateLayout); } else if (!this._showPullRequest.shouldShow && isFocusMode) { const showPRChangedDisposable = this._showPullRequest.onChangedShowValue(shouldShow => { - Logger.appendLine(`PR show value changed = ${shouldShow}.`, ReviewManager.ID); + Logger.appendLine(`PR show value changed = ${shouldShow}.`, this.id); if (shouldShow) { this._doFocusShow(pr, updateLayout); } @@ -511,31 +670,67 @@ export class ReviewManager { } } + private _openFirstDiff() { + if (this._reviewModel.localFileChanges.length) { + this.openDiff(); + } else { + const localFileChangesDisposable = this._reviewModel.onDidChangeLocalFileChanges(() => { + localFileChangesDisposable.dispose(); + this.openDiff(); + }); + } + } + + private _doFocusShow(pr: PullRequestModel, updateLayout: boolean) { // Respect the setting 'comments.openView' when it's 'never'. - const shouldShowCommentsView = vscode.workspace.getConfiguration('comments').get<'never' | string>('openView'); - if (shouldShowCommentsView !== 'never') { + const shouldShowCommentsView = vscode.workspace.getConfiguration(COMMENTS).get<'never' | string>(OPEN_VIEW); + if ((shouldShowCommentsView !== 'never') && (pr.hasComments)) { commands.executeCommand('workbench.action.focusCommentsPanel'); } this._activePrViewCoordinator.show(pr); if (updateLayout) { - const focusedMode = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get<'firstDiff' | 'overview' | false>(FOCUSED_MODE); + const focusedMode = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'firstDiff' | 'overview' | 'multiDiff' | false>(FOCUSED_MODE); + if (focusedMode && pr.fileChanges.size === 0) { + // If there are no file changes, the only useful thing to show is the overview + return this.openDescription(); + } + if (focusedMode === 'firstDiff') { - if (this._reviewModel.localFileChanges.length) { - this.openDiff(); - } else { - const localFileChangesDisposable = this._reviewModel.onDidChangeLocalFileChanges(() => { - localFileChangesDisposable.dispose(); - this.openDiff(); - }); - } + return this._openFirstDiff(); } else if (focusedMode === 'overview') { return this.openDescription(); + } else if (focusedMode === 'multiDiff') { + if (pr.fileChanges.size < 400) { + return PullRequestModel.openChanges(this._folderRepoManager, pr); + } else { + return this._openFirstDiff(); + } + } + } + } + + private async _closeOutdatedMultiDiffEditors(pullRequest: PullRequestModel): Promise<void> { + // Close any multidiff editors for this PR that may be outdated + const multiDiffLabel = vscode.l10n.t('Changes in Pull Request #{0}', pullRequest.number); + + const closePromises: Promise<boolean>[] = []; + for (const tabGroup of vscode.window.tabGroups.all) { + for (const tab of tabGroup.tabs) { + // Check if this is a TabInputTextMultiDiff with matching label + if (tab.input instanceof vscode.TabInputTextMultiDiff && tab.label.startsWith(multiDiffLabel)) { + Logger.appendLine(`Closing outdated multidiff editor for PR #${pullRequest.number}`, this.id); + closePromises.push(Promise.resolve(vscode.window.tabGroups.close(tab))); + } } } + await Promise.all(closePromises); } public async _upgradePullRequestEditors(pullRequest: PullRequestModel) { + // Close any outdated multidiff editors first + await this._closeOutdatedMultiDiffEditors(pullRequest); + // Go through all open editors and find pr scheme editors that belong to the active pull request. // Close the editors, and reopen them from the pull request. const reopenFilenames: Set<[PRUriParams, PRUriParams]> = new Set(); @@ -572,6 +767,164 @@ export class ReviewManager { return Promise.all(reopenPromises); } + async createSuggestionFromChange(editor: vscode.TextDocument, diffLines: vscode.LineChange[]) { + const change = this._reviewModel.localFileChanges.find(change => change.changeModel.filePath.toString() === editor.uri.toString()); + + if (!change) { + return; + } + const suggestions: (SuggestionInformation & { diffLine: vscode.LineChange })[] = []; + for (const line of diffLines) { + const content = editor.getText(new vscode.Range(line.modifiedStartLineNumber - 1, 0, line.modifiedEndLineNumber - 1, editor.lineAt(line.modifiedEndLineNumber - 1).range.end.character)); + const originalEndLineNumber = line.originalEndLineNumber < line.originalStartLineNumber ? line.originalStartLineNumber : line.originalEndLineNumber; + suggestions.push({ + suggestionContent: content, + originalLineLength: originalEndLineNumber - line.originalStartLineNumber + 1, + originalStartLine: line.originalStartLineNumber, + diffLine: line + }); + } + await Promise.all(suggestions.map(async (suggestion) => { + try { + await this._reviewCommentController?.createSuggestionsFromChanges(editor.uri, suggestion); + await vscode.commands.executeCommand('git.revertSelectedRanges', suggestion.diffLine); + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('The change is outside the commenting range.'), { modal: true }); + } + })); + } + + private trimContextFromHunk(hunk: DiffHunk) { + let oldLineNumber = hunk.oldLineNumber; + let oldLength = hunk.oldLength; + + let i = 0; + for (; i < hunk.diffLines.length; i++) { + const line = hunk.diffLines[i]; + if (line.type === DiffChangeType.Control) { + continue; + } + if (line.type === DiffChangeType.Context) { + oldLineNumber++; + oldLength--; + } else { + break; + } + } + let j = hunk.diffLines.length - 1; + for (; j >= 0; j--) { + if (hunk.diffLines[j].type === DiffChangeType.Context) { + oldLength--; + } else { + break; + } + } + + let slice = hunk.diffLines.slice(i, j + 1); + + if (slice.every(line => line.type === DiffChangeType.Add)) { + // we have only inserted lines, so we need to include a context line so that + // there's a line to anchor the suggestion to + if (i > 1) { + // include from the beginning of the hunk + i--; + oldLineNumber--; + oldLength++; + } else if (j < hunk.diffLines.length - 1) { + // include from the end of the hunk + j++; + oldLength++; + } else { + // include entire context + i = 1; + j = hunk.diffLines.length - 1; + } + slice = hunk.diffLines.slice(i, j + 1); + } + + hunk.diffLines = slice; + hunk.oldLength = oldLength; + hunk.oldLineNumber = oldLineNumber; + } + + private convertDiffHunkToSuggestion(hunk: DiffHunk): SuggestionInformation { + this.trimContextFromHunk(hunk); + return { + suggestionContent: hunk.diffLines.filter(line => (line.type === DiffChangeType.Add) || (line.type == DiffChangeType.Context)).map(line => line.text).join('\n'), + originalLineLength: hunk.oldLength, + originalStartLine: hunk.oldLineNumber, + }; + } + + async createSuggestionsFromChanges(resources: vscode.Uri[]) { + const resourceStrings = resources.map(resource => resource.toString()); + let hasError: boolean = false; + let diffCount: number = 0; + const convertedFiles: vscode.Uri[] = []; + + const convertOneSmallHunk = async (changeFile: Change, hunk: DiffHunk) => { + try { + await this._reviewCommentController?.createSuggestionsFromChanges(changeFile.uri, this.convertDiffHunkToSuggestion(hunk)); + convertedFiles.push(changeFile.uri); + } catch (e) { + hasError = true; + } + }; + + const getDiffFromChange = async (changeFile: Change) => { + if (!resourceStrings.includes(changeFile.uri.toString()) || (changeFile.status !== Status.MODIFIED)) { + return; + } + return parsePatch(await this._folderRepoManager.repository.diffWithHEAD(changeFile.uri.fsPath)).map(hunk => splitIntoSmallerHunks(hunk)).flat(); + }; + + const convertAllChangesInFile = async (changeFile: Change, parallel: boolean) => { + const diff = await getDiffFromChange(changeFile); + if (diff) { + diffCount += diff.length; + if (parallel) { + await Promise.allSettled(diff.map(async hunk => { + return convertOneSmallHunk(changeFile, hunk); + })); + } else { + for (const hunk of diff) { + await convertOneSmallHunk(changeFile, hunk); + } + } + } + }; + + await vscode.window.withProgress({ location: vscode.ProgressLocation.Window, title: 'Converting changes to suggestions' }, async () => { + // We need to create one suggestion first. This let's us ensure that only one review will be created. + let i = 0; + for (; (convertedFiles.length === 0) && (i < this._folderRepoManager.repository.state.workingTreeChanges.length); i++) { + const changeFile = this._folderRepoManager.repository.state.workingTreeChanges[i]; + await convertAllChangesInFile(changeFile, false); + } + + // If we have already created a suggestion, we can create the rest in parallel + const promises: Promise<void>[] = []; + for (; i < this._folderRepoManager.repository.state.workingTreeChanges.length; i++) { + const changeFile = this._folderRepoManager.repository.state.workingTreeChanges[i]; + promises.push(convertAllChangesInFile(changeFile, true)); + } + + await Promise.all(promises); + }); + if (!hasError) { + const checkoutAllFilesResponse = vscode.l10n.t('Reset all changes'); + vscode.window.showInformationMessage(vscode.l10n.t('All changes have been converted to suggestions.'), { modal: true, detail: vscode.l10n.t('Do you want to reset your local changes?') }, checkoutAllFilesResponse).then((response) => { + if (response === checkoutAllFilesResponse) { + return Promise.all(convertedFiles.map(changeFile => this._folderRepoManager.repository.checkout(changeFile.fsPath))); + } + }); + } else if (convertedFiles.length) { + vscode.window.showWarningMessage(vscode.l10n.t('Not all changes could be converted to suggestions.'), { detail: vscode.l10n.t('{0} of {1} changes converted. Some of the changes may be outside of commenting ranges.\nYour changes are still available locally.', convertedFiles.length, diffCount), modal: true }); + } else { + vscode.window.showWarningMessage(vscode.l10n.t('No changes could be converted to suggestions.'), { detail: vscode.l10n.t('All of the changes are outside of commenting ranges.'), modal: true }); + } + } + public async updateComments(): Promise<void> { const branch = this._repository.state.HEAD; if (!branch) { @@ -599,7 +952,7 @@ export class ReviewManager { ); if (!pr || !pr.isResolved()) { - Logger.warn('This PR is no longer valid', ReviewManager.ID); + Logger.warn('This PR is no longer valid', this.id); return; } @@ -656,9 +1009,9 @@ export class ReviewManager { try { const contentChanges = await pr.getFileChangesInfo(); this._reviewModel.localFileChanges = await this.getLocalChangeNodes(pr, contentChanges); - await Promise.all([pr.initializeReviewComments(), pr.initializeReviewThreadCache(), pr.initializePullRequestFileViewState()]); + await Promise.all([pr.initializeReviewThreadCacheAndReviewComments(), pr.initializePullRequestFileViewState()]); this._folderRepoManager.setFileViewedContext(); - const outdatedComments = pr.comments.filter(comment => !comment.position); + const outdatedComments = pr.comments.filter(comment => comment.isOutdated || !comment.position); const commitsGroup = groupBy(outdatedComments, comment => comment.originalCommitId!); const obsoleteFileChanges: (GitFileChangeNode | RemoteFileChangeNode)[] = []; @@ -712,7 +1065,54 @@ export class ReviewManager { return Promise.resolve(void 0); } catch (e) { - Logger.error(`${e}`, ReviewManager.ID); + Logger.error(`Failed to initialize PR data ${e}: ${e.message}`, this.id); + } + } + + private async registerGitHubInMemContentProvider() { + try { + this._inMemGitHubContentProvider?.dispose(); + this._inMemGitHubContentProvider = undefined; + + const pr = this._folderRepoManager.activePullRequest; + if (!pr) { + return; + } + const rawChanges = await pr.getFileChangesInfo(); + const mergeBase = pr.mergeBase; + if (!mergeBase) { + return; + } + const changes = rawChanges.map(change => { + if (change instanceof SlimFileChange) { + return new RemoteFileChangeModel(this._folderRepoManager, change, pr); + } + return new InMemFileChangeModel(this._folderRepoManager, + pr as (PullRequestModel & IResolvedPullRequestModel), + change, true, mergeBase); + }); + + this._inMemGitHubContentProvider = getInMemPRFileSystemProvider()?.registerTextDocumentContentProvider( + pr.number, + async (uri: vscode.Uri): Promise<string | Uint8Array> => { + const params = fromPRUri(uri); + if (!params) { + return ''; + } + const fileChange = changes.find( + contentChange => contentChange.fileName === params.fileName, + ); + + if (!fileChange) { + Logger.error(`Cannot find content for document ${uri.toString()}`, 'PR'); + return ''; + } + + return provideDocumentContentForChangeModel(this._folderRepoManager, pr, params, fileChange); + }, + ); + } catch (e) { + Logger.error(`Failed to register in mem content provider: ${e}`, this.id); } } @@ -739,6 +1139,8 @@ export class ReviewManager { this._folderRepoManager, this._repository, this._reviewModel, + this._gitApi, + this._telemetry ); await this._reviewCommentController.initialize(); @@ -746,11 +1148,12 @@ export class ReviewManager { } public async switch(pr: PullRequestModel): Promise<void> { - Logger.appendLine(`Switch to Pull Request #${pr.number} - start`, ReviewManager.ID); + Logger.appendLine(`Switch to Pull Request #${pr.number} - start`, this.id); this.statusBarItem.text = vscode.l10n.t('{0} Switching to Review Mode', '$(sync~spin)'); this.statusBarItem.command = undefined; - this.statusBarItem.show(); + this.showStatusBarIfSelected(); this.switchingToReviewMode = true; + this._switchedToPullRequest = pr; try { await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification }, async (progress) => { @@ -760,8 +1163,13 @@ export class ReviewManager { await this._folderRepoManager.fetchAndCheckout(pr, progress); } }); + const updateBaseSetting = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<PullPRBranchVariants>(PULL_PR_BRANCH_BEFORE_CHECKOUT, 'pull'); + if (updateBaseSetting === 'pullAndMergeBase' || updateBaseSetting === 'pullAndUpdateBase') { + await this._folderRepoManager.tryMergeBaseIntoHead(pr, updateBaseSetting === 'pullAndUpdateBase'); + } + } catch (e) { - Logger.error(`Checkout failed #${JSON.stringify(e)}`, ReviewManager.ID); + Logger.error(`Checkout failed #${JSON.stringify(e)}`, this.id); this.switchingToReviewMode = false; if (e.message === 'User aborted') { @@ -778,14 +1186,13 @@ export class ReviewManager { // The pull request was checked out, but the upstream branch was deleted vscode.window.showInformationMessage('The remote branch for this pull request has been deleted. The file contents may not match the remote.'); } else { - vscode.window.showErrorMessage(formatError(e)); + vscode.window.showErrorMessage(`Error switching to pull request: ${formatError(e)}`); } // todo, we should try to recover, for example, git checkout succeeds but set config fails. - if (this._folderRepoManager.activePullRequest) { + if (this._folderRepoManager.activePullRequest?.number === pr.number) { this.setStatusForPr(this._folderRepoManager.activePullRequest); } else { this.statusBarItem.hide(); - this.switchingToReviewMode = false; } return; } @@ -793,15 +1200,16 @@ export class ReviewManager { try { this.statusBarItem.text = '$(sync~spin) ' + vscode.l10n.t('Fetching additional data: {0}', `pr/${pr.number}`); this.statusBarItem.command = undefined; - this.statusBarItem.show(); + this.showStatusBarIfSelected(); await this._folderRepoManager.fulfillPullRequestMissingInfo(pr); + this._upgradePullRequestEditors(pr); /* __GDPR__ "pr.checkout" : {} */ this._telemetry.sendTelemetryEvent('pr.checkout'); - Logger.appendLine(`Switch to Pull Request #${pr.number} - done`, ReviewManager.ID); + Logger.appendLine(`Switch to Pull Request #${pr.number} - done`, this.id); } finally { this.setStatusForPr(pr); await this._repository.status(); @@ -813,194 +1221,46 @@ export class ReviewManager { this.justSwitchedToReviewMode = true; this.statusBarItem.text = vscode.l10n.t('Pull Request #{0}', pr.number); this.statusBarItem.command = undefined; - this.statusBarItem.show(); + this.showStatusBarIfSelected(); } - public async publishBranch(branch: Branch): Promise<Branch | undefined> { - const potentialTargetRemotes = await this._folderRepoManager.getAllGitHubRemotes(); - let selectedRemote = (await this.getRemote( - potentialTargetRemotes, - vscode.l10n.t(`Pick a remote to publish the branch '{0}' to:`, branch.name!), - ))!.remote; - - if (!selectedRemote || branch.name === undefined) { - return; - } - - const githubRepo = await this._folderRepoManager.createGitHubRepository( - selectedRemote, - this._folderRepoManager.credentialStore, - ); - const permission = await githubRepo.getViewerPermission(); - if ( - permission === ViewerPermission.Read || - permission === ViewerPermission.Triage || - permission === ViewerPermission.Unknown - ) { - // No permission to publish the branch to the chosen remote. Offer to fork. - const fork = await this._folderRepoManager.tryOfferToFork(githubRepo); - if (!fork) { + public async createPullRequest(compareBranch?: string): Promise<void> { + const postCreate = async (createdPR: PullRequestModel | undefined) => { + if (!createdPR) { return; } - selectedRemote = (await this._folderRepoManager.getGitHubRemotes()).find(element => element.remoteName === fork); - } - if (!selectedRemote) { - return; - } - const remote: Remote = selectedRemote; - - return new Promise<Branch | undefined>(async resolve => { - const inputBox = vscode.window.createInputBox(); - inputBox.value = branch.name!; - inputBox.ignoreFocusOut = true; - inputBox.prompt = - potentialTargetRemotes.length === 1 - ? vscode.l10n.t(`The branch '{0}' is not published yet, pick a name for the upstream branch`, branch.name!) - : vscode.l10n.t('Pick a name for the upstream branch'); - const validate = async function (value: string) { - try { - inputBox.busy = true; - const remoteBranch = await this._reposManager.getBranch(remote, value); - if (remoteBranch) { - inputBox.validationMessage = vscode.l10n.t(`Branch '{0}' already exists in {1}`, value, `${remote.owner}/${remote.repositoryName}`); - } else { - inputBox.validationMessage = undefined; + const postCreate = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'none' | 'openOverview' | 'checkoutDefaultBranch' | 'checkoutDefaultBranchAndShow' | 'checkoutDefaultBranchAndCopy'>(POST_CREATE, 'openOverview'); + if (postCreate === 'openOverview') { + const descriptionNode = this.changesInPrDataProvider.getDescriptionNode(this._folderRepoManager); + await openDescription( + this._telemetry, + createdPR, + descriptionNode, + this._folderRepoManager, + true + ); + } else if (postCreate.startsWith('checkoutDefaultBranch')) { + const defaultBranch = await this._folderRepoManager.getPullRequestRepositoryDefaultBranch(createdPR); + if (defaultBranch) { + if (postCreate === 'checkoutDefaultBranch') { + await this._folderRepoManager.checkoutDefaultBranch(defaultBranch, undefined); + } if (postCreate === 'checkoutDefaultBranchAndShow') { + await commands.executeCommand('pr:github.focus'); + await this._folderRepoManager.checkoutDefaultBranch(defaultBranch, undefined); + await this._pullRequestsTree.expandPullRequest(createdPR); + } else if (postCreate === 'checkoutDefaultBranchAndCopy') { + await Promise.all([ + this._folderRepoManager.checkoutDefaultBranch(defaultBranch, undefined), + vscode.env.clipboard.writeText(createdPR.html_url) + ]); } - } catch (e) { - inputBox.validationMessage = undefined; - } - - inputBox.busy = false; - }; - await validate(branch.name!); - inputBox.onDidChangeValue(validate.bind(this)); - inputBox.onDidAccept(async () => { - inputBox.validationMessage = undefined; - inputBox.hide(); - try { - // since we are probably pushing a remote branch with a different name, we use the complete syntax - // git push -u origin local_branch:remote_branch - await this._repository.push(remote.remoteName, `${branch.name}:${inputBox.value}`, true); - } catch (err) { - if (err.gitErrorCode === GitErrorCodes.PushRejected) { - vscode.window.showWarningMessage( - vscode.l10n.t(`Can't push refs to remote, try running 'git pull' first to integrate with your change`), - { - modal: true, - }, - ); - - resolve(undefined); - } - - if (err.gitErrorCode === GitErrorCodes.RemoteConnectionError) { - vscode.window.showWarningMessage( - vscode.l10n.t(`Could not read from remote repository '{0}'. Please make sure you have the correct access rights and the repository exists.`, remote.remoteName), - { - modal: true, - }, - ); - - resolve(undefined); - } - - // we can't handle the error - throw err; - } - - // we don't want to wait for repository status update - const latestBranch = await this._repository.getBranch(branch.name!); - if (!latestBranch || !latestBranch.upstream) { - resolve(undefined); - } - - resolve(latestBranch); - }); - - inputBox.show(); - }); - } - - private async getRemote( - potentialTargetRemotes: Remote[], - placeHolder: string, - defaultUpstream?: RemoteQuickPickItem, - ): Promise<RemoteQuickPickItem | undefined> { - if (!potentialTargetRemotes.length) { - vscode.window.showWarningMessage(vscode.l10n.t(`No GitHub remotes found. Add a remote and try again.`)); - return; - } - - if (potentialTargetRemotes.length === 1 && !defaultUpstream) { - return RemoteQuickPickItem.fromRemote(potentialTargetRemotes[0]); - } - - if ( - potentialTargetRemotes.length === 1 && - defaultUpstream && - defaultUpstream.owner === potentialTargetRemotes[0].owner && - defaultUpstream.name === potentialTargetRemotes[0].repositoryName - ) { - return defaultUpstream; - } - - let defaultUpstreamWasARemote = false; - const picks: RemoteQuickPickItem[] = potentialTargetRemotes.map(remote => { - const remoteQuickPick = RemoteQuickPickItem.fromRemote(remote); - if (defaultUpstream) { - const { owner, name } = defaultUpstream; - remoteQuickPick.picked = remoteQuickPick.owner === owner && remoteQuickPick.name === name; - if (remoteQuickPick.picked) { - defaultUpstreamWasARemote = true; } } - return remoteQuickPick; - }); - if (!defaultUpstreamWasARemote && defaultUpstream) { - picks.unshift(defaultUpstream); - } - - const selected: RemoteQuickPickItem | undefined = await vscode.window.showQuickPick<RemoteQuickPickItem>( - picks, - { - ignoreFocusOut: true, - placeHolder: placeHolder, - }, - ); - - if (!selected) { - return; - } - - return selected; - } - - public async createPullRequest(compareBranch?: string): Promise<void> { - if (!this._createPullRequestHelper) { - this._createPullRequestHelper = new CreatePullRequestHelper(this.repository); - this._createPullRequestHelper.onDidCreate(async createdPR => { - const postCreate = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get<'none' | 'openOverview' | 'checkoutDefaultBranch'>(POST_CREATE, 'openOverview'); - if (postCreate === 'openOverview') { - const descriptionNode = this.changesInPrDataProvider.getDescriptionNode(this._folderRepoManager); - await openDescription( - this._context, - this._telemetry, - createdPR, - descriptionNode, - this._folderRepoManager, - ); - } else if (postCreate === 'checkoutDefaultBranch') { - const defaultBranch = await this._folderRepoManager.getPullRequestRepositoryDefaultBranch(createdPR); - if (defaultBranch) { - await this._folderRepoManager.checkoutDefaultBranch(defaultBranch); - } - } - await this.updateState(false, false); - }); - } + await this.updateState(false, false); + }; - this._createPullRequestHelper.create(this._context.extensionUri, this._folderRepoManager, compareBranch); + return this._createPullRequestHelper.create(this._telemetry, this._context.extensionUri, this._folderRepoManager, compareBranch, postCreate); } public async openDescription(): Promise<void> { @@ -1011,11 +1271,11 @@ export class ReviewManager { const descriptionNode = this.changesInPrDataProvider.getDescriptionNode(this._folderRepoManager); await openDescription( - this._context, this._telemetry, pullRequest, descriptionNode, this._folderRepoManager, + true ); } @@ -1024,7 +1284,7 @@ export class ReviewManager { } private async updateFocusedViewMode(): Promise<void> { - const focusedSetting = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get(FOCUSED_MODE); + const focusedSetting = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FOCUSED_MODE); if (focusedSetting) { vscode.commands.executeCommand('setContext', FOCUS_REVIEW_MODE, true); await this._context.workspaceState.update(FOCUS_REVIEW_MODE, true); @@ -1035,37 +1295,38 @@ export class ReviewManager { } private async clear(quitReviewMode: boolean) { - const activePullRequest = this._folderRepoManager.activePullRequest; - if (activePullRequest) { - this._activePrViewCoordinator.removePullRequest(activePullRequest); - } - if (quitReviewMode) { + const activePullRequest = this._folderRepoManager.activePullRequest; + if (activePullRequest) { + this._activePrViewCoordinator.removePullRequest(activePullRequest); + } + if (this.changesInPrDataProvider) { await this.changesInPrDataProvider.removePrFromView(this._folderRepoManager); } this._prNumber = undefined; this._folderRepoManager.activePullRequest = undefined; + this._switchedToPullRequest = undefined; if (this._statusBarItem) { this._statusBarItem.hide(); } - vscode.commands.executeCommand('pr.refreshList'); + this._reviewModel.clear(); + + disposeAll(this._localToDispose); + // Ensure file explorer decorations are removed. When switching to a different PR branch, + // comments are recalculated when getting the data and the change decoration fired then, + // so comments only needs to be emptied in this case. + activePullRequest?.clear(); + this._folderRepoManager.setFileViewedContext(); } - this._updateMessageShown = false; - this._reviewModel.clear(); this._reviewCommentController?.dispose(); this._reviewCommentController = undefined; - this._localToDispose.forEach(disposable => disposable.dispose()); - this._reviewCommentController = undefined; - // Ensure file explorer decorations are removed. When switching to a different PR branch, - // comments are recalculated when getting the data and the change decoration fired then, - // so comments only needs to be emptied in this case. - activePullRequest?.clear(); - this._validateStatusInProgress = undefined; + this._inMemGitHubContentProvider?.dispose(); + this._inMemGitHubContentProvider = undefined; } async provideTextDocumentContent(uri: vscode.Uri): Promise<string | undefined> { @@ -1122,16 +1383,14 @@ export class ReviewManager { return ret.join('\n'); } else if (base && commit && this._folderRepoManager.activePullRequest) { // We can't get the content from git. Try to get it from github. - const content = await getGitHubFileContent(this._folderRepoManager.activePullRequest.githubRepository, path, commit); + const content = await this._folderRepoManager.activePullRequest.githubRepository.getFile(path, commit); return content.toString(); } } - dispose() { + override dispose() { + super.dispose(); this.clear(true); - this._disposables.forEach(d => { - d.dispose(); - }); } static getReviewManagerForRepository( diff --git a/src/view/reviewsManager.ts b/src/view/reviewsManager.ts index 972cdd7a4b..faa41ce511 100644 --- a/src/view/reviewsManager.ts +++ b/src/view/reviewsManager.ts @@ -4,64 +4,75 @@ *--------------------------------------------------------------------------------------------*/ 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 { 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 { +export class ReviewsManager extends Disposable { public static ID = 'Reviews'; - private _disposables: vscode.Disposable[]; constructor( 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 _notificationsManager: NotificationsManager, ) { - this._disposables = []; - const gitContentProvider = new GitContentFileSystemProvider(_gitApi, _credentialStore, _reviewManagers); + super(); + const gitContentProvider = new GitContentFileSystemProvider(_gitApi, _credentialStore, () => this._reviewManagers); gitContentProvider.registerTextDocumentContentFallback(this.provideTextDocumentContent.bind(this)); - this._disposables.push(vscode.workspace.registerFileSystemProvider(Schemes.Review, gitContentProvider, { isReadonly: true })); + this._register(vscode.workspace.registerFileSystemProvider(Schemes.Review, gitContentProvider, { isReadonly: true })); this.registerListeners(); - this._disposables.push(this._prsTreeDataProvider); + this._register(this._prsTreeDataProvider); + } + + get reviewManagers(): ReviewManager[] { + return this._reviewManagers; } private registerListeners(): void { - this._disposables.push( - vscode.workspace.onDidChangeConfiguration(async e => { - if (e.affectsConfiguration('githubPullRequests.showInSCM')) { - if (this._prFileChangesProvider) { - this._prFileChangesProvider.dispose(); - this._prFileChangesProvider = new PullRequestChangesTreeDataProvider(this._context, this._gitApi, this._reposManager); - - for (const reviewManager of this._reviewManagers) { - reviewManager.updateState(true); - } - } + this._register(vscode.workspace.onDidChangeConfiguration(async e => { + if (e.affectsConfiguration('githubPullRequests.showInSCM')) { + if (this._prFileChangesProvider) { + this._prFileChangesProvider.dispose(); + this._prFileChangesProvider = new PullRequestChangesTreeDataProvider(this._gitApi, this._reposManager); - this._prsTreeDataProvider.dispose(); - this._prsTreeDataProvider = new PullRequestsTreeDataProvider(this._telemetry, this._context); - this._prsTreeDataProvider.initialize(this._reposManager, this._reviewManagers.map(manager => manager.reviewModel), this._credentialStore); - this._disposables.push(this._prsTreeDataProvider); + for (const reviewManager of this._reviewManagers) { + reviewManager.updateState(true); + } } - }), - ); + + this._prsTreeDataProvider.dispose(); + this._prsTreeDataProvider = this._register(new PullRequestsTreeDataProvider(this._prsTreeModel, this._telemetry, this._context, this._reposManager)); + this._prsTreeDataProvider.initialize(this._reviewManagers.map(manager => manager.reviewModel), this._notificationsManager); + } + })); } async provideTextDocumentContent(uri: vscode.Uri): Promise<string | undefined> { for (const reviewManager of this._reviewManagers) { - if (uri.fsPath.startsWith(reviewManager.repository.rootUri.fsPath)) { + if (isDescendant(reviewManager.repository.rootUri.fsPath, uri.fsPath)) { return reviewManager.provideTextDocumentContent(uri); } } @@ -69,6 +80,20 @@ export class ReviewsManager { } public addReviewManager(reviewManager: ReviewManager) { + // Try to insert in workspace folder order + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders) { + const index = workspaceFolders.findIndex( + folder => folder.uri.toString() === reviewManager.repository.rootUri.toString(), + ); + if (index > -1) { + const arrayEnd = this._reviewManagers.slice(index, this._reviewManagers.length); + this._reviewManagers = this._reviewManagers.slice(0, index); + this._reviewManagers.push(reviewManager); + this._reviewManagers.push(...arrayEnd); + return; + } + } this._reviewManagers.push(reviewManager); } @@ -76,16 +101,118 @@ export class ReviewsManager { const reviewManagerIndex = this._reviewManagers.findIndex( manager => manager.repository.rootUri.toString() === repo.rootUri.toString(), ); - if (reviewManagerIndex) { + if (reviewManagerIndex >= 0) { const manager = this._reviewManagers[reviewManagerIndex]; this._reviewManagers.splice(reviewManagerIndex); manager.dispose(); } } - dispose() { - this._disposables.forEach(d => { - d.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<boolean> true if user chose to proceed (after staging/discarding), false if cancelled + */ +async function handleUncommittedChanges(repository: Repository): Promise<boolean> { + // 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 new file mode 100644 index 0000000000..825395f094 --- /dev/null +++ b/src/view/theme.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { parse } from 'jsonc-parser'; +import * as vscode from 'vscode'; +import { COLOR_THEME, WORKBENCH } from '../common/settingKeys'; + +export async function loadCurrentThemeData(): Promise<ThemeData> { + let themeData: any = null; + const currentThemeName = vscode.workspace.getConfiguration(WORKBENCH).get<string>(COLOR_THEME); + if (currentThemeName) { + const path = getCurrentThemePaths(currentThemeName); + if (path) { + themeData = await loadThemeFromFile(path); + } + } + return themeData; +} + +export interface ThemeData { + type: string, + colors?: { [key: string]: string } + // eslint-disable-next-line rulesdir/no-any-except-union-method-signature + tokenColors: any[], + // eslint-disable-next-line rulesdir/no-any-except-union-method-signature + semanticTokenColors: any[] +} + +async function loadThemeFromFile(path: vscode.Uri): Promise<ThemeData> { + const decoder = new TextDecoder(); + const decoded = decoder.decode(await vscode.workspace.fs.readFile(path)); + let themeData = parse(decoded); + + // Also load the include file if specified + if (themeData.include) { + try { + const includePath = vscode.Uri.joinPath(path, '..', themeData.include); + const includeData = await loadThemeFromFile(includePath); + themeData = { + ...themeData, + colors: { + ...(includeData.colors || {}), + ...(themeData.colors || {}), + }, + tokenColors: [ + ...(includeData.tokenColors || []), + ...(themeData.tokenColors || []), + ], + semanticTokenColors: { + ...(includeData.semanticTokenColors || {}), + ...(themeData.semanticTokenColors || {}), + }, + }; + } catch (error) { + console.warn(`Failed to load theme include file: ${error}`); + } + } + + return themeData; +} + +function getCurrentThemePaths(themeName: string): vscode.Uri | undefined { + for (const ext of vscode.extensions.all) { + const themes = ext.packageJSON.contributes && ext.packageJSON.contributes.themes; + if (!themes) { + continue; + } + const theme = themes.find(theme => theme.label === themeName || theme.id === themeName); + if (theme) { + return vscode.Uri.joinPath(ext.extensionUri, theme.path); + } + } +} + +export function getIconForeground(themeData: ThemeData | undefined, kind: 'light' | 'dark'): string { + return themeData?.colors?.['icon.foreground'] ?? (kind === 'dark' ? '#C5C5C5' : '#424242'); +} + +export function getListWarningForeground(themeData: ThemeData | undefined, kind: 'light' | 'dark'): string { + return themeData?.colors?.['list.warningForeground'] ?? (kind === 'dark' ? '#CCA700' : '#855F00'); +} + +export function getListErrorForeground(themeData: ThemeData | undefined, kind: 'light' | 'dark'): string { + return themeData?.colors?.['list.errorForeground'] ?? (kind === 'dark' ? '#F88070' : '#B01011'); +} + +export function getNotebookStatusSuccessIconForeground(themeData: ThemeData | undefined, kind: 'light' | 'dark'): string { + return themeData?.colors?.['notebookStatusSuccessIcon.foreground'] ?? (kind === 'dark' ? '#89D185' : '#388A34'); +} diff --git a/src/view/treeDecorationProvider.ts b/src/view/treeDecorationProvider.ts deleted file mode 100644 index b3d372e14c..0000000000 --- a/src/view/treeDecorationProvider.ts +++ /dev/null @@ -1,45 +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 { fromFileChangeNodeUri } from '../common/uri'; - -class TreeDecorationProvider implements vscode.FileDecorationProvider { - private fileHasComments: Map<string, boolean> = new Map<string, boolean>(); - - updateFileComments(resourceUri: vscode.Uri, prNumber: number, fileName: string, hasComments: boolean): void { - const key = `${prNumber}:${fileName}`; - const oldValue = this.fileHasComments.get(key); - if (oldValue !== hasComments) { - this.fileHasComments.set(`${prNumber}:${fileName}`, hasComments); - this._onDidChangeFileDecorations.fire(resourceUri); - } - } - - _onDidChangeFileDecorations: vscode.EventEmitter<vscode.Uri | vscode.Uri[]> = new vscode.EventEmitter< - vscode.Uri | vscode.Uri[] - >(); - onDidChangeFileDecorations: vscode.Event<vscode.Uri | vscode.Uri[]> = this._onDidChangeFileDecorations.event; - provideFileDecoration( - uri: vscode.Uri, - _token: vscode.CancellationToken, - ): vscode.ProviderResult<vscode.FileDecoration> { - const query = fromFileChangeNodeUri(uri); - if (query) { - const key = `${query.prNumber}:${query.fileName}`; - if (this.fileHasComments.get(key)) { - return { - propagate: false, - tooltip: 'Commented', - badge: '💬', - }; - } - } - - return undefined; - } -} - -export const DecorationProvider = new TreeDecorationProvider(); diff --git a/src/view/treeDecorationProviders.ts b/src/view/treeDecorationProviders.ts new file mode 100644 index 0000000000..daa014bd31 --- /dev/null +++ b/src/view/treeDecorationProviders.ts @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * 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, disposeAll } from '../common/lifecycle'; +import { Schemes, toResourceUri } from '../common/uri'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { PullRequestModel } from '../github/pullRequestModel'; +import { RepositoriesManager } from '../github/repositoriesManager'; + +export abstract class TreeDecorationProvider extends Disposable implements vscode.FileDecorationProvider { + protected _onDidChangeFileDecorations: vscode.EventEmitter<vscode.Uri | vscode.Uri[]> = this._register(new vscode.EventEmitter< + vscode.Uri | vscode.Uri[] + >()); + onDidChangeFileDecorations?: vscode.Event<vscode.Uri | vscode.Uri[] | undefined> | undefined = this._onDidChangeFileDecorations.event; + + constructor() { + super(); + this._register(vscode.window.registerFileDecorationProvider(this)); + } + + abstract provideFileDecoration(uri: unknown, token: unknown): vscode.ProviderResult<vscode.FileDecoration>; + + abstract registerPullRequestPropertyChangedListeners(folderManager: FolderRepositoryManager, model: PullRequestModel): vscode.Disposable; + + protected _handlePullRequestPropertyChange(folderManager: FolderRepositoryManager, model: PullRequestModel, changed: { path: string }) { + const path = changed.path; + const uri = vscode.Uri.joinPath(folderManager.repository.rootUri, path); + const fileChange = model.fileChanges.get(path); + if (fileChange) { + const fileChangeUri = toResourceUri(uri, model.number, path, fileChange.status, fileChange.previousFileName); + this._onDidChangeFileDecorations.fire(fileChangeUri); + this._onDidChangeFileDecorations.fire(fileChangeUri.with({ scheme: folderManager.repository.rootUri.scheme })); + this._onDidChangeFileDecorations.fire(fileChangeUri.with({ scheme: Schemes.Pr, authority: '' })); + } + } +} + +export class TreeDecorationProviders extends Disposable { + private _gitHubReposListeners: vscode.Disposable[] = []; + private _pullRequestListeners: vscode.Disposable[] = []; + private _pullRequestPropertyChangeListeners: vscode.Disposable[] = []; + + private _providers: TreeDecorationProvider[] = []; + + constructor(private _repositoriesManager: RepositoriesManager) { + super(); + } + + public registerProviders(provider: TreeDecorationProvider[]) { + this._providers.push(...provider); + this._registerListeners(); + } + + private _registerPullRequestPropertyListeners(folderManager: FolderRepositoryManager, model: PullRequestModel): vscode.Disposable[] { + return this._providers.map(provider => provider.registerPullRequestPropertyChangedListeners(folderManager, model)); + } + + private _registerPullRequestAddedListeners(folderManager: FolderRepositoryManager) { + folderManager.gitHubRepositories.forEach(gitHubRepo => { + this._pullRequestListeners.push(gitHubRepo.onDidAddPullRequest(model => { + this._pullRequestPropertyChangeListeners.push(...this._registerPullRequestPropertyListeners(folderManager, model)); + })); + const models = gitHubRepo.pullRequestModels; + const listeners = models.map(model => { + return this._registerPullRequestPropertyListeners(folderManager, model); + }).flat(); + this._pullRequestPropertyChangeListeners.push(...listeners); + }); + } + + private _registerRepositoriesChangedListeners() { + disposeAll(this._gitHubReposListeners); + disposeAll(this._pullRequestListeners); + disposeAll(this._pullRequestPropertyChangeListeners); + this._repositoriesManager.folderManagers.forEach(folderManager => { + this._gitHubReposListeners.push(folderManager.onDidChangeRepositories(() => { + this._registerPullRequestAddedListeners(folderManager); + })); + }); + } + + private _registerListeners() { + this._registerRepositoriesChangedListeners(); + this._register(this._repositoriesManager.onDidChangeFolderRepositories(() => { + this._registerRepositoriesChangedListeners(); + })); + + } + + override dispose() { + super.dispose(); + disposeAll(this._gitHubReposListeners); + disposeAll(this._pullRequestListeners); + disposeAll(this._pullRequestPropertyChangeListeners); + disposeAll(this._providers); + } +} diff --git a/src/view/treeNodes/categoryNode.ts b/src/view/treeNodes/categoryNode.ts index 0f7b5137a2..b7f0d2baa2 100644 --- a/src/view/treeNodes/categoryNode.ts +++ b/src/view/treeNodes/categoryNode.ts @@ -1,249 +1,356 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { AuthenticationError } from '../../common/authentication'; -import { PR_SETTINGS_NAMESPACE, QUERIES } from '../../common/settingKeys'; -import { ITelemetry } from '../../common/telemetry'; -import { formatError } from '../../common/utils'; -import { FolderRepositoryManager, ItemsResponseResult } from '../../github/folderRepositoryManager'; -import { PRType } from '../../github/interface'; -import { NotificationProvider } from '../../github/notifications'; -import { PullRequestModel } from '../../github/pullRequestModel'; -import { PrsTreeModel } from '../prsTreeModel'; -import { PRNode } from './pullRequestNode'; -import { TreeNode, TreeNodeParent } from './treeNode'; - -export enum PRCategoryActionType { - Empty, - More, - TryOtherRemotes, - Login, - LoginEnterprise, - NoRemotes, - NoMatchingRemotes, - ConfigureRemotes, -} - -export class PRCategoryActionNode extends TreeNode implements vscode.TreeItem { - public collapsibleState: vscode.TreeItemCollapsibleState; - public iconPath?: { light: string | vscode.Uri; dark: string | vscode.Uri }; - public type: PRCategoryActionType; - public command?: vscode.Command; - - constructor(parent: TreeNodeParent, type: PRCategoryActionType, node?: CategoryTreeNode) { - super(); - this.parent = parent; - this.type = type; - this.collapsibleState = vscode.TreeItemCollapsibleState.None; - switch (type) { - case PRCategoryActionType.Empty: - this.label = vscode.l10n.t('0 pull requests in this category'); - break; - case PRCategoryActionType.More: - this.label = vscode.l10n.t('Load more'); - this.command = { - title: vscode.l10n.t('Load more'), - command: 'pr.loadMore', - arguments: [node], - }; - break; - case PRCategoryActionType.TryOtherRemotes: - this.label = vscode.l10n.t('Continue fetching from other remotes'); - this.command = { - title: vscode.l10n.t('Load more'), - command: 'pr.loadMore', - arguments: [node], - }; - break; - case PRCategoryActionType.Login: - this.label = vscode.l10n.t('Sign in'); - this.command = { - title: vscode.l10n.t('Sign in'), - command: 'pr.signinAndRefreshList', - arguments: [], - }; - break; - case PRCategoryActionType.LoginEnterprise: - this.label = vscode.l10n.t('Sign in with GitHub Enterprise...'); - this.command = { - title: 'Sign in', - command: 'pr.signinAndRefreshList', - arguments: [], - }; - break; - case PRCategoryActionType.NoRemotes: - this.label = vscode.l10n.t('No GitHub repositories found.'); - break; - case PRCategoryActionType.NoMatchingRemotes: - this.label = vscode.l10n.t('No remotes match the current setting.'); - break; - case PRCategoryActionType.ConfigureRemotes: - this.label = vscode.l10n.t('Configure remotes...'); - this.command = { - title: vscode.l10n.t('Configure remotes'), - command: 'pr.configureRemotes', - arguments: [], - }; - break; - default: - break; - } - } - - getTreeItem(): vscode.TreeItem { - return this; - } -} - -interface PageInformation { - pullRequestPage: number; - hasMorePages: boolean; -} - -export class CategoryTreeNode extends TreeNode implements vscode.TreeItem { - public collapsibleState: vscode.TreeItemCollapsibleState; - public prs: PullRequestModel[]; - public fetchNextPage: boolean = false; - public repositoryPageInformation: Map<string, PageInformation> = new Map<string, PageInformation>(); - public contextValue: string; - public readonly id: string = ''; - - constructor( - public parent: TreeNodeParent, - private _folderRepoManager: FolderRepositoryManager, - private _telemetry: ITelemetry, - private _type: PRType, - private _notificationProvider: NotificationProvider, - expandedQueries: Set<string>, - private _prsTreeModel: PrsTreeModel, - _categoryLabel?: string, - private _categoryQuery?: string, - ) { - super(); - - this.prs = []; - - switch (_type) { - case PRType.All: - this.label = vscode.l10n.t('All Open'); - break; - case PRType.Query: - this.label = _categoryLabel!; - break; - case PRType.LocalPullRequest: - this.label = vscode.l10n.t('Local Pull Request Branches'); - break; - default: - this.label = ''; - break; - } - - this.id = parent instanceof TreeNode ? `${parent.label}/${this.label}` : this.label; - - if ((expandedQueries.size === 0) && (_type === PRType.All)) { - this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; - } else { - this.collapsibleState = - expandedQueries.has(this.id) - ? vscode.TreeItemCollapsibleState.Expanded - : vscode.TreeItemCollapsibleState.Collapsed; - } - - if (this._categoryQuery) { - this.contextValue = 'query'; - } - } - - async editQuery() { - const config = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE); - const inspect = config.inspect<{ label: string; query: string }[]>(QUERIES); - let command: string; - if (inspect?.workspaceValue) { - command = 'workbench.action.openWorkspaceSettingsFile'; - } else { - const value = config.get<{ label: string; query: string }[]>(QUERIES); - if (inspect?.defaultValue && JSON.stringify(inspect?.defaultValue) === JSON.stringify(value)) { - config.update(QUERIES, inspect.defaultValue, vscode.ConfigurationTarget.Global); - } - command = 'workbench.action.openSettingsJson'; - } - await vscode.commands.executeCommand(command); - const editor = vscode.window.activeTextEditor; - if (editor) { - const text = editor.document.getText(); - const search = text.search(this.label!); - if (search >= 0) { - const position = editor.document.positionAt(search); - editor.revealRange(new vscode.Range(position, position)); - editor.selection = new vscode.Selection(position, position); - } - } - } - - async getChildren(): Promise<TreeNode[]> { - super.getChildren(); - - let hasMorePages = false; - let hasUnsearchedRepositories = false; - let needLogin = false; - if (this._type === PRType.LocalPullRequest) { - try { - this.prs = await this._prsTreeModel.getLocalPullRequests(this._folderRepoManager); - } catch (e) { - vscode.window.showErrorMessage(vscode.l10n.t('Fetching local pull requests failed: {0}', formatError(e))); - needLogin = e instanceof AuthenticationError; - } - } else { - try { - let response: ItemsResponseResult<PullRequestModel>; - switch (this._type) { - case PRType.All: - response = await this._prsTreeModel.getAllPullRequests(this._folderRepoManager, this.fetchNextPage); - break; - case PRType.Query: - response = await this._prsTreeModel.getPullRequestsForQuery(this._folderRepoManager, this.fetchNextPage, this._categoryQuery!); - break; - } - if (!this.fetchNextPage) { - this.prs = response.items; - } else { - this.prs = this.prs.concat(response.items); - } - hasMorePages = response.hasMorePages; - hasUnsearchedRepositories = response.hasUnsearchedRepositories; - } catch (e) { - vscode.window.showErrorMessage(vscode.l10n.t('Fetching pull requests failed: {0}', formatError(e))); - needLogin = e instanceof AuthenticationError; - } finally { - this.fetchNextPage = false; - } - } - - if (this.prs && this.prs.length) { - const nodes: TreeNode[] = this.prs.map( - prItem => new PRNode(this, this._folderRepoManager, prItem, this._type === PRType.LocalPullRequest, this._notificationProvider), - ); - if (hasMorePages) { - nodes.push(new PRCategoryActionNode(this, PRCategoryActionType.More, this)); - } else if (hasUnsearchedRepositories) { - nodes.push(new PRCategoryActionNode(this, PRCategoryActionType.TryOtherRemotes, this)); - } - - this.children = nodes; - return nodes; - } else { - const category = needLogin ? PRCategoryActionType.Login : PRCategoryActionType.Empty; - const result = [new PRCategoryActionNode(this, category)]; - - this.children = result; - return result; - } - } - - getTreeItem(): vscode.TreeItem { - return this; - } -} +/*--------------------------------------------------------------------------------------------- + * 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 { RemoteInfo } from '../../../common/types'; +import { AuthenticationError } from '../../common/authentication'; +import { DEV_MODE, PR_SETTINGS_NAMESPACE } from '../../common/settingKeys'; +import { ITelemetry } from '../../common/telemetry'; +import { toQueryUri } from '../../common/uri'; +import { formatError } from '../../common/utils'; +import { isCopilotQuery } from '../../github/copilotPrWatcher'; +import { FolderRepositoryManager, ItemsResponseResult } from '../../github/folderRepositoryManager'; +import { PRType } from '../../github/interface'; +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, + More, + TryOtherRemotes, + Login, + LoginEnterprise, + NoRemotes, + NoMatchingRemotes, + ConfigureRemotes, +} + +export class PRCategoryActionNode extends TreeNode implements vscode.TreeItem { + public collapsibleState: vscode.TreeItemCollapsibleState; + public iconPath?: { light: vscode.Uri; dark: vscode.Uri }; + public type: PRCategoryActionType; + public command?: vscode.Command; + + constructor(parent: TreeNodeParent, type: PRCategoryActionType, node?: CategoryTreeNode) { + super(parent); + this.type = type; + this.collapsibleState = vscode.TreeItemCollapsibleState.None; + switch (type) { + case PRCategoryActionType.Empty: + this.label = vscode.l10n.t('0 pull requests in this category'); + break; + case PRCategoryActionType.More: + this.label = vscode.l10n.t('Load more'); + this.command = { + title: vscode.l10n.t('Load more'), + command: 'pr.loadMore', + arguments: [node], + }; + break; + case PRCategoryActionType.TryOtherRemotes: + this.label = vscode.l10n.t('Continue fetching from other remotes'); + this.command = { + title: vscode.l10n.t('Load more'), + command: 'pr.loadMore', + arguments: [node], + }; + break; + case PRCategoryActionType.Login: + this.label = vscode.l10n.t('Sign in'); + this.command = { + title: vscode.l10n.t('Sign in'), + command: 'pr.signinAndRefreshList', + arguments: [], + }; + break; + case PRCategoryActionType.LoginEnterprise: + this.label = vscode.l10n.t('Sign in with GitHub Enterprise...'); + this.command = { + title: 'Sign in', + command: 'pr.signinAndRefreshList', + arguments: [], + }; + break; + case PRCategoryActionType.NoRemotes: + this.label = vscode.l10n.t('No GitHub repositories found.'); + break; + case PRCategoryActionType.NoMatchingRemotes: + this.label = vscode.l10n.t('No remotes match the current setting.'); + break; + case PRCategoryActionType.ConfigureRemotes: + this.label = vscode.l10n.t('Configure remotes...'); + this.command = { + title: vscode.l10n.t('Configure remotes'), + command: 'pr.configureRemotes', + arguments: [], + }; + break; + default: + break; + } + } + + getTreeItem(): vscode.TreeItem { + return this; + } +} + +interface PageInformation { + pullRequestPage: number; + hasMorePages: boolean; +} + +export namespace DefaultQueries { + export namespace Queries { + export const LOCAL = 'Local Pull Request Branches'; + export const ALL = 'All Open'; + } + export namespace Values { + export const DEFAULT = 'default'; + } +} + +export function isLocalQuery(queryInfo: IQueryInfo): boolean { + return queryInfo.label === DefaultQueries.Queries.LOCAL && queryInfo.query === DefaultQueries.Values.DEFAULT; +} + +export function isAllQuery(queryInfo: IQueryInfo): boolean { + return queryInfo.label === DefaultQueries.Queries.ALL && queryInfo.query === DefaultQueries.Values.DEFAULT; +} + +export class CategoryTreeNode extends TreeNode implements vscode.TreeItem { + public collapsibleState: vscode.TreeItemCollapsibleState; + public prs: Map<number, PullRequestModel>; + public fetchNextPage: boolean = false; + public repositoryPageInformation: Map<string, PageInformation> = new Map<string, PageInformation>(); + public contextValue: string; + public resourceUri: vscode.Uri; + public tooltip?: string | vscode.MarkdownString | undefined; + readonly isCopilot: boolean; + private _repo: RemoteInfo | undefined; + + constructor( + parent: TreeNodeParent, + readonly folderRepoManager: FolderRepositoryManager, + private _telemetry: ITelemetry, + public readonly type: PRType, + private _notificationProvider: NotificationsManager, + private _prsTreeModel: PrsTreeModel, + _categoryLabel?: string, + private _categoryQuery?: string, + ) { + super(parent); + + this.prs = new Map(); + this.isCopilot = !!_categoryQuery && isCopilotQuery(_categoryQuery); + + switch (this.type) { + case PRType.All: + this.label = vscode.l10n.t('All Open'); + break; + case PRType.Query: + this.label = _categoryLabel!; + break; + case PRType.LocalPullRequest: + this.label = vscode.l10n.t('Local Pull Request Branches'); + break; + default: + this.label = ''; + break; + } + + this.id = parent instanceof TreeNode ? `${parent.id ?? parent.label}/${this.label}` : this.label; + + // Check if dev mode is enabled to collapse all queries + const devMode = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<boolean>(DEV_MODE, false); + + if (devMode) { + this.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; + } else { + if ((this._prsTreeModel.expandedQueries === undefined) && (this.type === PRType.All)) { + this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; + } else { + this.collapsibleState = + this._prsTreeModel.expandedQueries?.has(this.id) + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.Collapsed; + } + } + + if (this._categoryQuery) { + this.contextValue = this.isCopilot ? 'copilot-query' : 'query'; + } + + if (this.isCopilot) { + this.tooltip = vscode.l10n.t('Pull requests you asked the coding agent to create'); + } else if (this.type === PRType.LocalPullRequest) { + this.tooltip = vscode.l10n.t('Pull requests for branches you have locally'); + } else if (this.type === PRType.All) { + this.tooltip = vscode.l10n.t('All open pull requests in the current repository'); + } else if (_categoryQuery) { + this.tooltip = new vscode.MarkdownString(vscode.l10n.t('Pull requests for query: `{0}`', _categoryQuery)); + } else { + this.tooltip = this.label; + } + } + + get repo(): RemoteInfo | undefined { + return this._repo; + } + + private _getDescription(): string | undefined { + if (!this.isCopilot || !this._repo) { + return undefined; + } + const counts = this._prsTreeModel.getCopilotCounts(this._repo.owner, this._repo.repositoryName); + if (counts.total === 0) { + return undefined; + } else if (counts.error > 0) { + if (counts.inProgress > 0) { + return vscode.l10n.t('{0} in progress, {1} with errors', counts.inProgress, counts.error); + } else { + return vscode.l10n.t('{0} with errors', counts.error); + } + } else if (counts.inProgress > 0) { + return vscode.l10n.t('{0} in progress', counts.inProgress); + } else { + return vscode.l10n.t('done working on {0}', counts.total); + } + } + + public async expandPullRequest(pullRequest: PullRequestModel, retry: boolean = true): Promise<boolean> { + if (!this._children && retry) { + await this.getChildren(); + retry = false; + } + if (this._children) { + for (const child of this._children) { + if (child instanceof PRNode) { + if (child.pullRequestModel.equals(pullRequest)) { + this.reveal(child, { expand: true, select: true }); + return true; + } + } + } + // If we didn't find the PR, we might need to re-run the query + if (retry) { + await this.getChildren(); + return await this.expandPullRequest(pullRequest, false); + } + } + return false; + } + + override async getChildren(shouldDispose: boolean = true): Promise<TreeNode[]> { + await super.getChildren(shouldDispose); + if (!shouldDispose && this._children) { + return this._children; + } + const isFirstLoad = !this._firstLoad; + if (isFirstLoad) { + this._firstLoad = this.doGetChildren(); + if (!this._prsTreeModel.hasLoaded) { + this._firstLoad.then(() => this.refresh(this)); + return []; + } + } + return isFirstLoad ? this._firstLoad! : this.doGetChildren(); + } + + private _firstLoad: Promise<TreeNode[]> | undefined; + private async doGetChildren(): Promise<TreeNode[]> { + let hasMorePages = false; + let hasUnsearchedRepositories = false; + let needLogin = false; + const fetchNextPage = this.fetchNextPage; + this.fetchNextPage = false; + if (this.type === PRType.LocalPullRequest) { + try { + this.prs.clear(); + (await this._prsTreeModel.getLocalPullRequests(this.folderRepoManager)).items.forEach(item => this.prs.set(item.id, item)); + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Fetching local pull requests failed: {0}', formatError(e))); + needLogin = e instanceof AuthenticationError; + } + } else { + try { + let response: ItemsResponseResult<PullRequestModel>; + switch (this.type) { + case PRType.All: + response = await this._prsTreeModel.getAllPullRequests(this.folderRepoManager, fetchNextPage); + break; + case PRType.Query: + response = await this._prsTreeModel.getPullRequestsForQuery(this.folderRepoManager, fetchNextPage, this._categoryQuery!); + break; + } + if (!fetchNextPage) { + this.prs.clear(); + } + response.items.forEach(item => this.prs.set(item.id, item)); + hasMorePages = response.hasMorePages; + hasUnsearchedRepositories = response.hasUnsearchedRepositories; + } catch (e) { + if (this.isCopilot && (e.response.status === 422) && e.message.includes('the users do not exist')) { + // Skip it, it's copilot and the repo doesn't have copilot + } else { + const error = formatError(e); + const actions: string[] = []; + if (error.includes('Bad credentials')) { + actions.push(vscode.l10n.t('Login again')); + } + vscode.window.showErrorMessage(vscode.l10n.t('Fetching pull requests failed: {0}', formatError(e)), ...actions).then(action => { + if (action && action === actions[0]) { + this.folderRepoManager.credentialStore.recreate(vscode.l10n.t('Your login session is no longer valid.')); + } + }); + needLogin = e instanceof AuthenticationError; + } + } + } + + 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._prsTreeModel), + ); + if (hasMorePages) { + nodes.push(new PRCategoryActionNode(this, PRCategoryActionType.More, this)); + } else if (hasUnsearchedRepositories) { + nodes.push(new PRCategoryActionNode(this, PRCategoryActionType.TryOtherRemotes, this)); + } + + this._children = nodes; + return nodes; + } else { + const category = needLogin ? PRCategoryActionType.Login : PRCategoryActionType.Empty; + const result = [new PRCategoryActionNode(this, category)]; + + this._children = result; + return result; + } + } + + async getTreeItem(): Promise<vscode.TreeItem> { + this.description = this._getDescription(); + if (!this._repo) { + this._repo = await extractRepoFromQuery(this.folderRepoManager, this._categoryQuery); + } + this.resourceUri = toQueryUri({ remote: this._repo, isCopilot: this.isCopilot }); + + // Update contextValue based on current notification state + if (this._categoryQuery) { + 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'; + } + + return this; + } +} diff --git a/src/view/treeNodes/commitNode.ts b/src/view/treeNodes/commitNode.ts index 264e8bcb88..d67aa49438 100644 --- a/src/view/treeNodes/commitNode.ts +++ b/src/view/treeNodes/commitNode.ts @@ -3,13 +3,14 @@ * 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'; import { getGitChangeType } from '../../common/diffHunk'; -import { FILE_LIST_LAYOUT } from '../../common/settingKeys'; -import { toReviewUri } from '../../common/uri'; +import { FILE_LIST_LAYOUT, PR_SETTINGS_NAMESPACE } from '../../common/settingKeys'; +import { DataUri, reviewPath, toReviewUri } from '../../common/uri'; +import { dateFromNow } from '../../common/utils'; import { OctokitCommon } from '../../github/common'; -import { FolderRepositoryManager, SETTINGS_NAMESPACE } from '../../github/folderRepositoryManager'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; +import { AccountType, IAccount } from '../../github/interface'; import { IResolvedPullRequestModel, PullRequestModel } from '../../github/pullRequestModel'; import { GitFileChangeModel } from '../fileChangeModel'; import { DirectoryTreeNode } from './directoryTreeNode'; @@ -19,48 +20,48 @@ import { LabelOnlyNode, TreeNode, TreeNodeParent } from './treeNode'; export class CommitNode extends TreeNode implements vscode.TreeItem { public sha: string; public collapsibleState: vscode.TreeItemCollapsibleState; - public iconPath: vscode.Uri | undefined; + public iconPath: vscode.Uri | vscode.ThemeIcon | undefined; public contextValue?: string; constructor( - public parent: TreeNodeParent, + parent: TreeNodeParent, private readonly pullRequestManager: FolderRepositoryManager, private readonly pullRequest: PullRequestModel, private readonly commit: OctokitCommon.PullsListCommitsResponseItem, private readonly isCurrent: boolean ) { - super(); + super(parent); this.label = commit.commit.message; this.sha = commit.sha; this.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; - let userIconUri: vscode.Uri | undefined; - try { - if (commit.author && commit.author.avatar_url) { - userIconUri = vscode.Uri.parse(`${commit.author.avatar_url}&s=${64}`); - } - } catch (_) { - // no-op - } - - this.iconPath = userIconUri; this.contextValue = 'commit'; + this.description = commit.commit.author?.date ? dateFromNow(commit.commit.author.date) : undefined; } - getTreeItem(): vscode.TreeItem { + async getTreeItem(): Promise<vscode.TreeItem> { + if (this.commit.author) { + // For enterprise, use placeholder icon instead of trying to fetch avatar + if (!DataUri.isGitHubDotComAvatar(this.commit.author.avatar_url)) { + this.iconPath = new vscode.ThemeIcon('github'); + } else { + const author: IAccount = { id: this.commit.author.node_id, login: this.commit.author.login, url: this.commit.author.url, avatarUrl: this.commit.author.avatar_url, accountType: this.commit.author.type as AccountType }; + this.iconPath = (await DataUri.avatarCirclesAsImageDataUris(this.pullRequestManager.context, [author], 16, 16))[0]; + } + } return this; } - async getChildren(): Promise<TreeNode[]> { + override async getChildren(): Promise<TreeNode[]> { super.getChildren(); const fileChanges = (await this.pullRequest.getCommitChangedFiles(this.commit)) ?? []; if (fileChanges.length === 0) { - return [new LabelOnlyNode('No changed files')]; + return [new LabelOnlyNode(this, 'No changed files')]; } const fileChangeNodes = fileChanges.map(change => { const fileName = change.filename!; - const uri = vscode.Uri.parse(path.posix.join(`commit~${this.commit.sha.substr(0, 8)}`, fileName)); + const uri = reviewPath(fileName, this.commit.sha); const changeModel = new GitFileChangeModel( this.pullRequestManager, this.pullRequest, @@ -102,7 +103,7 @@ export class CommitNode extends TreeNode implements vscode.TreeItem { }); let result: TreeNode[] = []; - const layout = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get<string>(FILE_LIST_LAYOUT); + const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<string>(FILE_LIST_LAYOUT); if (layout === 'tree') { // tree view const dirNode = new DirectoryTreeNode(this, ''); @@ -110,7 +111,7 @@ export class CommitNode extends TreeNode implements vscode.TreeItem { dirNode.finalize(); if (dirNode.label === '') { // nothing on the root changed, pull children to parent - result.push(...dirNode.children); + result.push(...dirNode._children); } else { result.push(dirNode); } @@ -118,7 +119,7 @@ export class CommitNode extends TreeNode implements vscode.TreeItem { // flat view result = fileChangeNodes; } - this.children = result; + this._children = result; return result; } } diff --git a/src/view/treeNodes/commitsCategoryNode.ts b/src/view/treeNodes/commitsCategoryNode.ts index eec88ccdde..7a0fb197ca 100644 --- a/src/view/treeNodes/commitsCategoryNode.ts +++ b/src/view/treeNodes/commitsCategoryNode.ts @@ -4,37 +4,43 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { CommitNode } from './commitNode'; +import { TreeNode, TreeNodeParent } from './treeNode'; import Logger, { PR_TREE } from '../../common/logger'; +import { createCommitsNodeUri } from '../../common/uri'; 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 label: string = vscode.l10n.t('Commits'); public collapsibleState: vscode.TreeItemCollapsibleState; + public resourceUri: vscode.Uri; private _folderRepoManager: FolderRepositoryManager; private _pr: PullRequestModel; + public tooltip?: string; constructor( parent: TreeNodeParent, reposManager: FolderRepositoryManager, pr: PullRequestModel, ) { - super(); - this.parent = parent; + super(parent); + this.label = vscode.l10n.t('Commits'); this._pr = pr; this._folderRepoManager = reposManager; this.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; + this.resourceUri = createCommitsNodeUri(pr.remote.owner, pr.remote.repositoryName, pr.number); + this.tooltip = vscode.l10n.t('Commits'); this.childrenDisposables = []; this.childrenDisposables.push(this._pr.onDidChangeReviewThreads(() => { Logger.appendLine(`Review threads have changed, refreshing Commits node`, PR_TREE); this.refresh(this); })); - this.childrenDisposables.push(this._pr.onDidChangeComments(() => { - Logger.appendLine(`Comments have changed, refreshing Commits node`, PR_TREE); - this.refresh(this); + this.childrenDisposables.push(this._pr.onDidChange(e => { + if (e.comments) { + Logger.appendLine(`Comments have changed, refreshing Commits node`, PR_TREE); + this.refresh(this); + } })); } @@ -42,16 +48,16 @@ export class CommitsNode extends TreeNode implements vscode.TreeItem { return this; } - async getChildren(): Promise<TreeNode[]> { + override async getChildren(): Promise<TreeNode[]> { super.getChildren(); try { Logger.appendLine(`Getting children for Commits node`, PR_TREE); const commits = await this._pr.getCommits(); - this.children = commits.map( + this._children = commits.map( (commit, index) => new CommitNode(this, this._folderRepoManager, this._pr, commit, (index === commits.length - 1) && (this._folderRepoManager.repository.state.HEAD?.commit === commit.sha)), ); Logger.appendLine(`Got all children for Commits node`, PR_TREE); - return this.children; + return this._children; } catch (e) { return []; } diff --git a/src/view/treeNodes/descriptionNode.ts b/src/view/treeNodes/descriptionNode.ts deleted file mode 100644 index 878da63d36..0000000000 --- a/src/view/treeNodes/descriptionNode.ts +++ /dev/null @@ -1,49 +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 { Repository } from '../../api/api'; -import { PullRequestModel } from '../../github/pullRequestModel'; -import { TreeNode, TreeNodeParent } from './treeNode'; - -export class DescriptionNode extends TreeNode implements vscode.TreeItem { - public command?: vscode.Command; - public contextValue?: string; - public tooltip: string; - - constructor( - public parent: TreeNodeParent, - public label: string, - public iconPath: - | string - | vscode.Uri - | { light: string | vscode.Uri; dark: string | vscode.Uri } - | vscode.ThemeIcon, - public pullRequestModel: PullRequestModel, - public readonly repository: Repository - ) { - super(); - - this.command = { - title: vscode.l10n.t('View Pull Request Description'), - command: 'pr.openDescription', - arguments: [this], - }; - - this.tooltip =vscode.l10n.t('Description of pull request #{0}', pullRequestModel.number); - this.accessibilityInformation = { label: vscode.l10n.t('Pull request page of pull request number {0}', pullRequestModel.number), role: 'button' }; - } - - getTreeItem(): vscode.TreeItem { - this.updateContextValue(); - return this; - } - - protected updateContextValue(): void { - this.contextValue = 'description' + - (this.pullRequestModel.hasChangesSinceLastReview ? ':changesSinceReview' : '') + - (this.pullRequestModel.showChangesSinceReview ? ':active' : ':inactive'); - } -} diff --git a/src/view/treeNodes/directoryTreeNode.ts b/src/view/treeNodes/directoryTreeNode.ts index 211e114dab..b24e8e9b75 100644 --- a/src/view/treeNodes/directoryTreeNode.ts +++ b/src/view/treeNodes/directoryTreeNode.ts @@ -7,19 +7,20 @@ import * as vscode from 'vscode'; import { GitFileChangeNode, InMemFileChangeNode, RemoteFileChangeNode } from './fileChangeNode'; import { TreeNode, TreeNodeParent } from './treeNode'; -export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem2 { +export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem { public collapsibleState: vscode.TreeItemCollapsibleState; - public children: (RemoteFileChangeNode | InMemFileChangeNode | GitFileChangeNode | DirectoryTreeNode)[] = []; + public override _children: (RemoteFileChangeNode | InMemFileChangeNode | GitFileChangeNode | DirectoryTreeNode)[] = []; private pathToChild: Map<string, DirectoryTreeNode> = new Map(); - public checkboxState?: { state: vscode.TreeItemCheckboxState, tooltip: string }; + public checkboxState?: { state: vscode.TreeItemCheckboxState, tooltip: string, accessibilityInformation: vscode.AccessibilityInformation }; - constructor(public parent: TreeNodeParent, public label: string) { - super(); + constructor(parent: TreeNodeParent, label: string) { + super(parent); + this.label = label; this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; } - async getChildren(): Promise<TreeNode[]> { - return this.children; + override async getChildren(): Promise<TreeNode[]> { + return this._children; } public finalize(): void { @@ -28,11 +29,11 @@ export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem2 { } private trimTree(): void { - if (this.children.length === 0) { + if (this._children.length === 0) { return; } - this.children.forEach(n => { + this._children.forEach(n => { if (n instanceof DirectoryTreeNode) { n.trimTree(); // recursive } @@ -45,10 +46,10 @@ export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem2 { // becomes: // - a/b // - c - if (this.children.length !== 1) { + if (this._children.length !== 1) { return; } - const child = this.children[0]; + const child = this._children[0]; if (!(child instanceof DirectoryTreeNode)) { return; } @@ -58,12 +59,12 @@ export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem2 { if (this.label.startsWith('/')) { this.label = this.label.substr(1); } - this.children = child.children; - this.children.forEach(child => { child.parent = this; }); + this._children = child._children; + this._children.forEach(child => { child.parent = this; }); } private sort(): void { - if (this.children.length <= 1) { + if (this._children.length <= 1) { return; } @@ -71,7 +72,7 @@ export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem2 { const files: (RemoteFileChangeNode | InMemFileChangeNode | GitFileChangeNode)[] = []; // process directory - this.children.forEach(node => { + this._children.forEach(node => { if (node instanceof DirectoryTreeNode) { node.sort(); // recc dirs.push(node); @@ -82,10 +83,10 @@ export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem2 { }); // sort - dirs.sort((a, b) => (a.label < b.label ? -1 : 1)); + dirs.sort((a, b) => (a.label! < b.label! ? -1 : 1)); files.sort((a, b) => (a.label! < b.label! ? -1 : 1)); - this.children = [...dirs, ...files]; + this._children = [...dirs, ...files]; } public addFile(file: GitFileChangeNode | RemoteFileChangeNode | InMemFileChangeNode): void { @@ -100,7 +101,7 @@ export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem2 { if (paths.length === 1) { file.parent = this; - this.children.push(file); + this._children.push(file); return; } @@ -111,62 +112,29 @@ export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem2 { if (!node) { node = new DirectoryTreeNode(this, dir); this.pathToChild.set(dir, node); - this.children.push(node); + this._children.push(node); } node.addPathRecc(tail, file); } - updateCheckbox(newState: vscode.TreeItemCheckboxState) { - this.children.forEach(child => child.updateCheckbox(newState)); - - if (this.parent instanceof TreeNode && !this.parent.updateParentCheckbox()) { - this.refresh(this); - } - } - public allChildrenViewed(): boolean { - for (const child of this.children) { + for (const child of this._children) { if (child instanceof DirectoryTreeNode) { if (!child.allChildrenViewed()) { return false; } - } - else if (child.checkboxState.state !== vscode.TreeItemCheckboxState.Checked) { + } else if (!child.checkboxState || child.checkboxState.state !== vscode.TreeItemCheckboxState.Checked) { return false; } } return true; } - public updateParentCheckbox(): boolean { - // Returns true if the node has been refreshed and false otherwise - const allChildrenViewed = this.allChildrenViewed(); - if ( - (allChildrenViewed && this.checkboxState?.state === vscode.TreeItemCheckboxState.Checked) || - (!allChildrenViewed && this.checkboxState?.state === vscode.TreeItemCheckboxState.Unchecked) - ) { - return false; - } - - this.setCheckboxState(allChildrenViewed); - if (this.parent instanceof DirectoryTreeNode && this.parent.checkboxState !== undefined && this.checkboxState !== this.parent.checkboxState) { - if (!this.parent.updateParentCheckbox()) { - this.refresh(this); - return true; - } - } - else { - this.refresh(this); - return true; - } - return false; - } - private setCheckboxState(isChecked: boolean) { this.checkboxState = isChecked ? - { state: vscode.TreeItemCheckboxState.Checked, tooltip: 'unmark all files viewed' } : - { state: vscode.TreeItemCheckboxState.Unchecked, tooltip: 'mark all files viewed' }; + { state: vscode.TreeItemCheckboxState.Checked, tooltip: vscode.l10n.t('Mark all files unviewed'), accessibilityInformation: { label: vscode.l10n.t('Mark all files in folder {0} as unviewed', this.label!) } } : + { state: vscode.TreeItemCheckboxState.Unchecked, tooltip: vscode.l10n.t('Mark all files viewed'), accessibilityInformation: { label: vscode.l10n.t('Mark all files in folder {0} as viewed', this.label!) } }; } getTreeItem(): vscode.TreeItem { diff --git a/src/view/treeNodes/fileChangeNode.ts b/src/view/treeNodes/fileChangeNode.ts index f90bc2c398..0b457fdb12 100644 --- a/src/view/treeNodes/fileChangeNode.ts +++ b/src/view/treeNodes/fileChangeNode.ts @@ -7,20 +7,18 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { IComment, ViewedState } from '../../common/comment'; import { GitChangeType, InMemFileChange } from '../../common/file'; -import { FILE_LIST_LAYOUT, GIT, OPEN_DIFF_ON_CLICK } from '../../common/settingKeys'; -import { asImageDataURI, EMPTY_IMAGE_URI, fromReviewUri, ReviewUriParams, Schemes, toResourceUri } from '../../common/uri'; +import { FILE_LIST_LAYOUT, GIT, OPEN_DIFF_ON_CLICK, PR_SETTINGS_NAMESPACE } from '../../common/settingKeys'; +import { asTempStorageURI, EMPTY_IMAGE_URI, fromReviewUri, ReviewUriParams, Schemes, toGitHubUri, toResourceUri } from '../../common/uri'; import { groupBy } from '../../common/utils'; -import { FolderRepositoryManager, SETTINGS_NAMESPACE } from '../../github/folderRepositoryManager'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; import { IResolvedPullRequestModel, PullRequestModel } from '../../github/pullRequestModel'; import { FileChangeModel, GitFileChangeModel, InMemFileChangeModel, RemoteFileChangeModel } from '../fileChangeModel'; -import { DecorationProvider } from '../treeDecorationProvider'; import { TreeNode, TreeNodeParent } from './treeNode'; export function openFileCommand(uri: vscode.Uri, inputOpts: vscode.TextDocumentShowOptions = {}): vscode.Command { const activeTextEditor = vscode.window.activeTextEditor; const opts = { ...inputOpts, ...{ - preserveFocus: true, viewColumn: vscode.ViewColumn.Active, } }; @@ -44,8 +42,8 @@ async function openDiffCommand( opts: vscode.TextDocumentShowOptions | undefined, status: GitChangeType, ): Promise<vscode.Command> { - let parentURI = (await asImageDataURI(parentFilePath, folderManager.repository)) || parentFilePath; - let headURI = (await asImageDataURI(filePath, folderManager.repository)) || filePath; + let parentURI = (await asTempStorageURI(parentFilePath, folderManager.repository)) || parentFilePath; + let headURI = (await asTempStorageURI(filePath, folderManager.repository)) || filePath; if (parentURI.scheme === 'data' || headURI.scheme === 'data') { if (status === GitChangeType.ADD) { parentURI = EMPTY_IMAGE_URI; @@ -59,27 +57,25 @@ async function openDiffCommand( return { command: 'vscode.diff', arguments: [parentURI, headURI, `${pathSegments[pathSegments.length - 1]} (Pull Request)`, opts], - title: 'Open Changed File in PR', + title: 'Open Changed File in pull request', }; } /** * File change node whose content is stored in memory and resolved when being revealed. */ -export class FileChangeNode extends TreeNode implements vscode.TreeItem2 { +export class FileChangeNode extends TreeNode implements vscode.TreeItem { public iconPath?: | string | vscode.Uri - | { light: string | vscode.Uri; dark: string | vscode.Uri } + | { light: vscode.Uri; dark: vscode.Uri } | vscode.ThemeIcon; public fileChangeResourceUri: vscode.Uri; public contextValue: string; public command: vscode.Command; public opts: vscode.TextDocumentShowOptions; - public checkboxState: { state: vscode.TreeItemCheckboxState; tooltip?: string }; - - public childrenDisposables: vscode.Disposable[] = []; + public checkboxState?: { state: vscode.TreeItemCheckboxState; tooltip?: string; accessibilityInformation: vscode.AccessibilityInformation }; get status(): GitChangeType { return this.changeModel.status; @@ -102,20 +98,18 @@ export class FileChangeNode extends TreeNode implements vscode.TreeItem2 { } constructor( - public parent: TreeNodeParent, + parent: TreeNodeParent, protected readonly pullRequestManager: FolderRepositoryManager, public readonly pullRequest: PullRequestModel & IResolvedPullRequestModel, public readonly changeModel: FileChangeModel ) { - super(); + super(parent); const viewed = this.pullRequest.fileChangeViewedState[this.changeModel.fileName] ?? ViewedState.UNVIEWED; this.contextValue = `${Schemes.FileChange}:${GitChangeType[this.changeModel.status]}:${viewed === ViewedState.VIEWED ? 'viewed' : 'unviewed' }`; this.label = path.basename(this.changeModel.fileName); this.iconPath = vscode.ThemeIcon.File; - this.opts = { - preserveFocus: true, - }; + this.opts = {}; this.updateShowOptions(); this.fileChangeResourceUri = toResourceUri( vscode.Uri.file(this.changeModel.fileName), @@ -136,7 +130,7 @@ export class FileChangeNode extends TreeNode implements vscode.TreeItem2 { this.childrenDisposables.push( this.pullRequest.onDidChangeFileViewedState(e => { - const matchingChange = e.changed.find(viewStateChange => viewStateChange.fileName === this.changeModel.fileName); + const matchingChange = e.changed.find(viewStateChange => (viewStateChange.fileName === this.changeModel.fileName) && (viewStateChange.viewed !== this.changeModel.viewed)); if (matchingChange) { this.updateViewed(matchingChange.viewed); this.refresh(this); @@ -144,15 +138,16 @@ export class FileChangeNode extends TreeNode implements vscode.TreeItem2 { }), ); - this.accessibilityInformation = { label: `View diffs and comments for file ${this.label}`, role: 'link' }; + this.accessibilityInformation = { label: `${this.label} pull request diff`, role: 'link' }; + this.description = this._getDescription(); } get resourceUri(): vscode.Uri { return this.changeModel.filePath.with({ query: this.fileChangeResourceUri.query }); } - get description(): string | true { - const layout = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get<string>(FILE_LIST_LAYOUT); + protected _getDescription(): string | true { + const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<string>(FILE_LIST_LAYOUT); if (layout === 'flat') { return true; } else { @@ -160,43 +155,44 @@ export class FileChangeNode extends TreeNode implements vscode.TreeItem2 { } } + /** + * Check if this file node is under a commit node in the tree hierarchy. + * Files under commit nodes should not have checkboxes. + */ + private isUnderCommitNode(): boolean { + // If the file's sha is different from the PR's head sha, it's from an older commit + // and should not have a checkbox + return this.changeModel.sha !== undefined && this.changeModel.sha !== this.pullRequest.head?.sha; + } + updateViewed(viewed: ViewedState) { this.changeModel.updateViewed(viewed); this.contextValue = `${Schemes.FileChange}:${GitChangeType[this.changeModel.status]}:${viewed === ViewedState.VIEWED ? 'viewed' : 'unviewed' }`; - this.checkboxState = viewed === ViewedState.VIEWED ? - { state: vscode.TreeItemCheckboxState.Checked, tooltip: 'unmark file as viewed' } : - { state: vscode.TreeItemCheckboxState.Unchecked, tooltip: 'mark file as viewed' }; + // Don't show checkboxes for files under commit nodes + if (!this.isUnderCommitNode()) { + this.checkboxState = viewed === ViewedState.VIEWED ? + { state: vscode.TreeItemCheckboxState.Checked, tooltip: vscode.l10n.t('Mark File as Unviewed'), accessibilityInformation: { label: vscode.l10n.t('Mark file {0} as unviewed', this.label ?? '') } } : + { state: vscode.TreeItemCheckboxState.Unchecked, tooltip: vscode.l10n.t('Mark File as Viewed'), accessibilityInformation: { label: vscode.l10n.t('Mark file {0} as viewed', this.label ?? '') } }; + } else { + this.checkboxState = undefined; + } + this.pullRequestManager.setFileViewedContext(); } - public async markFileAsViewed() { - await this.pullRequest.markFileAsViewed(this.fileName); + public async markFileAsViewed(fromCheckboxChanged: boolean = true) { + await this.pullRequest.markFiles([this.fileName], !fromCheckboxChanged, 'viewed'); this.pullRequestManager.setFileViewedContext(); } - public async unmarkFileAsViewed() { - await this.pullRequest.unmarkFileAsViewed(this.fileName); + public async unmarkFileAsViewed(fromCheckboxChanged: boolean = true) { + await this.pullRequest.markFiles([this.fileName], !fromCheckboxChanged, 'unviewed'); this.pullRequestManager.setFileViewedContext(); } - updateCheckbox(newState: vscode.TreeItemCheckboxState) { + override updateFromCheckboxChanged(newState: vscode.TreeItemCheckboxState) { const viewed = newState === vscode.TreeItemCheckboxState.Checked ? ViewedState.VIEWED : ViewedState.UNVIEWED; this.updateViewed(viewed); - - async function markFile(node: FileChangeNode) { - if (newState === vscode.TreeItemCheckboxState.Checked) { - await node.markFileAsViewed(); - } - else { - await node.unmarkFileAsViewed(); - } - } - - markFile(this).then(_ => { - if (this.parent instanceof TreeNode && !this.parent.updateParentCheckbox()) { - this.refresh(this); - } - }); } updateShowOptions() { @@ -204,13 +200,6 @@ export class FileChangeNode extends TreeNode implements vscode.TreeItem2 { const reviewThreadsByFile = groupBy(reviewThreads, thread => thread.path); const reviewThreadsForNode = (reviewThreadsByFile[this.changeModel.fileName] || []).filter(thread => !thread.isOutdated); - DecorationProvider.updateFileComments( - this.fileChangeResourceUri, - this.pullRequest.number, - this.changeModel.fileName, - reviewThreadsForNode.length > 0, - ); - if (reviewThreadsForNode.length) { reviewThreadsForNode.sort((a, b) => a.endLine - b.endLine); const startLine = reviewThreadsForNode[0].startLine ?? reviewThreadsForNode[0].originalStartLine; @@ -240,7 +229,7 @@ export class FileChangeNode extends TreeNode implements vscode.TreeItem2 { }, this.changeModel.status, ); - vscode.commands.executeCommand(command.command, ...(command.arguments ?? [])); + return vscode.commands.executeCommand(command.command, ...(command.arguments ?? [])); } } @@ -248,7 +237,7 @@ export class FileChangeNode extends TreeNode implements vscode.TreeItem2 { * File change node whose content can not be resolved locally and we direct users to GitHub. */ export class RemoteFileChangeNode extends FileChangeNode implements vscode.TreeItem { - get description(): string { + protected override _getDescription(): string { let description = vscode.workspace.asRelativePath(path.dirname(this.changeModel.fileName), false); if (description === '.') { description = ''; @@ -257,13 +246,13 @@ export class RemoteFileChangeNode extends FileChangeNode implements vscode.TreeI } constructor( - public parent: TreeNodeParent, + parent: TreeNodeParent, folderRepositoryManager: FolderRepositoryManager, pullRequest: PullRequestModel & IResolvedPullRequestModel, changeModel: RemoteFileChangeModel ) { super(parent, folderRepositoryManager, pullRequest, changeModel); - this.fileChangeResourceUri = toResourceUri(vscode.Uri.parse(changeModel.blobUrl), changeModel.pullRequest.number, changeModel.fileName, changeModel.status, changeModel.previousFileName); + this.fileChangeResourceUri = toResourceUri(vscode.Uri.parse(changeModel.blobUrl!), changeModel.pullRequest.number, changeModel.fileName, changeModel.status, changeModel.previousFileName); this.command = { command: 'pr.openFileOnGitHub', title: 'Open File on GitHub', @@ -271,11 +260,11 @@ export class RemoteFileChangeNode extends FileChangeNode implements vscode.TreeI }; } - async openDiff(): Promise<void> { + override async openDiff(): Promise<void> { return vscode.commands.executeCommand(this.command.command); } - openFileCommand(): vscode.Command { + override openFileCommand(): vscode.Command { return this.command; } } @@ -286,9 +275,9 @@ export class RemoteFileChangeNode extends FileChangeNode implements vscode.TreeI export class InMemFileChangeNode extends FileChangeNode implements vscode.TreeItem { constructor( private readonly folderRepositoryManager: FolderRepositoryManager, - public parent: TreeNodeParent, - public readonly pullRequest: PullRequestModel & IResolvedPullRequestModel, - public readonly changeModel: InMemFileChangeModel + parent: TreeNodeParent, + pullRequest: PullRequestModel & IResolvedPullRequestModel, + changeModel: InMemFileChangeModel ) { super(parent, folderRepositoryManager, pullRequest, changeModel); } @@ -297,18 +286,18 @@ export class InMemFileChangeNode extends FileChangeNode implements vscode.TreeIt return this.pullRequest.comments.filter(comment => (comment.path === this.changeModel.fileName) && (comment.position !== null)); } - getTreeItem(): vscode.TreeItem { - return this; - } - async resolve(): Promise<void> { - this.command = await openDiffCommand( - this.folderRepositoryManager, - this.changeModel.parentFilePath, - this.changeModel.filePath, - undefined, - this.changeModel.status, - ); + if (this.status === GitChangeType.ADD) { + this.command = openFileCommand(this.changeModel.filePath); + } else { + this.command = await openDiffCommand( + this.folderRepositoryManager, + this.changeModel.parentFilePath, + this.changeModel.filePath, + undefined, + this.changeModel.status, + ); + } } } @@ -317,10 +306,10 @@ export class InMemFileChangeNode extends FileChangeNode implements vscode.TreeIt */ export class GitFileChangeNode extends FileChangeNode implements vscode.TreeItem { constructor( - public parent: TreeNodeParent, + parent: TreeNodeParent, pullRequestManager: FolderRepositoryManager, - public readonly pullRequest: PullRequestModel & IResolvedPullRequestModel, - public readonly changeModel: GitFileChangeModel, + pullRequest: PullRequestModel & IResolvedPullRequestModel, + changeModel: GitFileChangeModel, private isCurrent?: boolean, private _comments?: IComment[] ) { @@ -360,8 +349,8 @@ export class GitFileChangeNode extends FileChangeNode implements vscode.TreeItem command: 'vscode.diff', arguments: this.status === GitChangeType.DELETE - ? [this.changeModel.parentFilePath, emptyFileUri, `${this.fileName}`, { preserveFocus: true }] - : [emptyFileUri, this.changeModel.parentFilePath, `${this.fileName}`, { preserveFocus: true }], + ? [this.changeModel.parentFilePath, emptyFileUri, `${this.fileName}`, {}] + : [emptyFileUri, this.changeModel.parentFilePath, `${this.fileName}`, {}], title: 'Open Diff', }; } @@ -385,9 +374,7 @@ export class GitFileChangeNode extends FileChangeNode implements vscode.TreeItem currentFilePath = this.pullRequestManager.repository.rootUri.with({ path: path.posix.join(query.rootPath, query.path) }); } - const options: vscode.TextDocumentShowOptions = { - preserveFocus: true, - }; + const options: vscode.TextDocumentShowOptions = {}; const reviewThreads = this.pullRequest.reviewThreadsCache; const reviewThreadsByFile = groupBy(reviewThreads, t => t.path); @@ -416,7 +403,7 @@ export class GitFileChangeNode extends FileChangeNode implements vscode.TreeItem this.command = await this.alternateCommand(); } else { const openDiff = vscode.workspace.getConfiguration(GIT, this.pullRequestManager.repository.rootUri).get(OPEN_DIFF_ON_CLICK, true); - if (openDiff) { + if (openDiff && this.status !== GitChangeType.ADD) { this.command = await openDiffCommand( this.pullRequestManager, this.changeModel.parentFilePath, @@ -435,56 +422,43 @@ export class GitFileChangeNode extends FileChangeNode implements vscode.TreeItem * File change node whose content is resolved from GitHub. For files not yet associated with a pull request. */ export class GitHubFileChangeNode extends TreeNode implements vscode.TreeItem { - public description: string; public iconPath: vscode.ThemeIcon; public fileChangeResourceUri: vscode.Uri; - + public readonly tooltip: string; public command: vscode.Command; constructor( - public readonly parent: TreeNodeParent, + parent: TreeNodeParent, public readonly fileName: string, public readonly previousFileName: string | undefined, public readonly status: GitChangeType, public readonly baseBranch: string, public readonly headBranch: string, + public readonly isLocal: boolean ) { - super(); + super(parent); + const scheme = isLocal ? Schemes.GitPr : Schemes.GithubPr; this.label = fileName; + this.tooltip = fileName; this.iconPath = vscode.ThemeIcon.File; this.fileChangeResourceUri = vscode.Uri.file(fileName).with({ - scheme: Schemes.GithubPr, + scheme, query: JSON.stringify({ status, fileName }), }); - let parentURI = vscode.Uri.file(fileName).with({ - scheme: Schemes.GithubPr, - query: JSON.stringify({ fileName, branch: baseBranch }), - }); - let headURI = vscode.Uri.file(fileName).with({ - scheme: Schemes.GithubPr, - query: JSON.stringify({ fileName, branch: headBranch }), - }); + let parentURI = toGitHubUri(vscode.Uri.file(fileName), scheme, { fileName, branch: baseBranch }); + let headURI = toGitHubUri(vscode.Uri.file(fileName), scheme, { fileName, branch: headBranch }); switch (status) { case GitChangeType.ADD: - parentURI = vscode.Uri.file(fileName).with({ - scheme: Schemes.GithubPr, - query: JSON.stringify({ fileName, branch: baseBranch, isEmpty: true }), - }); + parentURI = toGitHubUri(vscode.Uri.file(fileName), scheme, { fileName, branch: baseBranch, isEmpty: true }); break; case GitChangeType.RENAME: - parentURI = vscode.Uri.file(previousFileName!).with({ - scheme: Schemes.GithubPr, - query: JSON.stringify({ fileName: previousFileName, branch: baseBranch, isEmpty: true }), - }); + parentURI = toGitHubUri(vscode.Uri.file(previousFileName!), scheme, { fileName: previousFileName!, branch: baseBranch, isEmpty: true }); break; case GitChangeType.DELETE: - headURI = vscode.Uri.file(fileName).with({ - scheme: Schemes.GithubPr, - query: JSON.stringify({ fileName, branch: headBranch, isEmpty: true }), - }); + headURI = toGitHubUri(vscode.Uri.file(fileName), scheme, { fileName, branch: headBranch, isEmpty: true }); break; } diff --git a/src/view/treeNodes/filesCategoryNode.ts b/src/view/treeNodes/filesCategoryNode.ts index 6e66222dd3..ec414574b9 100644 --- a/src/view/treeNodes/filesCategoryNode.ts +++ b/src/view/treeNodes/filesCategoryNode.ts @@ -4,23 +4,26 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { ViewedState } from '../../common/comment'; import Logger, { PR_TREE } from '../../common/logger'; +import { FILE_LIST_LAYOUT, HIDE_VIEWED_FILES, PR_SETTINGS_NAMESPACE } from '../../common/settingKeys'; +import { compareIgnoreCase } from '../../common/utils'; import { PullRequestModel } from '../../github/pullRequestModel'; import { ReviewModel } from '../reviewModel'; import { DirectoryTreeNode } from './directoryTreeNode'; import { LabelOnlyNode, TreeNode, TreeNodeParent } from './treeNode'; export class FilesCategoryNode extends TreeNode implements vscode.TreeItem { - public label: string = vscode.l10n.t('Files'); + public override readonly label: string = vscode.l10n.t('Files'); public collapsibleState: vscode.TreeItemCollapsibleState; private directories: TreeNode[] = []; constructor( - public parent: TreeNodeParent, + parent: TreeNodeParent, private _reviewModel: ReviewModel, _pullRequestModel: PullRequestModel ) { - super(); + super(parent); this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; this.childrenDisposables = []; this.childrenDisposables.push(this._reviewModel.onDidChangeLocalFileChanges(() => { @@ -31,18 +34,30 @@ export class FilesCategoryNode extends TreeNode implements vscode.TreeItem { Logger.appendLine(`Review threads have changed, refreshing Files node`, PR_TREE); this.refresh(this); })); - this.childrenDisposables.push(_pullRequestModel.onDidChangeComments(() => { - Logger.appendLine(`Comments have changed, refreshing Files node`, PR_TREE); + this.childrenDisposables.push(_pullRequestModel.onDidChange(e => { + if (e.comments) { + Logger.appendLine(`Comments have changed, refreshing Files node`, PR_TREE); + this.refresh(this); + } + })); + this.childrenDisposables.push(_pullRequestModel.onDidChangeFileViewedState(() => { + Logger.appendLine(`File viewed state has changed, refreshing Files node`, PR_TREE); this.refresh(this); })); + this.childrenDisposables.push(vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${HIDE_VIEWED_FILES}`)) { + Logger.appendLine(`Hide viewed files setting has changed, refreshing Files node`, PR_TREE); + this.refresh(this); + } + })); } getTreeItem(): vscode.TreeItem { return this; } - async getChildren(): Promise<TreeNode[]> { - super.getChildren(); + override async getChildren(): Promise<TreeNode[]> { + super.getChildren(false); Logger.appendLine(`Getting children for Files node`, PR_TREE); if (!this._reviewModel.hasLocalFileChanges) { @@ -56,18 +71,28 @@ export class FilesCategoryNode extends TreeNode implements vscode.TreeItem { } if (this._reviewModel.localFileChanges.length === 0) { - return [new LabelOnlyNode(vscode.l10n.t('No changed files'))]; + return [new LabelOnlyNode(this, vscode.l10n.t('No changed files'))]; } let nodes: TreeNode[]; - const layout = vscode.workspace.getConfiguration('githubPullRequests').get<string>('fileListLayout'); + const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<string>(FILE_LIST_LAYOUT); + const hideViewedFiles = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<boolean>(HIDE_VIEWED_FILES, false); + + // Filter files based on hideViewedFiles setting + const filesToShow = hideViewedFiles + ? this._reviewModel.localFileChanges.filter(f => f.changeModel.viewed !== ViewedState.VIEWED) + : this._reviewModel.localFileChanges; + + if (filesToShow.length === 0 && hideViewedFiles) { + return [new LabelOnlyNode(this, vscode.l10n.t('All files viewed'))]; + } const dirNode = new DirectoryTreeNode(this, ''); - this._reviewModel.localFileChanges.forEach(f => dirNode.addFile(f)); + filesToShow.forEach(f => dirNode.addFile(f)); dirNode.finalize(); if (dirNode.label === '') { // nothing on the root changed, pull children to parent - this.directories = dirNode.children; + this.directories = dirNode._children; } else { this.directories = [dirNode]; } @@ -75,10 +100,12 @@ export class FilesCategoryNode extends TreeNode implements vscode.TreeItem { if (layout === 'tree') { nodes = this.directories; } else { - nodes = this._reviewModel.localFileChanges; + const fileNodes = [...filesToShow]; + fileNodes.sort((a, b) => compareIgnoreCase(a.fileChangeResourceUri.toString(), b.fileChangeResourceUri.toString())); + nodes = fileNodes; } Logger.appendLine(`Got all children for Files node`, PR_TREE); - this.children = nodes; + this._children = nodes; return nodes; } } diff --git a/src/view/treeNodes/pullRequestNode.ts b/src/view/treeNodes/pullRequestNode.ts index 19a3df4d92..23a935ca4d 100644 --- a/src/view/treeNodes/pullRequestNode.ts +++ b/src/view/treeNodes/pullRequestNode.ts @@ -4,29 +4,31 @@ *--------------------------------------------------------------------------------------------*/ 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'; import { InMemFileChange, SlimFileChange } from '../../common/file'; import Logger from '../../common/logger'; -import { FILE_LIST_LAYOUT } from '../../common/settingKeys'; -import { createPRNodeUri, fromPRUri, Schemes } from '../../common/uri'; -import { dispose } from '../../common/utils'; -import { FolderRepositoryManager, SETTINGS_NAMESPACE } from '../../github/folderRepositoryManager'; -import { NotificationProvider } from '../../github/notifications'; +import { FILE_LIST_LAYOUT, LIST_HORIZONTAL_SCROLLING, PR_SETTINGS_NAMESPACE, SHOW_PULL_REQUEST_NUMBER_IN_TREE, WORKBENCH } from '../../common/settingKeys'; +import { createPRNodeUri, DataUri, fromPRUri, Schemes } from '../../common/uri'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; +import { CopilotWorkingStatus } from '../../github/githubRepository'; import { IResolvedPullRequestModel, PullRequestModel } from '../../github/pullRequestModel'; import { InMemFileChangeModel, RemoteFileChangeModel } from '../fileChangeModel'; import { getInMemPRFileSystemProvider, provideDocumentContentForChangeModel } from '../inMemPRContentProvider'; -import { DescriptionNode } from './descriptionNode'; +import { getIconForeground, getListErrorForeground, getListWarningForeground, getNotebookStatusSuccessIconForeground } from '../theme'; 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.CommentingRangeProvider { +export class PRNode extends TreeNode implements vscode.CommentingRangeProvider2 { static ID = 'PRNode'; private _fileChanges: (RemoteFileChangeNode | InMemFileChangeNode)[] | undefined; private _commentController?: vscode.CommentController; - private _disposables: vscode.Disposable[] = []; private _inMemPRContentProvider?: vscode.Disposable; @@ -45,33 +47,35 @@ export class PRNode extends TreeNode implements vscode.CommentingRangeProvider { } constructor( - public parent: TreeNodeParent, + parent: TreeNodeParent, private _folderReposManager: FolderRepositoryManager, public pullRequestModel: PullRequestModel, private _isLocal: boolean, - private _notificationProvider: NotificationProvider + private _notificationProvider: NotificationsManager, + private _prsTreeModel: PrsTreeModel, ) { - super(); + super(parent); this.registerSinceReviewChange(); - this._disposables.push(this.pullRequestModel.onDidInvalidate(() => this.refresh(this))); + this.registerConfigurationChange(); + this._register(this._folderReposManager.onDidChangeActivePullRequest(e => { + if (e.new?.number === this.pullRequestModel.number || e.old?.number === this.pullRequestModel.number) { + this.refresh(this); + } + })); + this._register(this._folderReposManager.themeWatcher.onDidChangeTheme(() => { + this.refresh(this); + })); + this.resolvePRCommentController(); } // #region Tree - async getChildren(): Promise<TreeNode[]> { + override async getChildren(): Promise<TreeNode[]> { super.getChildren(); Logger.debug(`Fetch children of PRNode #${this.pullRequestModel.number}`, PRNode.ID); try { - const descriptionNode = new DescriptionNode( - this, - vscode.l10n.t('Description'), - new vscode.ThemeIcon('git-pull-request'), - this.pullRequestModel, - this.repository - ); - if (!this.pullRequestModel.isResolved()) { - return [descriptionNode]; + return []; } [, this._fileChanges, ,] = await Promise.all([ @@ -86,10 +90,13 @@ export class PRNode extends TreeNode implements vscode.CommentingRangeProvider { this.pullRequestModel.number, this.provideDocumentContent.bind(this), ); + if (this._inMemPRContentProvider) { + this._register(this._inMemPRContentProvider); + } } - const result: TreeNode[] = [descriptionNode]; - const layout = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get<string>(FILE_LIST_LAYOUT); + const result: TreeNode[] = []; + const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<string>(FILE_LIST_LAYOUT); if (layout === 'tree') { // tree view const dirNode = new DirectoryTreeNode(this, ''); @@ -97,7 +104,7 @@ export class PRNode extends TreeNode implements vscode.CommentingRangeProvider { dirNode.finalize(); if (dirNode.label === '') { // nothing on the root changed, pull children to parent - result.push(...dirNode.children); + result.push(...dirNode._children); } else { result.push(dirNode); } @@ -110,27 +117,33 @@ export class PRNode extends TreeNode implements vscode.CommentingRangeProvider { this.reopenNewPrDiffs(this.pullRequestModel); } - this.children = result; + this._children = result; // Kick off review thread initialization but don't await it. // Events will be fired later that will cause the tree to update when this is ready. - this.pullRequestModel.initializeReviewThreadCache(); + if (!this.pullRequestModel.reviewThreadsCacheReady) { + this.pullRequestModel.initializeReviewThreadCache(); + } return result; } catch (e) { - Logger.error(e); + Logger.error(`Error getting children ${e}: ${e.message}`, PRNode.ID); return []; } } protected registerSinceReviewChange() { - this._disposables.push( - this.pullRequestModel.onDidChangeChangesSinceReview(_ => { - if (!this.pullRequestModel.isActive) { - this.refresh(); - } - }) - ); + this._register(this.pullRequestModel.onDidChangeChangesSinceReview(_ => { + this.refresh(this); + })); + } + + protected registerConfigurationChange() { + this._register(vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${SHOW_PULL_REQUEST_NUMBER_IN_TREE}`)) { + this.refresh(); + } + })); } public async reopenNewPrDiffs(pullRequest: PullRequestModel) { @@ -176,36 +189,30 @@ export class PRNode extends TreeNode implements vscode.CommentingRangeProvider { await this.pullRequestModel.githubRepository.ensureCommentsController(); this._commentController = this.pullRequestModel.githubRepository.commentsController!; - this._disposables.push( - this.pullRequestModel.githubRepository.commentsHandler!.registerCommentingRangeProvider( - this.pullRequestModel.number, - this, - ), - ); + this._register(this.pullRequestModel.githubRepository.commentsHandler!.registerCommentingRangeProvider( + this.pullRequestModel.number, + this + )); - this._disposables.push( - this.pullRequestModel.githubRepository.commentsHandler!.registerCommentController( - this.pullRequestModel.number, - this.pullRequestModel, - this._folderReposManager, - ), - ); + this._register(this.pullRequestModel.githubRepository.commentsHandler!.registerCommentController( + this.pullRequestModel.number, + this.pullRequestModel, + this._folderReposManager, + )); this.registerListeners(); } private registerListeners(): void { - this._disposables.push( - this.pullRequestModel.onDidChangePendingReviewState(async newDraftMode => { - if (!newDraftMode) { - (await this.getFileChanges()).forEach(fileChange => { - if (fileChange instanceof InMemFileChangeNode) { - fileChange.comments.forEach(c => (c.isDraft = newDraftMode)); - } - }); - } - }), - ); + this._register(this.pullRequestModel.onDidChangePendingReviewState(async newDraftMode => { + if (!newDraftMode) { + (await this.getFileChanges()).forEach(fileChange => { + if (fileChange instanceof InMemFileChangeNode) { + fileChange.comments.forEach(c => (c.isDraft = newDraftMode)); + } + }); + } + })); } public async getFileChanges(noCache: boolean | void): Promise<(RemoteFileChangeNode | InMemFileChangeNode)[]> { @@ -225,11 +232,10 @@ export class PRNode extends TreeNode implements vscode.CommentingRangeProvider { // URIs that will cause the review comment controller to be used. const rawChanges: (SlimFileChange | InMemFileChange)[] = []; const isCurrentPR = this.pullRequestModel.equals(this._folderReposManager.activePullRequest); - if (isCurrentPR && this._folderReposManager.activePullRequest !== undefined) { + if (isCurrentPR && (this._folderReposManager.activePullRequest !== undefined) && (this._folderReposManager.activePullRequest.fileChanges.size > 0)) { this.pullRequestModel = this._folderReposManager.activePullRequest; rawChanges.push(...this._folderReposManager.activePullRequest.fileChanges.values()); - } - else { + } else { rawChanges.push(...await this.pullRequestModel.getFileChangesInfo()); } @@ -264,47 +270,110 @@ export class PRNode extends TreeNode implements vscode.CommentingRangeProvider { }); } - getTreeItem(): vscode.TreeItem { + private async _getAuthorIcon(): Promise<vscode.Uri | vscode.ThemeIcon> { + // For enterprise, use placeholder icon instead of trying to fetch avatar + if (!DataUri.isGitHubDotComAvatar(this.pullRequestModel.author.avatarUrl)) { + return new vscode.ThemeIcon('github'); + } + return (await DataUri.avatarCirclesAsImageDataUris(this._folderReposManager.context, [this.pullRequestModel.author], 16, 16))[0] + ?? new vscode.ThemeIcon('github'); + } + + private async _getIcon(): Promise<vscode.Uri | vscode.ThemeIcon | { light: vscode.Uri; dark: vscode.Uri }> { + const copilotWorkingStatus = await this.pullRequestModel.copilotWorkingStatus(); + const theme = this._folderReposManager.themeWatcher.themeData; + if (copilotWorkingStatus === CopilotWorkingStatus.NotCopilotIssue) { + return this._getAuthorIcon(); + } + switch (copilotWorkingStatus) { + case CopilotWorkingStatus.InProgress: + return { + light: DataUri.copilotInProgressAsImageDataURI(getIconForeground(theme, 'light'), getListWarningForeground(theme, 'light')), + dark: DataUri.copilotInProgressAsImageDataURI(getIconForeground(theme, 'dark'), getListWarningForeground(theme, 'dark')) + }; + case CopilotWorkingStatus.Done: + return { + light: DataUri.copilotSuccessAsImageDataURI(getIconForeground(theme, 'light'), getNotebookStatusSuccessIconForeground(theme, 'light')), + dark: DataUri.copilotSuccessAsImageDataURI(getIconForeground(theme, 'dark'), getNotebookStatusSuccessIconForeground(theme, 'dark')) + }; + case CopilotWorkingStatus.Error: + return { + light: DataUri.copilotErrorAsImageDataURI(getIconForeground(theme, 'light'), getListErrorForeground(theme, 'light')), + dark: DataUri.copilotErrorAsImageDataURI(getIconForeground(theme, 'dark'), getListErrorForeground(theme, 'dark')) + }; + default: + return this._getAuthorIcon(); + } + } + + 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; + if (currentBranchIsForThisPR) { + label += '$(check) '; + } + + if ( + vscode.workspace + .getConfiguration(PR_SETTINGS_NAMESPACE) + .get<boolean>(SHOW_PULL_REQUEST_NUMBER_IN_TREE, false) + ) { + label += `#${number}: `; + } + + const horizontalScrolling = vscode.workspace.getConfiguration(WORKBENCH).get<boolean>(LIST_HORIZONTAL_SCROLLING, false); + let labelTitle = (horizontalScrolling && 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 { login } = author; + if (isDraft) { + label = `_${label}_`; + } - const hasNotification = this._notificationProvider.hasNotification(this.pullRequestModel); + return label; + } - const labelPrefix = currentBranchIsForThisPR ? '✓ ' : ''; - const tooltipPrefix = currentBranchIsForThisPR ? 'Current Branch * ' : ''; - const formattedPRNumber = number.toString(); - const label = `${labelPrefix}${isDraft ? '[DRAFT] ' : ''}${title}`; - const tooltip = `${tooltipPrefix}${title} by @${login}`; + async getTreeItem(): Promise<vscode.TreeItem> { + 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'), + command: 'pr.openDescription', + arguments: [this], + }; return { - label, - id: `${this.parent instanceof TreeNode ? (this.parent.id ?? this.parent.label) : ''}${html_url}`, // unique id stable across checkout status - tooltip, + 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, contextValue: 'pullrequest' + (this._isLocal ? ':local' : '') + (currentBranchIsForThisPR ? ':active' : ':nonactive') + - (hasNotification ? ':notification' : ''), - iconPath: this.pullRequestModel.userAvatarUri - ? this.pullRequestModel.userAvatarUri - : new vscode.ThemeIcon('github'), + (hasNotification ? ':notification' : '') + + (((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 }; } - async provideCommentingRanges( - document: vscode.TextDocument, - _token: vscode.CancellationToken, - ): Promise<vscode.Range[] | undefined> { + async provideCommentingRanges(document: vscode.TextDocument, _token: vscode.CancellationToken): Promise<vscode.Range[] | { enableFileComments: boolean; ranges?: vscode.Range[] } | undefined> { if (document.uri.scheme === Schemes.Pr) { const params = fromPRUri(document.uri); @@ -318,14 +387,14 @@ export class PRNode extends TreeNode implements vscode.CommentingRangeProvider { return undefined; } - return getCommentingRanges(await fileChange.changeModel.diffHunks(), params.isBase, PRNode.ID); + return { ranges: getCommentingRanges(await fileChange.changeModel.diffHunks(), params.isBase, PRNode.ID), enableFileComments: true }; } return undefined; } // #region Document Content Provider - private async provideDocumentContent(uri: vscode.Uri): Promise<string> { + private async provideDocumentContent(uri: vscode.Uri): Promise<string | Uint8Array> { const params = fromPRUri(uri); if (!params) { return ''; @@ -343,16 +412,8 @@ export class PRNode extends TreeNode implements vscode.CommentingRangeProvider { return provideDocumentContentForChangeModel(this._folderReposManager, this.pullRequestModel, params, fileChange); } - dispose(): void { + override dispose(): void { super.dispose(); - - if (this._inMemPRContentProvider) { - this._inMemPRContentProvider.dispose(); - } - this._commentController = undefined; - - dispose(this._disposables); - this._disposables = []; } } diff --git a/src/view/treeNodes/repositoryChangesNode.ts b/src/view/treeNodes/repositoryChangesNode.ts index 33e2b28710..5c42145529 100644 --- a/src/view/treeNodes/repositoryChangesNode.ts +++ b/src/view/treeNodes/repositoryChangesNode.ts @@ -4,61 +4,76 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { Repository } from '../../api/api'; import Logger, { PR_TREE } from '../../common/logger'; -import { Schemes } from '../../common/uri'; +import { FILE_AUTO_REVEAL, PR_SETTINGS_NAMESPACE } from '../../common/settingKeys'; +import { DataUri, Schemes } from '../../common/uri'; import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; import { PullRequestModel } from '../../github/pullRequestModel'; import { ProgressHelper } from '../progress'; import { ReviewModel } from '../reviewModel'; import { CommitsNode } from './commitsCategoryNode'; -import { DescriptionNode } from './descriptionNode'; import { FilesCategoryNode } from './filesCategoryNode'; import { BaseTreeNode, TreeNode } from './treeNode'; -export class RepositoryChangesNode extends DescriptionNode implements vscode.TreeItem { +export class RepositoryChangesNode extends TreeNode implements vscode.TreeItem { private _filesCategoryNode?: FilesCategoryNode; private _commitsCategoryNode?: CommitsNode; + public command?: vscode.Command; + public contextValue?: string; + public tooltip: string; + public iconPath: vscode.ThemeIcon | vscode.Uri | undefined; readonly collapsibleState = vscode.TreeItemCollapsibleState.Expanded; - - private _disposables: vscode.Disposable[] = []; + private isLocal: boolean; + public readonly repository: Repository; constructor( - public parent: BaseTreeNode, - private _pullRequest: PullRequestModel, + public override parent: BaseTreeNode, + public readonly pullRequestModel: PullRequestModel, private _pullRequestManager: FolderRepositoryManager, private _reviewModel: ReviewModel, private _progress: ProgressHelper ) { - super(parent, _pullRequest.title, _pullRequest.userAvatarUri!, _pullRequest, _pullRequestManager.repository); + super(parent); + this.isLocal = true; + this.repository = _pullRequestManager.repository; + this.label = pullRequestModel.title; + + this.command = { + title: vscode.l10n.t('View Pull Request Description'), + command: 'pr.openDescription', + arguments: [this], + }; + this.tooltip = vscode.l10n.t('Description of pull request #{0}', pullRequestModel.number); + this.accessibilityInformation = { label: vscode.l10n.t('Pull request page of pull request number {0}', pullRequestModel.number), role: 'button' }; + // Cause tree values to be filled this.getTreeItem(); - this._disposables.push( - vscode.window.onDidChangeActiveTextEditor(e => { - if (vscode.workspace.getConfiguration('explorer').get('autoReveal')) { - const tabInput = vscode.window.tabGroups.activeTabGroup.activeTab?.input; - if (tabInput instanceof vscode.TabInputTextDiff) { - if ((tabInput.original.scheme === Schemes.Review) - && (tabInput.modified.scheme !== Schemes.Review) - && (tabInput.original.path.startsWith('/commit'))) { - return; - } + this._register(vscode.window.onDidChangeActiveTextEditor(e => { + if (vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FILE_AUTO_REVEAL)) { + const tabInput = vscode.window.tabGroups.activeTabGroup.activeTab?.input; + if (tabInput instanceof vscode.TabInputTextDiff) { + if ((tabInput.original.scheme === Schemes.Review) + && (tabInput.modified.scheme !== Schemes.Review) + && (tabInput.original.path.startsWith('/commit'))) { + return; } - const activeEditorUri = e?.document.uri.toString(); - this.revealActiveEditorInTree(activeEditorUri); } - }), - ); - - this._disposables.push( - this.parent.view.onDidChangeVisibility(_ => { - const activeEditorUri = vscode.window.activeTextEditor?.document.uri.toString(); + const activeEditorUri = e?.document.uri.toString(); this.revealActiveEditorInTree(activeEditorUri); - }), - ); + } + })); - this._disposables.push(_pullRequest.onDidInvalidate(() => { - this.refresh(); + this._register(this.parent.view.onDidChangeVisibility(_ => { + const activeEditorUri = vscode.window.activeTextEditor?.document.uri.toString(); + this.revealActiveEditorInTree(activeEditorUri); + })); + + this._register(this.pullRequestModel.onDidChange(e => { + if (e.title || e.state) { + this.refresh(); + } })); } @@ -71,30 +86,58 @@ export class RepositoryChangesNode extends DescriptionNode implements vscode.Tre } } - async getChildren(): Promise<TreeNode[]> { + override async getChildren(): Promise<TreeNode[]> { await this._progress.progress; if (!this._filesCategoryNode || !this._commitsCategoryNode) { Logger.appendLine(`Creating file and commit nodes for PR #${this.pullRequestModel.number}`, PR_TREE); - this._filesCategoryNode = new FilesCategoryNode(this.parent, this._reviewModel, this._pullRequest); + this._filesCategoryNode = new FilesCategoryNode(this.parent, this._reviewModel, this.pullRequestModel); this._commitsCategoryNode = new CommitsNode( this.parent, this._pullRequestManager, - this._pullRequest, + this.pullRequestModel, ); } - this.children = [this._filesCategoryNode, this._commitsCategoryNode]; - return this.children; + this._children = [this._filesCategoryNode, this._commitsCategoryNode]; + return this._children; + } + + private setLabel() { + this.label = this.pullRequestModel.title; + if (this.label.length > 50) { + this.tooltip = this.label; + this.label = `${this.label.substring(0, 50)}...`; + } } - getTreeItem(): vscode.TreeItem { - this.label = this._pullRequest.title; + override async getTreeItem(): Promise<vscode.TreeItem> { + this.setLabel(); + // For enterprise, use placeholder icon instead of trying to fetch avatar + if (!DataUri.isGitHubDotComAvatar(this.pullRequestModel.author.avatarUrl)) { + this.iconPath = new vscode.ThemeIcon('github'); + } else { + this.iconPath = (await DataUri.avatarCirclesAsImageDataUris(this._pullRequestManager.context, [this.pullRequestModel.author], 16, 16))[0]; + } + this.description = undefined; + if (this.parent.children?.length && this.parent.children.length > 1) { + const allSameOwner = this.parent.children.every(child => { + return child instanceof RepositoryChangesNode && child.pullRequestModel.remote.owner === this.pullRequestModel.remote.owner; + }); + if (allSameOwner) { + this.description = this.pullRequestModel.remote.repositoryName; + } else { + this.description = `${this.pullRequestModel.remote.owner}/${this.pullRequestModel.remote.repositoryName}`; + } + } this.updateContextValue(); return this; } - dispose() { - super.dispose(); - this._disposables.forEach(d => d.dispose()); - this._disposables = []; + protected updateContextValue(): void { + const currentBranchIsForThisPR = this.pullRequestModel.equals(this._pullRequestManager.activePullRequest); + this.contextValue = 'description' + + (currentBranchIsForThisPR ? ':active' : ':nonactive') + + (this.pullRequestModel.hasChangesSinceLastReview ? ':hasChangesSinceReview' : '') + + (this.pullRequestModel.showChangesSinceReview ? ':showingChangesSinceReview' : ':showingAllChanges') + + (((this.pullRequestModel.item.isRemoteHeadDeleted && !this.isLocal) || !this._pullRequestManager.isPullRequestAssociatedWithOpenRepository(this.pullRequestModel)) ? '' : ':hasHeadRef'); } } diff --git a/src/view/treeNodes/treeNode.ts b/src/view/treeNodes/treeNode.ts index 9d86fbdf6d..399cbc1dca 100644 --- a/src/view/treeNodes/treeNode.ts +++ b/src/view/treeNodes/treeNode.ts @@ -4,35 +4,44 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { Disposable, disposeAll } from '../../common/lifecycle'; import Logger from '../../common/logger'; -import { dispose } from '../../common/utils'; export interface BaseTreeNode { reveal(element: TreeNode, options?: { select?: boolean; focus?: boolean; expand?: boolean | number }): Thenable<void>; refresh(treeNode?: TreeNode): void; + children: readonly TreeNode[] | undefined; view: vscode.TreeView<TreeNode>; } export type TreeNodeParent = TreeNode | BaseTreeNode; -export const EXPANDED_QUERIES_STATE = 'expandedQueries'; - -export abstract class TreeNode implements vscode.Disposable { - protected children: TreeNode[]; - childrenDisposables: vscode.Disposable[]; - parent: TreeNodeParent; +export abstract class TreeNode extends Disposable { + protected _children: TreeNode[] | undefined; + childrenDisposables: vscode.Disposable[] = []; label?: string; accessibilityInformation?: vscode.AccessibilityInformation; id?: string; + description?: string | boolean; - constructor() { } - abstract getTreeItem(): vscode.TreeItem; + constructor(public parent: TreeNodeParent) { + super(); + } + + abstract getTreeItem(): vscode.TreeItem | Promise<vscode.TreeItem>; getParent(): TreeNode | undefined { if (this.parent instanceof TreeNode) { return this.parent; } } + get children(): readonly TreeNode[] | undefined { + if (this._children && this._children.length) { + return this._children; + } + return undefined; + } + async reveal( treeNode: TreeNode, options?: { select?: boolean; focus?: boolean; expand?: boolean | number }, @@ -49,36 +58,33 @@ export abstract class TreeNode implements vscode.Disposable { } async cachedChildren(): Promise<TreeNode[]> { - if (this.children && this.children.length) { - return this.children; + if (this._children && this._children.length) { + return this._children; } return this.getChildren(); } - async getChildren(): Promise<TreeNode[]> { - if (this.children && this.children.length) { - dispose(this.children); - this.children = []; + async getChildren(shouldDispose: boolean = true): Promise<TreeNode[]> { + if (this._children && this._children.length && shouldDispose) { + disposeAll(this._children); } return []; } - updateCheckbox(_newState: vscode.TreeItemCheckboxState): void { } + updateFromCheckboxChanged(_newState: vscode.TreeItemCheckboxState): void { } - public updateParentCheckbox(): boolean { return false; } - - dispose(): void { + override dispose(): void { + super.dispose(); if (this.childrenDisposables) { - dispose(this.childrenDisposables); - this.childrenDisposables = []; + disposeAll(this.childrenDisposables); } } } export class LabelOnlyNode extends TreeNode { - public readonly label: string = ''; - constructor(label: string) { - super(); + public override readonly label: string = ''; + constructor(parent: TreeNodeParent, label: string) { + super(parent); this.label = label; } getTreeItem(): vscode.TreeItem { diff --git a/src/view/treeNodes/treeUtils.ts b/src/view/treeNodes/treeUtils.ts new file mode 100644 index 0000000000..94920c8718 --- /dev/null +++ b/src/view/treeNodes/treeUtils.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { FileChangeNode } from './fileChangeNode'; +import { TreeNode } from './treeNode'; + +export namespace TreeUtils { + export function processCheckboxUpdates(checkboxUpdates: vscode.TreeCheckboxChangeEvent<TreeNode>, selection: readonly TreeNode[]) { + const selectionContainsUpdates = selection.some(node => checkboxUpdates.items.some(update => update[0] === node)); + + const checkedNodes: FileChangeNode[] = []; + const uncheckedNodes: FileChangeNode[] = []; + + checkboxUpdates.items.forEach(checkboxUpdate => { + const node = checkboxUpdate[0]; + const newState = checkboxUpdate[1]; + + if (node instanceof FileChangeNode) { + if (newState == vscode.TreeItemCheckboxState.Checked) { + checkedNodes.push(node); + } else { + uncheckedNodes.push(node); + } + } + + node.updateFromCheckboxChanged(newState); + }); + + if (selectionContainsUpdates) { + for (const selected of selection) { + if (!(selected instanceof FileChangeNode)) { + continue; + } + if (!checkedNodes.includes(selected) && !uncheckedNodes.includes(selected)) { + // Only process files that have checkboxes (files without checkboxState, like those under commits, are skipped) + if (selected.checkboxState?.state === vscode.TreeItemCheckboxState.Unchecked) { + checkedNodes.push(selected); + } else if (selected.checkboxState?.state === vscode.TreeItemCheckboxState.Checked) { + uncheckedNodes.push(selected); + } + } + } + } + + if (checkedNodes.length > 0) { + const prModel = checkedNodes[0].pullRequest; + const filenames = checkedNodes.map(n => n.fileName); + prModel.markFiles(filenames, true, 'viewed'); + } + if (uncheckedNodes.length > 0) { + const prModel = uncheckedNodes[0].pullRequest; + const filenames = uncheckedNodes.map(n => n.fileName); + prModel.markFiles(filenames, true, 'unviewed'); + } + } +} \ No newline at end of file diff --git a/src/view/treeNodes/workspaceFolderNode.ts b/src/view/treeNodes/workspaceFolderNode.ts index 02d251bd47..46fd88e494 100644 --- a/src/view/treeNodes/workspaceFolderNode.ts +++ b/src/view/treeNodes/workspaceFolderNode.ts @@ -5,14 +5,15 @@ import * as path from 'path'; import * as vscode from 'vscode'; -import { QUERIES } from '../../common/settingKeys'; +import { PR_SETTINGS_NAMESPACE, QUERIES } from '../../common/settingKeys'; import { ITelemetry } from '../../common/telemetry'; -import { FolderRepositoryManager, SETTINGS_NAMESPACE } from '../../github/folderRepositoryManager'; +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 } from './categoryNode'; -import { EXPANDED_QUERIES_STATE, TreeNode, TreeNodeParent } from './treeNode'; +import { CategoryTreeNode, isAllQuery, isLocalQuery } from './categoryNode'; +import { TreeNode, TreeNodeParent } from './treeNode'; +import { NotificationsManager } from '../../notifications/notificationsManager'; export interface IQueryInfo { label: string; @@ -20,60 +21,75 @@ export interface IQueryInfo { } export class WorkspaceFolderNode extends TreeNode implements vscode.TreeItem { + protected override _children: CategoryTreeNode[] | undefined = undefined; public collapsibleState: vscode.TreeItemCollapsibleState; - public iconPath?: { light: string | vscode.Uri; dark: string | vscode.Uri }; + public iconPath?: { light: vscode.Uri; dark: vscode.Uri }; constructor( parent: TreeNodeParent, uri: vscode.Uri, - private folderManager: FolderRepositoryManager, + public readonly folderManager: FolderRepositoryManager, private telemetry: ITelemetry, - private notificationProvider: NotificationProvider, + private notificationProvider: NotificationsManager, private context: vscode.ExtensionContext, private readonly _prsTreeModel: PrsTreeModel ) { - super(); - this.parent = parent; + super(parent); this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; this.label = path.basename(uri.fsPath); + this.id = folderManager.repository.rootUri.toString(); } - private static getQueries(folderManager: FolderRepositoryManager): IQueryInfo[] { - return ( - vscode.workspace - .getConfiguration(SETTINGS_NAMESPACE, folderManager.repository.rootUri) - .get<IQueryInfo[]>(QUERIES) || [] - ); + public async expandPullRequest(pullRequest: PullRequestModel): Promise<boolean> { + if (this._children) { + for (const child of this._children) { + if (child.type === PRType.All) { + return child.expandPullRequest(pullRequest); + } + } + } + return false; + } + + private static async getQueries(folderManager: FolderRepositoryManager): Promise<IQueryInfo[]> { + const configuration = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE, folderManager.repository.rootUri); + const queries = (configuration.get<IQueryInfo[]>(QUERIES) ?? []); + return queries; } getTreeItem(): vscode.TreeItem { return this; } - async getChildren(): Promise<TreeNode[]> { - super.getChildren(); - this.children = WorkspaceFolderNode.getCategoryTreeNodes(this.folderManager, this.telemetry, this, this.notificationProvider, this.context, this._prsTreeModel); - return this.children; + override async getChildren(shouldDispose: boolean = true): Promise<TreeNode[]> { + super.getChildren(shouldDispose); + if (!shouldDispose && this._children) { + return this._children; + } + this._children = await WorkspaceFolderNode.getCategoryTreeNodes(this.folderManager, this.telemetry, this, this.notificationProvider, this.context, this._prsTreeModel); + return this._children; } - public static getCategoryTreeNodes( + public static async getCategoryTreeNodes( folderManager: FolderRepositoryManager, telemetry: ITelemetry, parent: TreeNodeParent, - notificationProvider: NotificationProvider, + notificationProvider: NotificationsManager, context: vscode.ExtensionContext, - prsTreeModel: PrsTreeModel + prsTreeModel: PrsTreeModel, ) { - const expandedQueries = new Set<string>(context.workspaceState.get(EXPANDED_QUERIES_STATE, [] as string[])); + const queries = await WorkspaceFolderNode.getQueries(folderManager); + const queryCategories: Map<string, CategoryTreeNode> = new Map(); + for (const queryInfo of queries) { + if (isLocalQuery(queryInfo)) { + queryCategories.set(queryInfo.label, new CategoryTreeNode(parent, folderManager, telemetry, PRType.LocalPullRequest, notificationProvider, prsTreeModel)); + } else if (isAllQuery(queryInfo)) { + queryCategories.set(queryInfo.label, new CategoryTreeNode(parent, folderManager, telemetry, PRType.All, notificationProvider, prsTreeModel)); + } else { + queryCategories.set(queryInfo.label, new CategoryTreeNode(parent, folderManager, telemetry, PRType.Query, notificationProvider, prsTreeModel, queryInfo.label, queryInfo.query)); + } + } - const queryCategories = WorkspaceFolderNode.getQueries(folderManager).map( - queryInfo => - new CategoryTreeNode(parent, folderManager, telemetry, PRType.Query, notificationProvider, expandedQueries, prsTreeModel, queryInfo.label, queryInfo.query), - ); - return [ - new CategoryTreeNode(parent, folderManager, telemetry, PRType.LocalPullRequest, notificationProvider, expandedQueries, prsTreeModel), - ...queryCategories, - new CategoryTreeNode(parent, folderManager, telemetry, PRType.All, notificationProvider, expandedQueries, prsTreeModel), - ]; + return Array.from(queryCategories.values()); } } diff --git a/src/view/webviewViewCoordinator.ts b/src/view/webviewViewCoordinator.ts index f72786c81f..da40fc4b3e 100644 --- a/src/view/webviewViewCoordinator.ts +++ b/src/view/webviewViewCoordinator.ts @@ -4,39 +4,40 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { dispose } from '../common/utils'; +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 implements vscode.Disposable { +export class WebviewViewCoordinator extends Disposable { private _webviewViewProvider?: PullRequestViewProvider; private _pullRequestModel: Map<PullRequestModel, { folderRepositoryManager: FolderRepositoryManager, reviewManager: ReviewManager }> = new Map(); - private _disposables: vscode.Disposable[] = []; + private readonly _currentDisposables: Disposable[] = []; - constructor(private _context: vscode.ExtensionContext) { } + constructor(private _context: vscode.ExtensionContext) { + super(); + } + + public override dispose() { + super.dispose(); + this.reset(); + } - dispose() { - dispose(this._disposables); - this._disposables = []; - this._webviewViewProvider?.dispose(); + reset() { + disposeAll(this._currentDisposables); this._webviewViewProvider = undefined; } private create(pullRequestModel: PullRequestModel, folderRepositoryManager: FolderRepositoryManager, reviewManager: ReviewManager) { - this._webviewViewProvider = new PullRequestViewProvider(this._context.extensionUri, folderRepositoryManager, reviewManager, pullRequestModel); - this._disposables.push( - vscode.window.registerWebviewViewProvider( - this._webviewViewProvider.viewType, - this._webviewViewProvider, - ), - ); - this._disposables.push( - vscode.commands.registerCommand('pr.refreshActivePullRequest', _ => { - this._webviewViewProvider?.refresh(); - }), - ); + this._webviewViewProvider = addDisposable(new PullRequestViewProvider(this._context.extensionUri, folderRepositoryManager, reviewManager, pullRequestModel), this._currentDisposables); + addDisposable(vscode.window.registerWebviewViewProvider( + this._webviewViewProvider.viewType, + this._webviewViewProvider, + ), this._currentDisposables); + addDisposable(vscode.commands.registerCommand('pr.refreshActivePullRequest', _ => { + this._webviewViewProvider?.refresh(); + }), this._currentDisposables); } public setPullRequest(pullRequestModel: PullRequestModel, folderRepositoryManager: FolderRepositoryManager, reviewManager: ReviewManager, replace?: PullRequestModel) { @@ -50,7 +51,7 @@ export class WebviewViewCoordinator implements vscode.Disposable { private updatePullRequest() { const pullRequestModel = Array.from(this._pullRequestModel.keys())[0]; if (!pullRequestModel) { - this.dispose(); + this.reset(); return; } const { folderRepositoryManager, reviewManager } = this._pullRequestModel.get(pullRequestModel)!; @@ -61,17 +62,17 @@ export class WebviewViewCoordinator implements vscode.Disposable { } } - public removePullRequest(pullReqestModel: PullRequestModel) { + public removePullRequest(pullRequestModel: PullRequestModel) { const oldHead = Array.from(this._pullRequestModel.keys())[0]; - this._pullRequestModel.delete(pullReqestModel); + this._pullRequestModel.delete(pullRequestModel); const newHead = Array.from(this._pullRequestModel.keys())[0]; if (newHead !== oldHead) { this.updatePullRequest(); } } - public show(pullReqestModel: PullRequestModel) { - if (this._webviewViewProvider && (this._pullRequestModel.size > 0) && (Array.from(this._pullRequestModel.keys())[0] === pullReqestModel)) { + public show(pullRequestModel: PullRequestModel) { + if (this._webviewViewProvider && (this._pullRequestModel.size > 0) && (Array.from(this._pullRequestModel.keys())[0] === pullRequestModel)) { this._webviewViewProvider.show(); } } diff --git a/tsconfig.base.json b/tsconfig.base.json index d701766cf0..87e0c3e4fd 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -4,7 +4,6 @@ "forceConsistentCasingInFileNames": true, "incremental": true, "isolatedModules": false, - "jsx": "react", "lib": ["es2019"], "module": "esnext", "moduleResolution": "node", @@ -20,7 +19,8 @@ "strict": false, "strictNullChecks": false, "target": "es2019", - "useDefineForClassFields": true + "useDefineForClassFields": true, + "noImplicitOverride": true }, "include": ["src", "webviews"], "exclude": ["node_modules"] diff --git a/tsconfig.browser.json b/tsconfig.browser.json index f33d7f496c..db86274aac 100644 --- a/tsconfig.browser.json +++ b/tsconfig.browser.json @@ -1,7 +1,20 @@ { "extends": "./tsconfig.base.json", "compilerOptions": { - "lib": ["dom", "dom.iterable", "es2019"], - "tsBuildInfoFile": "tsconfig.browser.tsbuildinfo" - } + "jsx": "react", + "jsxFactory": "vscpp", + "jsxFragmentFactory": "vscppf", + "lib": [ + "dom", + "dom.iterable", + "es2019" + ], + "tsBuildInfoFile": "tsconfig.browser.tsbuildinfo", + "types": [] + }, + "exclude": [ + "node_modules", + "src/test", + "webviews" + ] } 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.json b/tsconfig.json index 295cd0b859..c84f7118b4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,12 +1,17 @@ { "extends": "./tsconfig.base.json", "compilerOptions": { + "jsx": "react", + "jsxFactory": "vscpp", + "jsxFragmentFactory": "vscppf", + "strictNullChecks": true, "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "strictNullChecks": true + "types": [ + "node" + ] }, "exclude": [ "node_modules", - "src/test", "webviews" ] } \ No newline at end of file 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/tsconfig.test.json b/tsconfig.test.json index cd20e29298..dd680f25f5 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -5,5 +5,11 @@ "module": "commonjs", "outDir": "out", "tsBuildInfoFile": "tsconfig.test.tsbuildinfo" - } + }, + "include": [ + "src/@types", + "src/common", + "src/github", + "src/test" + ] } diff --git a/tsconfig.webviews.json b/tsconfig.webviews.json index f87795a970..c806a45e06 100644 --- a/tsconfig.webviews.json +++ b/tsconfig.webviews.json @@ -1,6 +1,18 @@ { - "extends": "./tsconfig.browser.json", + "extends": "./tsconfig.base.json", "compilerOptions": { + "jsx": "react", + "lib": [ + "dom", + "dom.iterable", + "es2019" + ], "tsBuildInfoFile": "tsconfig.webviews.tsbuildinfo" - } + }, + "include": [ + "src/@types", + "src/common", + "src/github", + "webviews" + ] } diff --git a/webpack.config.js b/webpack.config.js index 67e1c5aceb..250d4933f8 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -12,7 +12,7 @@ const execFile = require('child_process').execFile; const path = require('path'); -const { ESBuildMinifyPlugin } = require('esbuild-loader'); +const { EsbuildPlugin } = require('esbuild-loader'); const ForkTsCheckerPlugin = require('fork-ts-checker-webpack-plugin'); const JSON5 = require('json5'); const TerserPlugin = require('terser-webpack-plugin'); @@ -58,11 +58,6 @@ async function getWebviewConfig(mode, env, entry) { }), new ForkTsCheckerPlugin({ async: false, - eslint: { - enabled: true, - files: path.join(basePath, '**', '*.ts'), - options: { cache: true, configFile: path.join(__dirname, '.eslintrc.webviews.json') }, - }, formatter: 'basic', typescript: { configFile: path.join(__dirname, 'tsconfig.webviews.json'), @@ -79,12 +74,15 @@ async function getWebviewConfig(mode, env, entry) { output: { filename: '[name].js', path: path.resolve(__dirname, 'dist'), + // Use absolute paths (file:///) in source maps instead of the default webpack:// scheme + devtoolModuleFilenameTemplate: info => 'file:///' + info.absoluteResourcePath.replace(/\\/g, '/'), + devtoolFallbackModuleFilenameTemplate: 'file:///[absolute-resource-path]' }, optimization: { minimizer: [ // @ts-ignore env.esbuild - ? new ESBuildMinifyPlugin({ + ? new EsbuildPlugin({ format: 'cjs', minify: true, treeShaking: true, @@ -107,13 +105,12 @@ async function getWebviewConfig(mode, env, entry) { rules: [ { exclude: /node_modules/, - include: [basePath, path.join(__dirname, 'src')], + include: [basePath, path.join(__dirname, 'src'), path.join(__dirname, 'common')], test: /\.tsx?$/, use: env.esbuild ? { loader: 'esbuild-loader', options: { - loader: 'tsx', target: 'es2019', tsconfigRaw: await resolveTSConfig(path.join(__dirname, 'tsconfig.webviews.json')), }, @@ -135,6 +132,10 @@ async function getWebviewConfig(mode, env, entry) { test: /\.svg/, use: ['svg-inline-loader'], }, + { + test: /\.ttf$/, + type: 'asset/resource' + }, ], }, resolve: { @@ -143,6 +144,7 @@ async function getWebviewConfig(mode, env, entry) { crypto: require.resolve("crypto-browserify"), path: require.resolve('path-browserify'), stream: require.resolve("stream-browserify"), + http: require.resolve("stream-http") }, }, plugins: plugins, @@ -157,34 +159,59 @@ async function getWebviewConfig(mode, env, entry) { */ async function getExtensionConfig(target, mode, env) { const basePath = path.join(__dirname, 'src'); + const glob = require('glob'); /** * @type WebpackConfig['plugins'] | any */ const plugins = [ - new webpack.optimize.LimitChunkCountPlugin({ - maxChunks: 1 - }), new ForkTsCheckerPlugin({ async: false, - eslint: { - enabled: true, - files: path.join(basePath, '**', '*.ts'), - options: { - cache: true, - configFile: path.join( - __dirname, - target === 'webworker' ? '.eslintrc.browser.json' : '.eslintrc.node.json', - ), - }, - }, formatter: 'basic', typescript: { configFile: path.join(__dirname, target === 'webworker' ? 'tsconfig.browser.json' : 'tsconfig.json'), }, - }) + }), + new webpack.ContextReplacementPlugin(/mocha/, /^$/) ]; + // Add fixtures copying plugin for node target (which has individual test files) + if (target === 'node') { + const fs = require('fs'); + const srcRoot = 'src'; + class CopyFixturesPlugin { + apply(compiler) { + compiler.hooks.afterEmit.tap('CopyFixturesPlugin', () => { + this.copyFixtures(srcRoot, compiler.options.output.path); + }); + } + + copyFixtures(inputDir, outputDir) { + try { + const files = fs.readdirSync(inputDir); + for (const file of files) { + const filePath = path.join(inputDir, file); + const stats = fs.statSync(filePath); + if (stats.isDirectory()) { + if (file === 'fixtures') { + const outputFilePath = path.join(outputDir, inputDir.substring(srcRoot.length), file); + const inputFilePath = path.join(inputDir, file); + fs.cpSync(inputFilePath, outputFilePath, { recursive: true, force: true }); + } else { + this.copyFixtures(filePath, outputDir); + } + } + } + } catch (error) { + // Ignore errors during fixtures copying to not break the build + console.warn('Warning: Could not copy fixtures:', error.message); + } + } + } + + plugins.push(new CopyFixturesPlugin()); + } + if (target === 'webworker') { plugins.push(new webpack.ProvidePlugin({ process: path.join( @@ -193,13 +220,36 @@ async function getExtensionConfig(target, mode, env) { 'process', 'browser.js') })); + plugins.push(new webpack.ProvidePlugin({ + Buffer: ['buffer', 'Buffer'] + })); } const entry = { extension: './src/extension.ts', }; + + // Add test entry points if (target === 'webworker') { entry['test/index'] = './src/test/browser/index.ts'; + } else if (target === 'node') { + // Add main test runner + entry['test/index'] = './src/test/index.ts'; + + // Add individual test files as separate entry points + const testFiles = glob.sync('src/test/**/*.test.ts', { cwd: __dirname }); + testFiles.forEach(testFile => { + // Convert src/test/github/utils.test.ts -> test/github/utils.test + const entryName = testFile.replace('src/', '').replace('.ts', ''); + entry[entryName] = `./${testFile}`; + }); + } + + // Don't limit chunks for node target when we have individual test files + if (target !== 'node' || !('test/index' in entry && Object.keys(entry).some(key => key.endsWith('.test')))) { + plugins.unshift(new webpack.optimize.LimitChunkCountPlugin({ + maxChunks: 1 + })); } return { @@ -213,12 +263,15 @@ async function getExtensionConfig(target, mode, env) { libraryTarget: 'commonjs2', filename: '[name].js', chunkFilename: 'feature-[name].js', + // Use absolute paths (file:///) in source maps for easier debugging of tests & sources + devtoolModuleFilenameTemplate: info => 'file:///' + info.absoluteResourcePath.replace(/\\/g, '/'), + devtoolFallbackModuleFilenameTemplate: 'file:///[absolute-resource-path]', }, optimization: { minimizer: [ // @ts-ignore env.esbuild - ? new ESBuildMinifyPlugin({ + ? new EsbuildPlugin({ format: 'cjs', minify: true, treeShaking: true, @@ -242,13 +295,12 @@ async function getExtensionConfig(target, mode, env) { rules: [ { exclude: /node_modules/, - include: path.join(__dirname, 'src'), + include: [path.join(__dirname, 'src'), path.join(__dirname, 'common')], test: /\.tsx?$/, use: env.esbuild ? { loader: 'esbuild-loader', options: { - loader: 'ts', target: 'es2019', tsconfigRaw: await resolveTSConfig( path.join( @@ -276,7 +328,7 @@ async function getExtensionConfig(target, mode, env) { // // // // We should either fix or remove that package, then remove this rule, // // which introduces nonstandard behavior for mjs files, which are - // // terrible. This is all terrible. Everything is terrible.👇🏾 + // // terrible. This is all terrible. Everything is terrible. // { // test: /\.mjs$/, // include: /node_modules/, @@ -286,24 +338,13 @@ async function getExtensionConfig(target, mode, env) { exclude: /node_modules/, test: /\.(graphql|gql)$/, loader: 'graphql-tag/loader', - }, - // { - // test: /webview-*\.js/, - // use: 'raw-loader' - // }, + } ], }, resolve: { alias: target === 'webworker' ? { - 'universal-user-agent': path.join( - __dirname, - 'node_modules', - 'universal-user-agent', - 'dist-web', - 'index.js', - ), 'node-fetch': 'cross-fetch', '../env/node/net': path.resolve(__dirname, 'src', 'env', 'browser', 'net'), '../env/node/ssh': path.resolve(__dirname, 'src', 'env', 'browser', 'ssh'), @@ -332,9 +373,12 @@ async function getExtensionConfig(target, mode, env) { 'os': require.resolve('os-browserify/browser'), "constants": require.resolve("constants-browserify"), buffer: require.resolve('buffer'), - timers: require.resolve('timers-browserify') + timers: require.resolve('timers-browserify'), + http: require.resolve("stream-http") } - : undefined, + : { + http: require.resolve("stream-http") + }, extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'], symlinks: false, }, @@ -345,7 +389,10 @@ async function getExtensionConfig(target, mode, env) { // 'encoding': 'encoding', 'applicationinsights-native-metrics': 'applicationinsights-native-metrics', '@opentelemetry/tracing': '@opentelemetry/tracing', + '@opentelemetry/instrumentation': '@opentelemetry/instrumentation', + '@azure/opentelemetry-instrumentation-azure-sdk': '@azure/opentelemetry-instrumentation-azure-sdk', 'fs': 'fs', + 'mocha': 'commonjs mocha', }, plugins: plugins, stats: { @@ -356,7 +403,7 @@ async function getExtensionConfig(target, mode, env) { errorsCount: true, warningsCount: true, timings: true, - }, + } }; } @@ -380,7 +427,7 @@ module.exports = getWebviewConfig(mode, env, { 'webview-pr-description': './webviews/editorWebview/index.ts', 'webview-open-pr-view': './webviews/activityBarView/index.ts', - 'webview-create-pr-view': './webviews/createPullRequestView/index.ts', + 'webview-create-pr-view-new': './webviews/createPullRequestViewNew/index.ts', }), ]); }; diff --git a/webviews/activityBarView/app.tsx b/webviews/activityBarView/app.tsx index 36b8800106..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 { PullRequest } from '../common/cache'; -import PullRequestContext from '../common/context'; import { Overview } from './overview'; +import { PullRequest } from '../../src/github/views'; +import PullRequestContext from '../common/context'; export function main() { render(<Root>{pr => <Overview {...pr} />}</Root>, document.getElementById('app')); diff --git a/webviews/activityBarView/exit.tsx b/webviews/activityBarView/exit.tsx index ca2f8e9f80..bc70935338 100644 --- a/webviews/activityBarView/exit.tsx +++ b/webviews/activityBarView/exit.tsx @@ -5,19 +5,19 @@ import React, { useContext, useState } from 'react'; import { GithubItemStateEnum } from '../../src/github/interface'; -import { PullRequest } from '../common/cache'; +import { PullRequest } from '../../src/github/views'; import PullRequestContext from '../common/context'; -const ExitButton = ({ repositoryDefaultBranch, isBusy, onClick }: { repositoryDefaultBranch: string, isBusy: boolean, onClick: () => Promise<void> }) => { +const ExitButton = ({ doneCheckoutBranch, isBusy, onClick }: { doneCheckoutBranch: string, isBusy: boolean, onClick: () => Promise<void> }) => { return (<button title="Switch to a different branch than this pull request branch" disabled={isBusy} onClick={onClick}> - Checkout '{repositoryDefaultBranch}' + Checkout '{doneCheckoutBranch}' </button>); }; -const ExitLink = ({ repositoryDefaultBranch, onClick }: { repositoryDefaultBranch: string, onClick: () => Promise<void> }) => { +const ExitLink = ({ doneCheckoutBranch, onClick }: { doneCheckoutBranch: string, onClick: () => Promise<void> }) => { return ( <span> - <a title="Switch to a different branch than this pull request branch" onClick={onClick}>Checkout '{repositoryDefaultBranch}' </a>without deleting branch + <a title="Switch to a different branch than this pull request branch" onClick={onClick}>Checkout '{doneCheckoutBranch}' </a>without deleting branch </span> ); }; @@ -39,8 +39,8 @@ export const ExitSection = ({ pr }: { pr: PullRequest }) => { <div className="button-container"> { pr.state === GithubItemStateEnum.Open ? - <ExitButton repositoryDefaultBranch={pr.repositoryDefaultBranch} isBusy={isBusy} onClick={onClick} /> - : <ExitLink repositoryDefaultBranch={pr.repositoryDefaultBranch} onClick={onClick} /> + <ExitButton doneCheckoutBranch={pr.doneCheckoutBranch} isBusy={isBusy} onClick={onClick} /> + : <ExitLink doneCheckoutBranch={pr.doneCheckoutBranch} onClick={onClick} /> } </div> ); diff --git a/webviews/activityBarView/index.css b/webviews/activityBarView/index.css index 13b084d046..10c13cefd1 100644 --- a/webviews/activityBarView/index.css +++ b/webviews/activityBarView/index.css @@ -10,7 +10,7 @@ body { textarea { min-height: 80px; max-height: 500px; - border-radius: 2px; + border-radius: 4px; } .form-actions { @@ -23,6 +23,14 @@ textarea { width: 16px; } +.reviewer-icons .icon svg { + margin-right: 0px; +} + +.reviewer-icons { + margin-left: 20px; +} + #status-checks { display: flex; flex-direction: column; @@ -67,7 +75,17 @@ form, width: 100%; } -.button-container button { +.button-container>button { + width: 100%; +} + +.button-container:has(> .dropdown-container) { + display: flex; + min-width: 0; +} + +.dropdown-container { + flex-grow: 1; width: 100%; } @@ -92,15 +110,10 @@ form button { fill: var(--vscode-button-foreground); } -button:focus, -input[type='submit']:focus { - outline-offset: -1px; -} .select-control button, .branch-status-container button, input[type='submit'] { - min-height: 31px; white-space: normal; } @@ -165,3 +178,21 @@ button .icon svg { display: flex; padding-top: 1px; } + +#status-checks .button-container { + padding-top: 16px; +} + +.comment-button { + display: flex; + flex-grow: 1; + min-width: 0; +} + +.dropdown-container { + justify-content: center; +} + +button.split-left { + display: block; +} diff --git a/webviews/activityBarView/overview.tsx b/webviews/activityBarView/overview.tsx index 3b0e3afb4c..976a9d35fd 100644 --- a/webviews/activityBarView/overview.tsx +++ b/webviews/activityBarView/overview.tsx @@ -4,11 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import React from 'react'; -import { PullRequest } from '../common/cache'; +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/aria.ts b/webviews/common/aria.ts new file mode 100644 index 0000000000..beb72d549f --- /dev/null +++ b/webviews/common/aria.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CommentEvent, EventType, ReviewEvent } from '../../src/common/timelineEvent'; + +export function ariaAnnouncementForReview(comment: ReviewEvent | CommentEvent) { + const commentTime = (comment as ReviewEvent).submittedAt ?? (comment as CommentEvent).createdAt; + const veryRecentEvent = commentTime && ((Date.now() - new Date(commentTime).getTime()) < (1000 * 60)); + const commentState = (comment as ReviewEvent).state ?? ((comment as CommentEvent).event === EventType.Commented ? 'COMMENTED' : undefined); + let ariaAnnouncement = ''; + if (veryRecentEvent) { + switch (commentState) { + case 'APPROVED': + ariaAnnouncement = 'Pull request approved'; + break; + case 'CHANGES_REQUESTED': + ariaAnnouncement = 'Changes requested on pull request'; + break; + case 'COMMENTED': + ariaAnnouncement = 'Commented on pull request'; + break; + } + } + return ariaAnnouncement; +} \ No newline at end of file diff --git a/webviews/common/cache.ts b/webviews/common/cache.ts index debf310f1a..f369e8770c 100644 --- a/webviews/common/cache.ts +++ b/webviews/common/cache.ts @@ -3,85 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { TimelineEvent } from '../../src/common/timelineEvent'; -import { - GithubItemStateEnum, - IAccount, - ILabel, - IMilestone, - MergeMethod, - MergeMethodsAvailability, - PullRequestChecks, - PullRequestMergeability, - ReviewState, -} from '../../src/github/interface'; import { vscode } from './message'; - -export enum ReviewType { - Comment = 'comment', - Approve = 'approve', - RequestChanges = 'requestChanges', -} - -export interface PullRequest { - number: number; - title: string; - titleHTML: string; - url: string; - createdAt: string; - body: string; - bodyHTML?: string; - author: IAccount; - state: GithubItemStateEnum; - events: TimelineEvent[]; - isCurrentlyCheckedOut: boolean; - isRemoteBaseDeleted?: boolean; - base: string; - isRemoteHeadDeleted?: boolean; - isLocalHeadDeleted?: boolean; - head: string; - labels: ILabel[]; - assignees: IAccount[]; - commitsCount: number; - milestone: IMilestone; - repositoryDefaultBranch: string; - /** - * User can edit PR title and description (author or user with push access) - */ - canEdit: boolean; - /** - * Users with push access to repo have rights to merge/close PRs, - * edit title/description, assign reviewers/labels etc. - */ - hasWritePermission: boolean; - pendingCommentText?: string; - pendingCommentDrafts?: { [key: string]: string }; - pendingReviewType?: ReviewType; - status: PullRequestChecks; - mergeable: PullRequestMergeability; - defaultMergeMethod: MergeMethod; - mergeMethodsAvailability: MergeMethodsAvailability; - autoMerge?: boolean; - allowAutoMerge: boolean; - autoMergeMethod?: MergeMethod; - reviewers: ReviewState[]; - isDraft?: boolean; - isIssue: boolean; - isAuthor?: boolean; - continueOnGitHub: boolean; - currentUserReviewState: string; - isDarkTheme: boolean; - hasReviewDraft: boolean; -} +import { PullRequest } from '../../src/github/views'; export function getState(): PullRequest { return vscode.getState(); } -export function setState(pullRequest: PullRequest): void { +export function setState(pullRequest: PullRequest | undefined): void { const oldPullRequest = getState(); - if (oldPullRequest && oldPullRequest.number && oldPullRequest.number === pullRequest.number) { + if (oldPullRequest && oldPullRequest.number && oldPullRequest.number === pullRequest?.number) { pullRequest.pendingCommentText = oldPullRequest.pendingCommentText; } @@ -90,7 +22,7 @@ export function setState(pullRequest: PullRequest): void { } } -export function updateState(data: Partial<PullRequest>): void { +export function updateState(data: Partial<PullRequest> | undefined): void { const pullRequest = vscode.getState(); vscode.setState(Object.assign(pullRequest, data)); } diff --git a/webviews/common/common.css b/webviews/common/common.css index e4463de0e3..8e9e7568b7 100644 --- a/webviews/common/common.css +++ b/webviews/common/common.css @@ -4,7 +4,17 @@ *--------------------------------------------------------------------------------------------*/ body a { - text-decoration: none; + text-decoration: var(--text-link-decoration); +} + +h3 { + display: unset; + font-size: unset; + margin-block-start: unset; + margin-block-end: unset; + margin-inline-start: unset; + margin-inline-end: unset; + font-weight: unset; } body a:hover { @@ -13,39 +23,46 @@ body a:hover { button, input[type='submit'] { - background-color: var(--vscode-button-background); color: var(--vscode-button-foreground); font-family: var(--vscode-font-family); - border-radius: 2px; + border-radius: 4px; border: 1px solid transparent; - outline: none; - padding: 4px 12px; + padding: 3px 12px; font-size: 13px; line-height: 18px; white-space: nowrap; user-select: none; } +button:not(.icon-button):not(.danger):not(.secondary), +input[type='submit'] { + background-color: var(--vscode-button-background); +} + input.select-left { - border-radius: 2px 0 0 2px; + border-radius: 4px 0 0 4px; } button.select-right { - border-radius: 0 2px 2px 0; + border-radius: 0 4px 4px 0; + width: 24px; + position: relative; +} + +button.select-right span { + position: absolute; + top: 2px; + right: 4px; } button:focus, input[type='submit']:focus { - outline: 1px solid var(--vscode-focusBorder); + outline-color: var(--vscode-focusBorder); + outline-style: solid; + outline-width: 1px; outline-offset: 2px; } -button:focus-within, -input[type='submit']:focus-within { - border: 1px solid transparent; - outline: 1px solid transparent; -} - button:hover:enabled, button:focus:enabled, input[type='submit']:focus:enabled, @@ -54,12 +71,16 @@ input[type='submit']:hover:enabled { cursor: pointer; } -body button.secondary { +button.secondary { background-color: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); + border-color: var(--vscode-button-border, transparent); } -body button.secondary:hover { +button.secondary:hover:enabled, +button.secondary:focus:enabled, +input[type='submit'].secondary:focus:enabled, +input[type='submit'].secondary:hover:enabled { background-color: var(--vscode-button-secondaryHoverBackground); } @@ -75,7 +96,7 @@ input[type='text'] { background-color: var(--vscode-input-background); color: var(--vscode-input-foreground); font-family: var(--vscode-font-family); - border-radius: 2px; + border-radius: 4px; } textarea::placeholder, @@ -87,7 +108,7 @@ select { display: block; box-sizing: border-box; padding: 4px 8px; - border-radius: 2px; + border-radius: 4px; font-size: 13px; border: 1px solid var(--vscode-dropdown-border); background-color: var(--vscode-dropdown-background); @@ -96,15 +117,64 @@ select { textarea:focus, input[type='text']:focus, -input[type='checkbox']:focus, select:focus { outline: 1px solid var(--vscode-focusBorder); } input[type='checkbox'] { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + width: 18px; + height: 18px; + min-width: 18px; + min-height: 18px; + flex-shrink: 0; + margin: 0; + border: 1px solid var(--vscode-checkbox-border); + background-color: var(--vscode-checkbox-background); + border-radius: 3px; + cursor: pointer; + outline: none; +} + +input[type='checkbox']:checked { + background-color: var(--vscode-checkbox-selectBackground, var(--vscode-checkbox-background)); +} + +.checkbox-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.checkbox-wrapper::after { + content: ''; + position: absolute; + top: 1px; + left: 6px; + width: 5px; + height: 10px; + border: solid var(--vscode-checkbox-foreground); + border-width: 0 1px 1px 0; + transform: rotate(45deg); + pointer-events: none; + opacity: 0; +} + +.checkbox-wrapper:has(input:checked)::after { + opacity: 1; +} + +input[type='checkbox']:focus { + outline: 1px solid var(--vscode-focusBorder); outline-offset: 1px; } +input[type='checkbox']:not(:checked):hover { + background-color: var(--vscode-inputOption-hoverBackground); +} + .vscode-high-contrast input[type='checkbox'] { outline: 1px solid var(--vscode-contrastBorder); } @@ -113,13 +183,15 @@ input[type='checkbox'] { outline: 1px solid var(--vscode-contrastActiveBorder); } -svg path { +:not(.copilot-icon) > svg path, +.copilot-icon svg path:first-of-type { fill: var(--vscode-foreground); } body button:disabled, input[type='submit']:disabled { opacity: 0.4; + border: 1px solid var(--vscode-button-background, transparent) !important; } body .hidden { @@ -141,6 +213,35 @@ body img.avatar { flex-shrink: 0; } +.icon-button { + display: flex; + padding: 2px; + background: transparent; + border-radius: 4px; + line-height: 0; +} + +.icon-button:hover, +.title .icon-button:hover, +.title .icon-button:focus, +.section .icon-button:hover, +.section .icon-button:focus { + background-color: var(--vscode-toolbar-hoverBackground); + cursor: pointer; +} + +.icon-button:focus, +.title .icon-button:focus, +.section .icon-button:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +.label .icon-button:hover, +.label .icon-button:focus { + background-color: transparent; +} + .section-item { display: flex; align-items: center; @@ -184,11 +285,19 @@ body img.avatar { .section-icon.approved svg path { fill: var(--vscode-issues-open); } + .reviewer-icons { display: flex; gap: 4px; } +.reviewer-icons [role='alert'] { + position: absolute; + width: 0; + height: 0; + overflow: hidden; +} + .push-right { margin-left: auto; } @@ -201,18 +310,31 @@ body img.avatar { .author-link { font-weight: 600; color: var(--vscode-editor-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.status-item button { + margin-left: auto; + margin-right: 0; } .automerge-section { display: flex; } +.automerge-section, +.status-section { + flex-wrap: wrap; +} + #status-checks .automerge-section { align-items: center; padding: 16px; - background: var(--vscode-editorHoverWidget-background); - border-bottom-left-radius: 3px; - border-bottom-right-radius: 3px; + background: var(--vscode-panel-background); + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; } .automerge-section .merge-select-container { @@ -226,14 +348,30 @@ body img.avatar { margin-right: 4px; } +.automerge-checkbox-label { + min-width: 80px; +} + +.merge-queue-title .merge-queue-pending { + color: var(--vscode-list-warningForeground); +} + +.merge-queue-title .merge-queue-blocked { + color: var(--vscode-list-errorForeground); +} + +.merge-queue-title { + font-weight: bold; + font-size: larger; +} + /** Theming */ -.vscode-high-contrast button { - outline: none; +.vscode-high-contrast button:not(.secondary):not(.icon-button) { background: var(--vscode-button-background); - border: 1px solid var(--vscode-contrastBorder); } + .vscode-high-contrast input { outline: none; background: var(--vscode-input-background); @@ -270,4 +408,157 @@ body img.avatar { font-size: 11px; line-height: 18px; font-weight: 600; +} + +/* split button */ + +.primary-split-button { + display: flex; + flex-grow: 1; + min-width: 0; + max-width: 260px; +} + +button.split-left { + border-radius: 4px 0 0 4px; + flex-grow: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + display: flex; + border: 1px solid var(--vscode-button-border, transparent); + border-right: none; +} + +.split { + background-color: var(--vscode-button-background); + border-top: 1px solid var(--vscode-button-border); + border-bottom: 1px solid var(--vscode-button-border); + padding: 4px 0; +} + +.split .separator { + height: 100%; + width: 1px; + background-color: var(--vscode-button-separator); +} + +.split.disabled { + opacity: 0.4; + border-top: 1px solid var(--vscode-button-background); + border-bottom: 1px solid var(--vscode-button-background); +} + +.split.secondary { + background-color: var(--vscode-button-secondaryBackground); + border-top: 1px solid var(--vscode-button-secondaryBorder); + border-bottom: 1px solid var(--vscode-button-secondaryBorder); +} + +button.split-right { + border-radius: 0 4px 4px 0; + cursor: pointer; + width: 24px; + position: relative; + border: 1px solid var(--vscode-button-border, transparent); + border-left: none; +} +button.split-right:disabled { + cursor: default; +} + +button.split-right .icon { + pointer-events: none; + position: absolute; + top: 4px; + right: 4px; +} + +button.split-right .icon svg path { + fill: unset; +} + +button.input-box { + display: block; + height: 24px; + margin-top: -4px; + padding-top: 2px; + padding-left: 8px; + text-align: left; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--vscode-input-foreground) !important; + background-color: var(--vscode-input-background) !important; +} + +button.input-box:active, +button.input-box:focus { + color: var(--vscode-inputOption-activeForeground) !important; + background-color: var(--vscode-inputOption-activeBackground) !important; +} + +button.input-box:hover:not(:disabled) { + background-color: var(--vscode-inputOption-hoverBackground) !important; +} + +button.input-box:focus { + border-color: var(--vscode-focusBorder) !important; +} + +.dropdown-container { + display: flex; + min-width: 0; + margin: 0; +} + +.dropdown-container.spreadable { + flex-grow: 1; + width: 100%; +} + +button.inlined-dropdown { + width: 100%; + max-width: 150px; + margin-right: 8px; + display: inline-block; + text-align: center; +} + +button.inlined-dropdown:last-child { + margin-right: 0; +} + +.spinner { + margin-top: 5px; + margin-left: 5px; +} + +.commit-spinner-inline { + margin-left: 8px; + display: inline-flex; + align-items: center; + vertical-align: middle; + grid-column: none; +} + +.commit-spinner-before { + margin-right: 6px; + display: inline-flex; + align-items: center; + vertical-align: middle; +} + +.loading { + animation: spinner-rotate 1s linear infinite; +} + +@keyframes spinner-rotate { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } } \ No newline at end of file diff --git a/webviews/common/constants.ts b/webviews/common/constants.ts new file mode 100644 index 0000000000..55a0367153 --- /dev/null +++ b/webviews/common/constants.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * ID for the main comment textarea element in the PR description page. + */ +export const COMMENT_TEXTAREA_ID = 'comment-textarea'; + +/** + * ID for the edit title button in the PR/Issue header. + */ +export const EDIT_TITLE_BUTTON_ID = 'edit-title-button'; diff --git a/webviews/common/context.tsx b/webviews/common/context.tsx index b9d899a9d1..a1db032ba6 100644 --- a/webviews/common/context.tsx +++ b/webviews/common/context.tsx @@ -4,16 +4,19 @@ *--------------------------------------------------------------------------------------------*/ import { createContext } from 'react'; -import { IComment } from '../../src/common/comment'; -import { EventType, ReviewEvent, TimelineEvent } from '../../src/common/timelineEvent'; -import { MergeMethod, ReviewState } from '../../src/github/interface'; -import { getState, PullRequest, setState, updateState } from './cache'; +import { getState, setState, updateState } from './cache'; +import { COMMENT_TEXTAREA_ID } from './constants'; import { getMessageHandler, MessageHandler } from './message'; +import { CloseResult, DescriptionResult, 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, ChangeBaseReply, ConvertToDraftReply, DeleteReviewResult, MergeArguments, MergeResult, ProjectItemsReply, PullRequest, ReadyForReviewReply, SubmitReviewReply } from '../../src/github/views'; export class PRContext { constructor( - public pr: PullRequest = getState(), - public onchange: ((ctx: PullRequest) => void) | null = null, + public pr: PullRequest | undefined = getState(), + public onchange: ((ctx: PullRequest | undefined) => void) | null = null, private _handler: MessageHandler | null = null, ) { if (!_handler) { @@ -31,10 +34,14 @@ export class PRContext { public checkout = () => this.postMessage({ command: 'pr.checkout' }); + public openChanges = (openToTheSide?: boolean) => this.postMessage({ command: 'pr.open-changes', args: { openToTheSide } }); + public copyPrLink = () => this.postMessage({ command: 'pr.copy-prlink' }); public copyVscodeDevLink = () => this.postMessage({ command: 'pr.copy-vscodedevlink' }); + public cancelCodingAgent = (event: TimelineEvent): Promise<CancelCodingAgentReply> => this.postMessage({ command: 'pr.cancel-coding-agent', args: event }); + public exitReviewMode = async () => { if (!this.pr) { return; @@ -47,40 +54,70 @@ export class PRContext { public gotoChangesSinceReview = () => this.postMessage({ command: 'pr.gotoChangesSinceReview' }); - public refresh = () => this.postMessage({ command: 'pr.refresh' }); + public refresh = async () =>{ + if (this.pr) { + this.pr.busy = true; + } + this.updatePR(this.pr); + await this.postMessage({ command: 'pr.refresh' }); + if (this.pr) { + this.pr.busy = false; + } + this.updatePR(this.pr); + }; public checkMergeability = () => this.postMessage({ command: 'pr.checkMergeability' }); - public merge = (args: { title: string; description: string; method: MergeMethod }) => - this.postMessage({ command: 'pr.merge', args }); + public changeEmail = async (current: string) => { + const newEmail = await this.postMessage({ command: 'pr.change-email', args: current }); + this.updatePR({ emailForCommit: newEmail }); + }; + + public merge = async (args: MergeArguments): Promise<MergeResult> => { + const result: MergeResult = await this.postMessage({ command: 'pr.merge', args }); + return result; + }; public openOnGitHub = () => this.postMessage({ command: 'pr.openOnGitHub' }); public deleteBranch = () => this.postMessage({ command: 'pr.deleteBranch' }); - public readyForReview = () => this.postMessage({ command: 'pr.readyForReview' }); - - public comment = async (args: string) => { - const result = await this.postMessage({ command: 'pr.comment', args }); - const newComment = result.value; - newComment.event = EventType.Commented; - this.updatePR({ - events: [...this.pr.events, newComment], - pendingCommentText: '', - }); + public revert = async () => { + this.updatePR({ busy: true }); + const revertResult = await this.postMessage({ command: 'pr.revert' }); + this.updatePR({ busy: false, ...revertResult }); }; + public readyForReview = (): Promise<ReadyForReview> => this.postMessage({ command: 'pr.readyForReview' }); + + public readyForReviewAndMerge = (args: { mergeMethod: MergeMethod }): Promise<ReadyForReview> => this.postMessage({ command: 'pr.readyForReviewAndMerge', args }); + + public convertToDraft = (): Promise<ConvertToDraftReply> => this.postMessage({ command: 'pr.convertToDraft' }); + public addReviewers = () => this.postMessage({ command: 'pr.change-reviewers' }); + public addReviewerCopilot = () => this.postMessage({ command: 'pr.add-reviewer-copilot' }); + public changeBaseBranch = async () => { + const result: ChangeBaseReply = await this.postMessage({ command: 'pr.change-base-branch' }); + if (result?.base) { + this.updatePR({ base: result.base, events: result.events }); + } + }; + public changeProjects = (): Promise<ProjectItemsReply> => this.postMessage({ command: 'pr.change-projects' }); + public removeProject = (project: IProjectItem) => this.postMessage({ command: 'pr.remove-project', args: project }); public addMilestone = () => this.postMessage({ command: 'pr.add-milestone' }); public removeMilestone = () => this.postMessage({ command: 'pr.remove-milestone' }); - public addAssignees = () => this.postMessage({ command: 'pr.change-assignees' }); - public addAssigneeYourself = () => this.postMessage({ command: 'pr.add-assignee-yourself' }); + public addAssignees = (): Promise<ChangeAssigneesReply> => this.postMessage({ command: 'pr.change-assignees' }); + public addAssigneeYourself = (): Promise<ChangeAssigneesReply> => this.postMessage({ command: 'pr.add-assignee-yourself' }); + public addAssigneeCopilot = (): Promise<ChangeAssigneesReply> => this.postMessage({ command: 'pr.add-assignee-copilot' }); public addLabels = () => this.postMessage({ command: 'pr.add-labels' }); public create = () => this.postMessage({ command: 'pr.open-create' }); public deleteComment = async (args: { id: number; pullRequestReviewId?: number }) => { await this.postMessage({ command: 'pr.delete-comment', args }); const { pr } = this; + if (!pr) { + throw new Error('Unexpectedly no pull request when trying to delete comment'); + } const { id, pullRequestReviewId } = args; if (!pullRequestReviewId) { this.updatePR({ @@ -98,16 +135,22 @@ export class PRContext { console.error('No comments to delete for review:', pullRequestReviewId, review); return; } - this.pr.events.splice(index, 1, { + pr.events.splice(index, 1, { ...review, comments: review.comments.filter(c => c.id !== id), }); - this.updatePR(this.pr); + this.updatePR(pr); }; public editComment = (args: { comment: IComment; text: string }) => this.postMessage({ command: 'pr.edit-comment', args }); + public generateDescription = (): Promise<DescriptionResult> => + this.postMessage({ command: 'pr.generate-description' }); + + public cancelGenerateDescription = () => + this.postMessage({ command: 'pr.cancel-generate-description' }); + public updateDraft = (id: number, body: string) => { const pullRequest = getState(); const pendingCommentDrafts = pullRequest.pendingCommentDrafts || Object.create(null); @@ -118,26 +161,76 @@ export class PRContext { this.updatePR({ pendingCommentDrafts: pendingCommentDrafts }); }; - public requestChanges = async (body: string) => - this.appendReview(await this.postMessage({ command: 'pr.request-changes', args: body })); + private async submitReviewCommand(command: string, body: string) { + try { + const result: SubmitReviewReply = await this.postMessage({ command, args: body }); + return this.appendReview(result); + } catch (error) { + return this.updatePR({ busy: false }); + } + } + + public requestChanges = (body: string) => this.submitReviewCommand('pr.request-changes', body); + + public approve = (body: string) => this.submitReviewCommand('pr.approve', body); - public approve = async (body: string) => - this.appendReview(await this.postMessage({ command: 'pr.approve', args: body })); + public submit = (body: string) => this.submitReviewCommand('pr.submit', body); - public submit = async (body: string) => - this.appendReview(await this.postMessage({ command: 'pr.submit', args: 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) { + throw new Error('Unexpectedly no pull request when trying to close'); + } try { - this.appendReview(await this.postMessage({ command: 'pr.close', args: body })); + const result: CloseResult = await this.postMessage({ command: 'pr.close', args: body }); + let events: TimelineEvent[] = [...pr.events]; + if (result.commentEvent) { + events.push(result.commentEvent); + } + if (result.closeEvent) { + events.push(result.closeEvent); + } + this.updatePR({ + events, + pendingCommentText: '', + state: result.state + }); } catch (_) { // Ignore } }; public removeLabel = async (label: string) => { + const { pr } = this; + if (!pr) { + throw new Error('Unexpectedly no pull request when trying to remove label'); + } await this.postMessage({ command: 'pr.remove-label', args: label }); - const labels = this.pr.labels.filter(r => r.name !== label); + const labels = pr.labels.filter(r => r.name !== label); this.updatePR({ labels }); }; @@ -145,35 +238,111 @@ export class PRContext { this.postMessage({ command: 'pr.apply-patch', args: { comment } }); }; - private appendReview({ review, reviewers }: { review: ReviewEvent, reviewers: ReviewState[] }) { - const state = this.pr; - const events = state.events.filter(e => e.event !== EventType.Reviewed || e.state.toLowerCase() !== 'pending'); - events.forEach(event => { - if (event.event === EventType.Reviewed) { - event.comments.forEach(c => (c.isDraft = false)); - } - }); - state.reviewers = reviewers; - state.events = [...state.events.filter(e => (e.event === EventType.Reviewed ? e.state !== 'PENDING' : e)), review]; - state.currentUserReviewState = review.state; + private appendReview(reply: SubmitReviewReply) { + const { pr: state } = this; + if (!state) { + throw new Error('Unexpectedly no pull request when trying to append review'); + } + const { events, reviewers, reviewedEvent } = reply; + state.busy = false; + if (!events) { + this.updatePR(state); + return; + } + if (reviewers) { + state.reviewers = reviewers; + } + state.events = events.length === 0 ? [...state.events, reviewedEvent] : events; + if (reviewedEvent.event === EventType.Reviewed) { + state.currentUserReviewState = reviewedEvent.state; + } + state.pendingCommentText = ''; + state.pendingReviewType = undefined; this.updatePR(state); } - public reRequestReview = async (reviewerLogin: string) => { - const { reviewers } = await this.postMessage({ command: 'pr.re-request-review', args: reviewerLogin }); - const state = this.pr; - state.reviewers = reviewers; + private readyForReviewComplete(reply: ReadyForReviewReply) { + const { pr: state } = this; + if (!state) { + throw new Error('Unexpectedly no pull request when trying to ready for review'); + } + const { isDraft, reviewEvent, reviewers } = reply; + state.busy = false; + state.isDraft = isDraft; + if (!reviewEvent) { + this.updatePR(state); + return; + } + if (reviewers) { + state.reviewers = reviewers; + } + state.events = [...state.events, reviewEvent]; + if (reviewEvent.event === EventType.Reviewed) { + state.currentUserReviewState = reviewEvent.state; + } + if (reply.autoMerge !== undefined) { + state.autoMerge = reply.autoMerge; + state.autoMergeMethod = state.defaultMergeMethod; + } this.updatePR(state); } + public reRequestReview = async (reviewerId: string) => { + const { pr: state } = this; + if (!state) { + throw new Error('Unexpectedly no pull request when trying to re-request review'); + } + 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; + if (!state) { + throw new Error('Unexpectedly no pull request when trying to update auto merge'); + } const response: { autoMerge: boolean, autoMergeMethod?: MergeMethod } = await this.postMessage({ command: 'pr.update-automerge', args: { autoMerge, autoMergeMethod } }); - const state = this.pr; state.autoMerge = response.autoMerge; state.autoMergeMethod = response.autoMergeMethod; this.updatePR(state); } + public updateBranch = async () => { + const { pr: state } = this; + if (!state) { + throw new Error('Unexpectedly no pull request when trying to update branch'); + } + const result: Partial<PullRequest> = await this.postMessage({ command: 'pr.update-branch' }); + state.events = result.events ?? state.events; + state.mergeable = result.mergeable ?? state.mergeable; + this.updatePR(state); + }; + + public dequeue = async () => { + const { pr: state } = this; + if (!state) { + throw new Error('Unexpectedly no pull request when trying to dequeue'); + } + const isDequeued = await this.postMessage({ command: 'pr.dequeue' }); + if (isDequeued) { + state.mergeQueueEntry = undefined; + } + this.updatePR(state); + }; + + public enqueue = async () => { + const { pr: state } = this; + if (!state) { + throw new Error('Unexpectedly no pull request when trying to enqueue'); + } + const result = await this.postMessage({ command: 'pr.enqueue' }); + if (result.mergeQueueEntry) { + state.mergeQueueEntry = result.mergeQueueEntry; + } + this.updatePR(state); + }; + public openDiff = (comment: IComment) => this.postMessage({ command: 'pr.open-diff', args: { comment } }); public toggleResolveComment = (threadId: string, thread: IComment[], newResolved: boolean) => { @@ -190,7 +359,19 @@ export class PRContext { }); }; - setPR = (pr: PullRequest) => { + public openSessionLog = (link: SessionLinkInfo) => this.postMessage({ command: 'pr.open-session-log', args: { link } }); + + public openCommitChanges = async (commitSha: string) => { + this.updatePR({ loadingCommit: commitSha }); + try { + const args: OpenCommitChangesArgs = { commitSha }; + await this.postMessage({ command: 'pr.openCommitChanges', args }); + } finally { + this.updatePR({ loadingCommit: undefined }); + } + }; + + setPR = (pr: PullRequest | undefined) => { this.pr = pr; setState(this.pr); if (this.onchange) { @@ -199,9 +380,9 @@ export class PRContext { return this; }; - updatePR = (pr: Partial<PullRequest>) => { + updatePR = (pr: Partial<PullRequest> | undefined) => { updateState(pr); - this.pr = { ...this.pr, ...pr }; + this.pr = this.pr ? { ...this.pr, ...pr } : pr as PullRequest; if (this.onchange) { this.onchange(this.pr); } @@ -214,6 +395,9 @@ export class PRContext { handleMessage = (message: any) => { switch (message.command) { + case 'pr.clear': + this.setPR(undefined); + return; case 'pr.initialize': return this.setPR(message.pullrequest); case 'update-state': @@ -236,11 +420,20 @@ export class PRContext { window.scrollTo(message.scrollPosition.x, message.scrollPosition.y); return; case 'pr.scrollToPendingReview': - const pendingReview = document.getElementById('pending-review'); + const pendingReview = document.getElementById('pending-review') ?? document.getElementById(COMMENT_TEXTAREA_ID); if (pendingReview) { pendingReview.scrollIntoView(); + pendingReview.focus(); } return; + case 'pr.submitting-review': + return this.updatePR({ busy: true, lastReviewType: message.lastReviewType }); + case 'pr.append-review': + return this.appendReview(message); + case 'pr.readying-for-review': + return this.updatePR({ busy: true }); + case 'pr.readied-for-review': + return this.readyForReviewComplete(message); } }; diff --git a/webviews/common/createContext.ts b/webviews/common/createContext.ts deleted file mode 100644 index 01ccd74842..0000000000 --- a/webviews/common/createContext.ts +++ /dev/null @@ -1,248 +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 { createContext } from 'react'; -import { CreateParams, CreatePullRequest, ScrollPosition } from '../../common/views'; -import { getMessageHandler, MessageHandler, vscode } from './message'; - -const defaultCreateParams: CreateParams = { - availableBaseRemotes: [], - availableCompareRemotes: [], - branchesForRemote: [], - branchesForCompare: [], - validate: false, - showTitleValidationError: false, - labels: [] -}; - -export class CreatePRContext { - public createParams: CreateParams; - - constructor( - public onchange: ((ctx: CreateParams) => void) | null = null, - private _handler: MessageHandler | null = null, - ) { - this.createParams = vscode.getState() ?? defaultCreateParams; - if (!_handler) { - this._handler = getMessageHandler(this.handleMessage); - } - } - - get initialized(): boolean { - if (this.createParams.availableBaseRemotes.length !== 0 - || this.createParams.availableCompareRemotes.length !== 0 - || this.createParams.branchesForRemote.length !== 0 - || this.createParams.branchesForCompare.length !== 0 - || this.createParams.validate - || this.createParams.showTitleValidationError) { - return true; - } - - return false; - } - - public cancelCreate = (): Promise<void> => { - const args = this.copyParams(); - vscode.setState(defaultCreateParams); - return this.postMessage({ command: 'pr.cancelCreate', args }); - }; - - public updateState = (params: Partial<CreateParams>): void => { - this.createParams = { ...this.createParams, ...params }; - vscode.setState(this.createParams); - if (this.onchange) { - this.onchange(this.createParams); - } - }; - - public changeBaseRemote = async (owner: string, repositoryName: string): Promise<void> => { - const response = await this.postMessage({ - command: 'pr.changeBaseRemote', - args: { - owner, - repositoryName, - }, - }); - - this.updateState({ - baseRemote: { owner, repositoryName }, - branchesForRemote: response.branches, - baseBranch: response.defaultBranch, - }); - }; - - public changeBaseBranch = async (branch: string): Promise<void> => { - const response: { title?: string, description?: string } = await this.postMessage({ - command: 'pr.changeBaseBranch', - args: branch - }); - - const pendingTitle = ((this.createParams.pendingTitle === undefined) || (this.createParams.pendingTitle === this.createParams.defaultTitle)) - ? response.title : this.createParams.pendingTitle; - const pendingDescription = ((this.createParams.pendingDescription === undefined) || (this.createParams.pendingDescription === this.createParams.defaultDescription)) - ? response.description : this.createParams.pendingDescription; - - this.updateState({ - pendingTitle, - pendingDescription - }); - }; - - public changeCompareRemote = async (owner: string, repositoryName: string): Promise<void> => { - const response = await this.postMessage({ - command: 'pr.changeCompareRemote', - args: { - owner, - repositoryName, - }, - }); - - this.updateState({ - compareRemote: { owner, repositoryName }, - branchesForCompare: response.branches, - compareBranch: response.defaultBranch, - }); - }; - - public changeCompareBranch = async (branch: string): Promise<void> => { - return this.postMessage({ command: 'pr.changeCompareBranch', args: branch }); - }; - - public validate = (): boolean => { - let isValid = true; - if (!this.createParams.pendingTitle) { - this.updateState({ showTitleValidationError: true }); - isValid = false; - } - - this.updateState({ validate: true, createError: undefined }); - - return isValid; - }; - - private copyParams(): CreatePullRequest { - return { - title: this.createParams.pendingTitle!, - body: this.createParams.pendingDescription!, - owner: this.createParams.baseRemote!.owner, - repo: this.createParams.baseRemote!.repositoryName, - base: this.createParams.baseBranch!, - compareBranch: this.createParams.compareBranch!, - compareOwner: this.createParams.compareRemote!.owner, - compareRepo: this.createParams.compareRemote!.repositoryName, - draft: !!this.createParams.isDraft, - autoMerge: !!this.createParams.autoMerge, - autoMergeMethod: this.createParams.autoMergeMethod, - labels: this.createParams.labels ?? [] - }; - } - - public submit = async (): Promise<void> => { - try { - const args: CreatePullRequest = this.copyParams(); - vscode.setState(defaultCreateParams); - await this.postMessage({ - command: 'pr.create', - args, - }); - } catch (e) { - this.updateState({ createError: (typeof e === 'string') ? e : (e.message ? e.message : 'An unknown error occurred.') }); - } - }; - - postMessage = async (message: any): Promise<any> => { - return this._handler?.postMessage(message); - }; - - handleMessage = async (message: { command: string, params?: CreateParams, scrollPosition?: ScrollPosition }): Promise<void> => { - switch (message.command) { - case 'pr.initialize': - if (!message.params) { - return; - } - if (this.createParams.pendingTitle === undefined) { - message.params.pendingTitle = message.params.defaultTitle; - } - - if (this.createParams.pendingDescription === undefined) { - message.params.pendingDescription = message.params.defaultDescription; - } - - if (this.createParams.baseRemote === undefined) { - message.params.baseRemote = message.params.defaultBaseRemote; - } else { - // Notify the extension of the stored selected remote state - await this.changeBaseRemote( - this.createParams.baseRemote.owner, - this.createParams.baseRemote.repositoryName, - ); - } - - if (this.createParams.baseBranch === undefined) { - message.params.baseBranch = message.params.defaultBaseBranch; - } else { - // Notify the extension of the stored base branch state - await this.changeBaseBranch(this.createParams.baseBranch); - } - - if (this.createParams.compareRemote === undefined) { - message.params.compareRemote = message.params.defaultCompareRemote; - } else { - // Notify the extension of the stored base branch state This is where master is getting set. - await this.changeCompareRemote( - this.createParams.compareRemote.owner, - this.createParams.compareRemote.repositoryName - ); - } - - if (this.createParams.compareBranch === undefined) { - message.params.compareBranch = message.params.defaultCompareBranch; - } else { - // Notify the extension of the stored compare branch state - await this.changeCompareBranch(this.createParams.compareBranch); - } - - if (this.createParams.isDraft === undefined) { - message.params.isDraft = false; - } - - this.updateState(message.params); - return; - - case 'reset': - if (!message.params) { - return; - } - message.params.pendingTitle = message.params.defaultTitle; - message.params.pendingDescription = message.params.defaultDescription; - message.params.baseRemote = message.params.defaultBaseRemote; - message.params.baseBranch = message.params.defaultBaseBranch; - message.params.compareBranch = message.params.defaultCompareBranch; - message.params.compareRemote = message.params.defaultCompareRemote; - message.params.autoMerge = false; - this.updateState(message.params); - return; - - case 'set-scroll': - if (!message.scrollPosition) { - return; - } - window.scrollTo(message.scrollPosition.x, message.scrollPosition.y); - return; - - case 'set-labels': - if (!message.params) { - return; - } - this.updateState(message.params); - return; - } - }; - - public static instance = new CreatePRContext(); -} - -const PullRequestContext = createContext<CreatePRContext>(CreatePRContext.instance); -export default PullRequestContext; diff --git a/webviews/common/createContextNew.ts b/webviews/common/createContextNew.ts new file mode 100644 index 0000000000..b96d8daf44 --- /dev/null +++ b/webviews/common/createContextNew.ts @@ -0,0 +1,389 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +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'; + +const defaultCreateParams: CreateParamsNew = { + canModifyBranches: true, + defaultBaseRemote: undefined, + defaultBaseBranch: undefined, + defaultCompareRemote: undefined, + defaultCompareBranch: undefined, + validate: false, + showTitleValidationError: false, + labels: [], + isDraftDefault: false, + autoMergeDefault: false, + assignees: [], + reviewers: [], + milestone: undefined, + defaultTitle: undefined, + pendingTitle: undefined, + defaultDescription: undefined, + pendingDescription: undefined, + creating: false, + generateTitleAndDescriptionTitle: undefined, + initializeWithGeneratedTitleAndDescription: false, + baseHasMergeQueue: false, + preReviewState: PreReviewState.None, + preReviewer: undefined, + reviewing: false, + usingTemplate: false +}; + +export class CreatePRContextNew { + public createParams: CreateParamsNew; + private _titleStack: string[] = []; + private _descriptionStack: string[] = []; + + constructor( + public onchange: ((ctx: CreateParamsNew) => void) | null = null, + private _handler: MessageHandler | null = null, + ) { + this.createParams = vscode.getState() ?? defaultCreateParams; + if (!_handler) { + this._handler = getMessageHandler(this.handleMessage); + } + } + + get isCreatable(): boolean { + if (!this.createParams.canModifyBranches) { + return true; + } + if (this.createParams.baseRemote && this.createParams.compareRemote && this.createParams.baseBranch && this.createParams.compareBranch + && compareIgnoreCase(this.createParams.baseRemote?.owner, this.createParams.compareRemote?.owner) === 0 + && compareIgnoreCase(this.createParams.baseRemote?.repositoryName, this.createParams.compareRemote?.repositoryName) === 0 + && compareIgnoreCase(this.createParams.baseBranch, this.createParams.compareBranch) === 0) { + + return false; + } + return true; + } + + get initialized(): boolean { + if (!this.createParams.canModifyBranches) { + return true; + } + + if (this.createParams.defaultBaseRemote !== undefined + || this.createParams.defaultBaseBranch !== undefined + || this.createParams.defaultCompareRemote !== undefined + || this.createParams.defaultCompareBranch !== undefined + || this.createParams.validate + || this.createParams.showTitleValidationError) { + return true; + } + + return false; + } + + private _requestedInitialize = false; + public initialize = async (): Promise<void> => { + if (!this._requestedInitialize) { + this._requestedInitialize = true; + this.postMessage({ command: 'pr.requestInitialize' }); + } + }; + + public cancelCreate = (): Promise<void> => { + const args = this.copyParams(); + vscode.setState(defaultCreateParams); + return this.postMessage({ command: 'pr.cancelCreate', args }); + }; + + public updateState = (params: Partial<CreateParamsNew>, reset: boolean = false): void => { + this.createParams = reset ? { ...defaultCreateParams, ...params } : { ...this.createParams, ...params }; + vscode.setState(this.createParams); + if (this.onchange) { + this.onchange(this.createParams); + } + }; + + public changeBaseRemoteAndBranch = async (currentRemote?: RemoteInfo, currentBranch?: string): Promise<void> => { + const args: ChooseRemoteAndBranchArgs = { + currentRemote, + currentBranch + }; + const startingBaseOwner = this.createParams.baseRemote?.owner; + const startingBaseRepo = this.createParams.baseRemote?.repositoryName; + const response: ChooseBaseRemoteAndBranchResult = await this.postMessage({ + command: 'pr.changeBaseRemoteAndBranch', + args + }); + + const updateValues: Partial<CreateParamsNew> = { + baseRemote: response.baseRemote, + baseBranch: response.baseBranch, + createError: '' + }; + if ((startingBaseOwner !== response.baseRemote.owner) || (startingBaseRepo !== response.baseRemote.repositoryName)) { + updateValues.defaultMergeMethod = response.defaultMergeMethod; + updateValues.allowAutoMerge = response.allowAutoMerge; + updateValues.mergeMethodsAvailability = response.mergeMethodsAvailability; + updateValues.autoMergeDefault = response.autoMergeDefault; + updateValues.baseHasMergeQueue = response.baseHasMergeQueue; + if (!this.createParams.allowAutoMerge && updateValues.allowAutoMerge) { + updateValues.autoMerge = this.createParams.isDraft ? false : updateValues.autoMergeDefault; + } else if (this.createParams.allowAutoMerge && !updateValues.allowAutoMerge) { + updateValues.autoMerge = false; + } + updateValues.defaultTitle = response.defaultTitle; + if ((this.createParams.pendingTitle === undefined) || (this.createParams.pendingTitle === this.createParams.defaultTitle)) { + updateValues.pendingTitle = response.defaultTitle; + } + updateValues.defaultDescription = response.defaultDescription; + if ((this.createParams.pendingDescription === undefined) || (this.createParams.pendingDescription === this.createParams.defaultDescription)) { + updateValues.pendingDescription = response.defaultDescription; + } + } + + this.updateState(updateValues); + }; + + public openAssociatedPullRequest = async (): Promise<void> => { + return this.postMessage({ command: 'pr.openAssociatedPullRequest' }); + }; + + public changeMergeRemoteAndBranch = async (currentRemote?: RemoteInfo, currentBranch?: string): Promise<void> => { + const args: ChooseRemoteAndBranchArgs = { + currentRemote, + currentBranch + }; + const response: ChooseCompareRemoteAndBranchResult = await this.postMessage({ + command: 'pr.changeCompareRemoteAndBranch', + args + }); + + const updateValues: Partial<CreateParamsNew> = { + compareRemote: response.compareRemote, + compareBranch: response.compareBranch, + createError: '' + }; + + this.updateState(updateValues); + }; + + public generateTitle = async (useCopilot: boolean): Promise<void> => { + const args: TitleAndDescriptionArgs = { + useCopilot + }; + const response: TitleAndDescriptionResult = await this.postMessage({ + command: 'pr.generateTitleAndDescription', + args + }); + const updateValues: { pendingTitle?: string, pendingDescription?: string, showTitleValidationError?: boolean } = {}; + if (response.title) { + updateValues.pendingTitle = response.title; + updateValues.showTitleValidationError = false; + } + if (response.description) { + updateValues.pendingDescription = response.description; + } + if (updateValues.pendingTitle && this.createParams.pendingTitle && this.createParams.pendingTitle !== updateValues.pendingTitle) { + this._titleStack.push(this.createParams.pendingTitle); + } + if (updateValues.pendingDescription && this.createParams.pendingDescription && this.createParams.pendingDescription !== updateValues.pendingDescription) { + this._descriptionStack.push(this.createParams.pendingDescription); + } + this.updateState(updateValues); + }; + + public cancelGenerateTitle = async (): Promise<void> => { + return this.postMessage({ + command: 'pr.cancelGenerateTitleAndDescription' + }); + }; + + public popTitle = (): void => { + if (this._titleStack.length > 0) { + this.updateState({ pendingTitle: this._titleStack.pop() }); + } + }; + + public popDescription = (): void => { + if (this._descriptionStack.length > 0) { + this.updateState({ pendingDescription: this._descriptionStack.pop() }); + } + }; + + public preReview = async (): Promise<void> => { + this.updateState({ reviewing: true }); + const result: PreReviewState = await this.postMessage({ command: 'pr.preReview' }); + this.updateState({ preReviewState: result, reviewing: false }); + }; + + public cancelPreReview = async (): Promise<void> => { + return this.postMessage({ command: 'pr.cancelPreReview' }); + }; + + public validate = (): boolean => { + let isValid = true; + if (!this.createParams.pendingTitle) { + this.updateState({ showTitleValidationError: true }); + isValid = false; + } + + this.updateState({ validate: true, createError: undefined, creating: false }); + + return isValid; + }; + + private copyParams(): CreatePullRequestNew { + return { + title: this.createParams.pendingTitle!, + body: this.createParams.pendingDescription!, + owner: this.createParams.baseRemote!.owner, + repo: this.createParams.baseRemote!.repositoryName, + base: this.createParams.baseBranch!, + compareBranch: this.createParams.compareBranch!, + compareOwner: this.createParams.compareRemote!.owner, + compareRepo: this.createParams.compareRemote!.repositoryName, + draft: !!this.createParams.isDraft, + autoMerge: !!this.createParams.autoMerge, + autoMergeMethod: this.createParams.autoMergeMethod, + labels: this.createParams.labels ?? [], + projects: this.createParams.projects ?? [], + assignees: this.createParams.assignees ?? [], + reviewers: this.createParams.reviewers ?? [], + milestone: this.createParams.milestone + }; + } + + public submit = async (): Promise<void> => { + try { + this.updateState({ creating: false }); + const args: CreatePullRequestNew = this.copyParams(); + vscode.setState(defaultCreateParams); + await this.postMessage({ + command: 'pr.create', + args, + }); + } catch (e) { + this.updateState({ createError: (typeof e === 'string') ? e : (e.message ? e.message : 'An unknown error occurred.') }); + } + }; + + postMessage = async (message: any): Promise<any> => { + return this._handler?.postMessage(message); + }; + + handleMessage = async (message: { command: string, params?: Partial<CreateParamsNew>, scrollPosition?: ScrollPosition }): Promise<void> => { + switch (message.command) { + case 'pr.initialize': + if (!message.params) { + return; + } + if (this.createParams.pendingTitle === undefined) { + message.params.pendingTitle = message.params.defaultTitle; + } + + if (this.createParams.pendingDescription === undefined) { + message.params.pendingDescription = message.params.defaultDescription; + } + + if (this.createParams.baseRemote === undefined) { + message.params.baseRemote = message.params.defaultBaseRemote; + } + + if (this.createParams.baseBranch === undefined) { + message.params.baseBranch = message.params.defaultBaseBranch; + } + + if (this.createParams.compareRemote === undefined) { + message.params.compareRemote = message.params.defaultCompareRemote; + } + + if (this.createParams.compareBranch === undefined) { + message.params.compareBranch = message.params.defaultCompareBranch; + } + + if (this.createParams.isDraft === undefined) { + message.params.isDraft = message.params.isDraftDefault; + } else { + message.params.isDraft = this.createParams.isDraft; + } + + if (this.createParams.autoMerge === undefined) { + message.params.autoMerge = message.params.autoMergeDefault; + message.params.autoMergeMethod = message.params.defaultMergeMethod; + if (message.params.autoMerge) { + message.params.isDraft = false; + } + } else { + message.params.autoMerge = this.createParams.autoMerge; + message.params.autoMergeMethod = this.createParams.autoMergeMethod; + } + + this.updateState(message.params); + return; + + case 'reset': + if (!message.params) { + this.updateState(defaultCreateParams, true); + return; + } + message.params.creating = message.params.creating ?? false; + message.params.pendingTitle = message.params.defaultTitle ?? this.createParams.pendingTitle; + message.params.pendingDescription = message.params.defaultDescription ?? this.createParams.pendingDescription; + message.params.baseRemote = message.params.defaultBaseRemote ?? this.createParams.baseRemote; + message.params.baseBranch = message.params.defaultBaseBranch ?? this.createParams.baseBranch; + message.params.compareBranch = message.params.defaultCompareBranch ?? this.createParams.compareBranch; + message.params.compareRemote = message.params.defaultCompareRemote ?? this.createParams.compareRemote; + message.params.autoMerge = (message.params.autoMergeDefault !== undefined ? message.params.autoMergeDefault : this.createParams.autoMerge); + message.params.autoMergeMethod = (message.params.defaultMergeMethod !== undefined ? message.params.defaultMergeMethod : this.createParams.autoMergeMethod); + message.params.isDraft = (message.params.isDraftDefault !== undefined ? message.params.isDraftDefault : this.createParams.isDraft); + if (message.params.autoMergeDefault) { + message.params.isDraft = false; + } + this.updateState(message.params); + return; + + case 'set-scroll': + if (!message.scrollPosition) { + return; + } + window.scrollTo(message.scrollPosition.x, message.scrollPosition.y); + return; + + case 'set-labels': + case 'set-assignees': + case 'set-reviewers': + case 'set-projects': + if (!message.params) { + return; + } + this.updateState(message.params); + return; + case 'set-milestone': + if (!message.params) { + return; + } + this.updateState(Object.keys(message.params).length === 0 ? { milestone: undefined } : message.params); + return; + case 'create': + if (!message.params) { + return; + } + this.updateState(message.params); + return; + case 'reviewing': + if (!message.params) { + return; + } + this.preReview(); + return; + } + }; + + public static instance = new CreatePRContextNew(); +} + +const PullRequestContextNew = createContext<CreatePRContextNew>(CreatePRContextNew.instance); +export default PullRequestContextNew; diff --git a/webviews/common/errorBoundary.tsx b/webviews/common/errorBoundary.tsx index 1f21c1021c..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<ErrorBoundaryProps, ErrorBoundaryState> { + constructor(props: ErrorBoundaryProps) { super(props); this.state = { hasError: false }; } - static getDerivedStateFromError(_error) { + static getDerivedStateFromError(_error: unknown): ErrorBoundaryState { return { hasError: true }; } - componentDidCatch(error, errorInfo) { - console.log(error); - console.log(errorInfo); + override componentDidCatch(error: unknown, errorInfo: React.ErrorInfo): void { + console.error(error); + console.error(errorInfo); } - render() { - if ((this.state as any).hasError) { + override render(): React.ReactNode { + if (this.state.hasError) { return <div>Something went wrong.</div>; } diff --git a/webviews/common/label.tsx b/webviews/common/label.tsx index 76e0bd879e..8cfc160578 100644 --- a/webviews/common/label.tsx +++ b/webviews/common/label.tsx @@ -1,31 +1,45 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - - -import React, { ReactNode } from 'react'; -import { gitHubLabelColor } from '../../src/common/utils'; -import { ILabel } from '../../src/github/interface'; - -export interface LabelProps { - label: ILabel & { canDelete: boolean; isDarkTheme: boolean }; -} - -export function Label(label: ILabel & { canDelete: boolean; isDarkTheme: boolean; children?: ReactNode}) { - const { name, canDelete, color } = label; - const labelColor = gitHubLabelColor(color, label.isDarkTheme, false); - return ( - <div - className="section-item label" - style={{ - backgroundColor: labelColor.backgroundColor, - color: labelColor.textColor, - borderColor: `${labelColor.borderColor}`, - paddingRight: canDelete ? '2px' : '8px' - }} - > - {name}{label.children} - </div> - ); -} \ No newline at end of file +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import React, { ReactNode } from 'react'; +import { gitHubLabelColor } from '../../src/common/utils'; +import { DisplayLabel } from '../../src/github/views'; + +export interface LabelProps { + label: DisplayLabel & { canDelete: boolean; isDarkTheme: boolean }; +} + +export function Label(label: DisplayLabel & { canDelete: boolean; isDarkTheme: boolean; children?: ReactNode}) { + const { displayName, canDelete, color } = label; + const labelColor = gitHubLabelColor(color, label.isDarkTheme, false); + return ( + <div + className="section-item label" + style={{ + backgroundColor: labelColor.backgroundColor, + color: labelColor.textColor, + borderColor: `${labelColor.borderColor}`, + paddingRight: canDelete ? '2px' : '8px' + }} + > + {displayName}{label.children} + </div> + ); +} + +export function LabelCreate(label: DisplayLabel & { canDelete: boolean; isDarkTheme: boolean; children?: ReactNode}) { + const { displayName, color } = label; + const labelColor = gitHubLabelColor(color, label.isDarkTheme, false); + return ( + <li + style={{ + backgroundColor: labelColor.backgroundColor, + color: labelColor.textColor, + borderColor: `${labelColor.borderColor}` + }}> + {displayName}{label.children}</li> + ); +} diff --git a/webviews/common/message.ts b/webviews/common/message.ts index bc15a80e35..4d720f22f6 100644 --- a/webviews/common/message.ts +++ b/webviews/common/message.ts @@ -11,22 +11,24 @@ interface IRequestMessage<T> { interface IReplyMessage { seq: string; - err: any; + err: string; + // eslint-disable-next-line rulesdir/no-any-except-union-method-signature res: any; } +// eslint-disable-next-line rulesdir/no-any-except-union-method-signature declare let acquireVsCodeApi: any; export const vscode = acquireVsCodeApi(); export class MessageHandler { private _commandHandler: ((message: any) => void) | null; private lastSentReq: number; - private pendingReplies: any; + private pendingReplies: Record<string, { resolve: (value: any) => void; reject: (reason?: string) => void }>; constructor(commandHandler: any) { this._commandHandler = commandHandler; this.lastSentReq = 0; this.pendingReplies = Object.create(null); - window.addEventListener('message', this.handleMessage.bind(this)); + window.addEventListener('message', this.handleMessage.bind(this) as (this: Window, ev: MessageEvent<any>) => any); } public registerCommandHandler(commandHandler: (message: any) => void) { diff --git a/webviews/components/automergeSelect.tsx b/webviews/components/automergeSelect.tsx index 0c32a97e45..efc6c349a8 100644 --- a/webviews/components/automergeSelect.tsx +++ b/webviews/components/automergeSelect.tsx @@ -4,18 +4,31 @@ *--------------------------------------------------------------------------------------------*/ import * as React from 'react'; -import { MergeMethod, MergeMethodsAvailability } from '../../src/github/interface'; import { MergeSelect } from './merge'; +import { MergeMethod, MergeMethodsAvailability, MergeQueueEntry, MergeQueueState } from '../../src/github/interface'; +import PullRequestContext from '../common/context'; + +const AutoMergeLabel = ({ busy, baseHasMergeQueue }: { busy: boolean, baseHasMergeQueue: boolean }) => { + if (busy) { + return <label htmlFor="automerge-checkbox" className="automerge-checkbox-label">Setting...</label>; + } else { + return <label htmlFor="automerge-checkbox" className="automerge-checkbox-label"> + {baseHasMergeQueue ? 'Merge when ready' : 'Auto-merge'} + </label>; + } +}; export const AutoMerge = ({ updateState, + baseHasMergeQueue, allowAutoMerge, defaultMergeMethod, mergeMethodsAvailability, autoMerge, isDraft, }: { - updateState: (params: Partial<{ autoMerge: boolean; autoMergeMethod: MergeMethod }>) => void; + updateState: (params: Partial<{ autoMerge: boolean; autoMergeMethod: MergeMethod }>) => Promise<void>; + baseHasMergeQueue: boolean; allowAutoMerge?: boolean; defaultMergeMethod?: MergeMethod; mergeMethodsAvailability?: MergeMethodsAvailability; @@ -25,35 +38,85 @@ export const AutoMerge = ({ if ((!allowAutoMerge && !autoMerge) || !mergeMethodsAvailability || !defaultMergeMethod) { return null; } - const select = React.useRef<HTMLSelectElement>(); + const select: React.MutableRefObject<HTMLSelectElement> = React.useRef<HTMLSelectElement>() as React.MutableRefObject<HTMLSelectElement>; + + const [isBusy, setBusy] = React.useState(false); + const selectedMethod = (): MergeMethod => { + const value: string = select.current?.value ?? 'merge'; + return value as MergeMethod; + }; return ( <div className="automerge-section"> - <div className="automerge-checkbox-wrapper"> + <div className="automerge-checkbox-wrapper checkbox-wrapper"> <input id="automerge-checkbox" type="checkbox" name="automerge" checked={autoMerge} - disabled={!allowAutoMerge || isDraft} - onChange={() => - updateState({ autoMerge: !autoMerge, autoMergeMethod: select.current?.value as MergeMethod }) - } - ></input> - </div> - <label htmlFor="automerge-checkbox" className="automerge-checkbox-label"> - Auto-merge - </label> - <div className="merge-select-container"> - <MergeSelect - ref={select} - defaultMergeMethod={defaultMergeMethod} - mergeMethodsAvailability={mergeMethodsAvailability} - onChange={() => { - updateState({ autoMergeMethod: select.current?.value as MergeMethod }); + disabled={!allowAutoMerge || isDraft || isBusy} + onChange={async () => { + setBusy(true); + await updateState({ autoMerge: !autoMerge, autoMergeMethod: selectedMethod() }); + setBusy(false); }} - /> + ></input> </div> + <AutoMergeLabel busy={isBusy} baseHasMergeQueue={baseHasMergeQueue} /> + {baseHasMergeQueue ? null : + <div className="merge-select-container"> + <MergeSelect + ref={select} + defaultMergeMethod={defaultMergeMethod} + mergeMethodsAvailability={mergeMethodsAvailability} + onChange={async () => { + setBusy(true); + await updateState({ autoMergeMethod: selectedMethod() }); + setBusy(false); + }} + disabled={isBusy} + /> + </div> + } </div> ); }; + +export const QueuedToMerge = ({ mergeQueueEntry }: { mergeQueueEntry: MergeQueueEntry }) => { + const ctx = React.useContext(PullRequestContext); + let message; + let title; + switch (mergeQueueEntry.state) { + case (MergeQueueState.Mergeable): // TODO @alexr00 What does "Mergeable" mean in the context of a merge queue? + case (MergeQueueState.AwaitingChecks): + case (MergeQueueState.Queued): { + title = <span className="merge-queue-pending">Queued to merge...</span>; + if (mergeQueueEntry.position === 1) { + message = <span>This pull request is at the head of the <a href={mergeQueueEntry.url}>merge queue</a>.</span>; + } else { + message = <span>This pull request is in the <a href={mergeQueueEntry.url}>merge queue</a>.</span>; + } + break; + } + case (MergeQueueState.Locked): { + title = <span className="merge-queue-blocked">Merging is blocked</span>; + message = <span>The base branch does not allow updates</span>; + break; + } + case (MergeQueueState.Unmergeable): { + title = <span className="merge-queue-blocked">Merging is blocked</span>; + message = <span>There are conflicts with the base branch.</span>; + break; + } + } + return <div className="merge-queue-container"> + <div className="merge-queue"> + <div className="merge-queue-icon"></div> + <div className="merge-queue-title">{title}</div> + {message} + </div> + <div className='button-container'> + <button onClick={ctx.dequeue}>Remove from Queue</button> + </div> + </div>; +}; diff --git a/webviews/components/comment.tsx b/webviews/components/comment.tsx index c8f7dee4d9..6233fa5185 100644 --- a/webviews/components/comment.tsx +++ b/webviews/components/comment.tsx @@ -4,40 +4,62 @@ *--------------------------------------------------------------------------------------------*/ import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { ContextDropdown } from './contextDropdown'; +import { copyIcon, editIcon, quoteIcon, sparkleIcon, stopCircleIcon, 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'; -import { PullRequest, ReviewType } from '../common/cache'; +import { PullRequest, ReviewType } from '../../src/github/views'; +import { ariaAnnouncementForReview } from '../common/aria'; +import { COMMENT_TEXTAREA_ID } from '../common/constants'; import PullRequestContext from '../common/context'; import emitter from '../common/events'; import { useStateProp } from '../common/hooks'; -import { Dropdown } from './dropdown'; -import { commentIcon, deleteIcon, editIcon } from './icon'; -import { nbsp, Spaced } from './space'; -import { Timestamp } from './timestamp'; -import { AuthorLink, Avatar } from './user'; -export type Props = Partial<IComment & PullRequest> & { +export type Props = { headerInEditMode?: boolean; isPRDescription?: boolean; + children?: React.ReactNode; + comment: IComment | ReviewEvent | PullRequest | CommentEvent; + allowEmpty?: boolean; }; -export function CommentView(comment: Props) { - const { id, pullRequestReviewId, canEdit, canDelete, bodyHTML, body, isPRDescription } = comment; +const association = ({ authorAssociation }: ReviewEvent, format = (assoc: string) => `(${assoc.toLowerCase()})`) => + authorAssociation.toLowerCase() === 'user' + ? format('you') + : authorAssociation && authorAssociation !== 'NONE' + ? format(authorAssociation) + : null; + +export function CommentView(commentProps: Props) { + const { isPRDescription, children, comment, headerInEditMode } = commentProps; + const { bodyHTML, body } = comment; + + const id = (comment as Partial<IComment | ReviewEvent | CommentEvent>).id ?? -1; + const canEdit: boolean = !!(comment as Partial<IComment | PullRequest | CommentEvent>).canEdit; + const canDelete: boolean = !!(comment as Partial<IComment | CommentEvent>).canDelete; + + const pullRequestReviewId = (comment as IComment).pullRequestReviewId; const [bodyMd, setBodyMd] = useStateProp(body); const [bodyHTMLState, setBodyHtml] = useStateProp(bodyHTML); const { deleteComment, editComment, setDescription, pr } = useContext(PullRequestContext); - const currentDraft = pr.pendingCommentDrafts && pr.pendingCommentDrafts[id]; + const currentDraft = pr?.pendingCommentDrafts && pr.pendingCommentDrafts[id]; const [inEditMode, setEditMode] = useState(!!currentDraft); const [showActionBar, setShowActionBar] = useState(false); + const commentUrl = (comment as Partial<IComment | ReviewEvent | CommentEvent>).htmlUrl || (comment as PullRequest).url; if (inEditMode) { - return React.cloneElement(comment.headerInEditMode ? <CommentBox for={comment} /> : <></>, {}, [ + return React.cloneElement(headerInEditMode ? <CommentBox for={comment} /> : <></>, {}, [ <EditComment id={id} key={`editComment${id}`} body={currentDraft || bodyMd} + isPRDescription={isPRDescription} onCancel={() => { - if (pr.pendingCommentDrafts) { + if (pr?.pendingCommentDrafts) { delete pr.pendingCommentDrafts[id]; } setEditMode(false); @@ -58,6 +80,9 @@ export function CommentView(comment: Props) { ]); } + const ariaAnnouncement = ((comment as CommentEvent | ReviewEvent).event === EventType.Commented || (comment as CommentEvent | ReviewEvent).event === EventType.Reviewed) + ? ariaAnnouncementForReview(comment as (CommentEvent | ReviewEvent)) : undefined; + return ( <CommentBox for={comment} @@ -65,14 +90,24 @@ export function CommentView(comment: Props) { onMouseLeave={() => setShowActionBar(false)} onFocus={() => setShowActionBar(true)} > + {ariaAnnouncement ? <div role='alert' aria-label={ariaAnnouncement} /> : null} <div className="action-bar comment-actions" style={{ display: showActionBar ? 'flex' : 'none' }}> <button title="Quote reply" className="icon-button" onClick={() => emitter.emit('quoteReply', bodyMd)} > - {commentIcon} + {quoteIcon} </button> + {commentUrl ? ( + <button + title="Copy Comment Link" + className="icon-button" + onClick={() => navigator.clipboard.writeText(commentUrl)} + > + {copyIcon} + </button> + ) : null} {canEdit ? ( <button title="Edit comment" className="icon-button" onClick={() => setEditMode(true)}> {editIcon} @@ -84,7 +119,7 @@ export function CommentView(comment: Props) { className="icon-button" onClick={() => deleteComment({ id, pullRequestReviewId })} > - {deleteIcon} + {trashIcon} </button> ) : null} </div> @@ -92,33 +127,64 @@ export function CommentView(comment: Props) { comment={comment as IComment} bodyHTML={bodyHTMLState} body={bodyMd} - canApplyPatch={pr.isCurrentlyCheckedOut} + canApplyPatch={!!pr?.isCurrentlyCheckedOut} + allowEmpty={!!commentProps.allowEmpty} + specialDisplayBodyPostfix={(comment as IComment).specialDisplayBodyPostfix} /> + {children} </CommentBox> ); } type CommentBoxProps = { - for: Partial<IComment & PullRequest>; + for: IComment | ReviewEvent | PullRequest | CommentEvent; header?: React.ReactChild; - onFocus?: any; - onMouseEnter?: any; - onMouseLeave?: any; - children?: any; + onFocus?: React.FocusEventHandler; + onMouseEnter?: React.MouseEventHandler; + onMouseLeave?: React.MouseEventHandler; + children?: React.ReactNode; +}; + +function isReviewEvent(comment: IComment | ReviewEvent | PullRequest | CommentEvent): comment is ReviewEvent { + return (comment as ReviewEvent).authorAssociation !== undefined; +} + +function isIComment(comment: any): comment is IComment { + return comment && typeof comment === 'object' && + typeof comment.body === 'string' && typeof comment.diffHunk === 'string'; +} + +const DESCRIPTORS = { + REQUESTED: 'will review', + PENDING: 'will review', + COMMENTED: 'reviewed', + CHANGES_REQUESTED: 'requested changes', + APPROVED: 'approved', }; +const reviewDescriptor = (state: keyof typeof DESCRIPTORS) => DESCRIPTORS[state]; + function CommentBox({ for: comment, onFocus, onMouseEnter, onMouseLeave, children }: CommentBoxProps) { - const { user, author, createdAt, htmlUrl, isDraft } = comment; + const asNotPullRequest = comment as Partial<IComment | ReviewEvent | CommentEvent>; + const htmlUrl = asNotPullRequest.htmlUrl ?? (comment as PullRequest).url; + const isDraft = (isIComment(comment) && comment.isDraft) ?? (isReviewEvent(comment) && (comment.state?.toLocaleUpperCase() === 'PENDING')); + const author = asNotPullRequest.user ?? (comment as PullRequest).author; + const createdAt = (comment as IComment | CommentEvent | PullRequest).createdAt ?? (comment as ReviewEvent).submittedAt; + return ( <div className="comment-container comment review-comment" {...{ onFocus, onMouseEnter, onMouseLeave }}> <div className="review-comment-container"> - <div className="review-comment-header"> + <h3 className={`review-comment-header${(isReviewEvent(comment) && comment.comments.length > 0) ? '' : ' no-details'}`}> <Spaced> - <Avatar for={user || author} /> - <AuthorLink for={user || author} /> + <Avatar for={author} /> + <AuthorLink for={author} /> + {isReviewEvent(comment) ? association(comment) : null} + + {createdAt ? ( <> - commented{nbsp} + {(isReviewEvent(comment) && comment.state) ? reviewDescriptor(comment.state) : 'commented'} + {nbsp} <Timestamp href={htmlUrl} date={createdAt} /> </> ) : ( @@ -130,7 +196,7 @@ function CommentBox({ for: comment, onFocus, onMouseEnter, onMouseLeave, childre </> ) : null} </Spaced> - </div> + </h3> {children} </div> </div> @@ -144,14 +210,16 @@ type FormInputSet = { type EditCommentProps = { id: number; body: string; + isPRDescription?: boolean; onCancel: () => void; onSave: (body: string) => Promise<any>; }; -function EditComment({ id, body, onCancel, onSave }: EditCommentProps) { - const { updateDraft } = useContext(PullRequestContext); +function EditComment({ id, body, isPRDescription, onCancel, onSave }: EditCommentProps) { + const { updateDraft, pr, generateDescription, cancelGenerateDescription } = useContext(PullRequestContext); const draftComment = useRef<{ body: string; dirty: boolean }>({ body, dirty: false }); const form = useRef<HTMLFormElement>(); + const [isGenerating, setIsGenerating] = useState(false); useEffect(() => { const interval = setInterval(() => { @@ -164,7 +232,7 @@ function EditComment({ id, body, onCancel, onSave }: EditCommentProps) { }, [draftComment]); const submit = useCallback(async () => { - const { markdown, submitButton }: FormInputSet = form.current; + const { markdown, submitButton }: FormInputSet = form.current!; submitButton.disabled = true; try { await onSave(markdown.value); @@ -193,15 +261,63 @@ 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], ); + const handleGenerateDescription = useCallback(async () => { + if (!generateDescription) { + return; + } + setIsGenerating(true); + try { + const generated = await generateDescription(); + if (generated?.description && form.current) { + const textarea = form.current.markdown as HTMLTextAreaElement; + textarea.value = generated.description; + draftComment.current.body = generated.description; + draftComment.current.dirty = true; + } + } finally { + setIsGenerating(false); + } + }, [generateDescription]); + + const handleCancelGenerate = useCallback(() => { + if (cancelGenerateDescription) { + cancelGenerateDescription(); + } + setIsGenerating(false); + }, [cancelGenerateDescription]); + return ( - <form ref={form} onSubmit={onSubmit}> - <textarea name="markdown" defaultValue={body} onKeyDown={onKeyDown} onInput={onInput} /> + <form ref={form as React.MutableRefObject<HTMLFormElement>} onSubmit={onSubmit}> + <div className="textarea-wrapper"> + <textarea name="markdown" defaultValue={body} onKeyDown={onKeyDown} onInput={onInput} disabled={isGenerating} /> + {isPRDescription ? ( + isGenerating ? ( + <button + type="button" + title="Cancel" + className="title-action icon-button" + onClick={handleCancelGenerate} + > + {stopCircleIcon} + </button> + ) : ( + <button + type="button" + title={pr?.generateDescriptionTitle || 'Generate description'} + className="title-action icon-button" + onClick={handleGenerateDescription} + > + {sparkleIcon} + </button> + ) + ) : null} + </div> <div className="form-actions"> <button className="secondary" onClick={onCancel}> Cancel @@ -217,10 +333,15 @@ export interface Embodied { bodyHTML?: string; body?: string; canApplyPatch: boolean; + allowEmpty: boolean; + specialDisplayBodyPostfix?: string; } -export const CommentBody = ({ comment, bodyHTML, body, canApplyPatch }: Embodied) => { +export const CommentBody = ({ comment, bodyHTML, body, canApplyPatch, allowEmpty, specialDisplayBodyPostfix }: Embodied) => { if (!body && !bodyHTML) { + if (allowEmpty) { + return null; + } return ( <div className="comment-body"> <em>No description provided.</em> @@ -229,159 +350,261 @@ export const CommentBody = ({ comment, bodyHTML, body, canApplyPatch }: Embodied } const { applyPatch } = useContext(PullRequestContext); - const renderedBody = <div dangerouslySetInnerHTML={{ __html: bodyHTML }} />; + const renderedBody = <div dangerouslySetInnerHTML={{ __html: bodyHTML ?? '' }} />; - const containsSuggestion = (body || bodyHTML).indexOf('```diff') > -1; + const containsSuggestion = ((body || bodyHTML)?.indexOf('```diff') ?? -1) > -1; const applyPatchButton = - containsSuggestion && canApplyPatch ? <button onClick={() => applyPatch(comment)}>Apply Patch</button> : <></>; + containsSuggestion && canApplyPatch && comment ? <button onClick={() => applyPatch(comment)}>Apply Patch</button> : <></>; return ( <div className="comment-body"> {renderedBody} {applyPatchButton} + {specialDisplayBodyPostfix ? <br /> : null} + {specialDisplayBodyPostfix ? <em>{specialDisplayBodyPostfix}</em> : null} + <CommentReactions reactions={comment?.reactions} /> + </div> + ); +}; + +type CommentReactionsProps = { + reactions?: { label: string; count: number; reactors: readonly string[] }[]; +}; + +const CommentReactions = ({ reactions }: CommentReactionsProps) => { + if (!Array.isArray(reactions) || reactions.length === 0) return null; + const filtered = reactions.filter(r => r.count > 0); + if (filtered.length === 0) return null; + return ( + <div className="comment-reactions" style={{ marginTop: 6 }}> + {filtered.map((reaction, idx) => { + const maxReactors = 10; + const reactors = reaction.reactors || []; + const displayReactors = reactors.slice(0, maxReactors); + const moreCount = reactors.length > maxReactors ? reactors.length - maxReactors : 0; + let title: string = ''; + if (displayReactors.length > 0) { + if (moreCount > 0) { + title = `${joinWithAnd(displayReactors)} and ${moreCount} more reacted with ${reaction.label}`; + } else { + title = `${joinWithAnd(displayReactors)} reacted with ${reaction.label}`; + } + } + return ( + <div + key={reaction.label + idx} + title={title} + > + <span className="reaction-label">{reaction.label}</span>{nbsp}{reaction.count > 1 ? <span className="reaction-count">{reaction.count}</span> : null} + </div> + ); + })} </div> ); }; export function AddComment({ pendingCommentText, + isCopilotOnMyBehalf, state, hasWritePermission, isIssue, isAuthor, continueOnGitHub, currentUserReviewState, + lastReviewType, + busy, + hasReviewDraft, }: PullRequest) { - const { updatePR, comment, requestChanges, approve, close, openOnGitHub } = useContext(PullRequestContext); + const { updatePR, requestChanges, approve, close, openOnGitHub, submit } = useContext(PullRequestContext); const [isBusy, setBusy] = useState(false); const form = useRef<HTMLFormElement>(); const textareaRef = useRef<HTMLTextAreaElement>(); emitter.addListener('quoteReply', (message: string) => { - const quoted = message.replace(/\n\n/g, '\n\n> '); + const quoted = message.replace(/\n/g, '\n> '); updatePR({ pendingCommentText: `> ${quoted} \n\n` }); - textareaRef.current.scrollIntoView(); - textareaRef.current.focus(); + textareaRef.current?.scrollIntoView(); + textareaRef.current?.focus(); }); - const submit = useCallback( - async (command: (body: string) => Promise<any> = comment) => { - try { - setBusy(true); - const { body }: FormInputSet = form.current; - if (continueOnGitHub && command !== comment) { - await openOnGitHub(); - } else { - await command(body.value); - updatePR({ pendingCommentText: '' }); - } - } finally { - setBusy(false); - } - }, - [comment, updatePR, setBusy], - ); + const closeButton: React.MouseEventHandler<HTMLButtonElement> = e => { + e.preventDefault(); + const { value } = textareaRef.current!; + close(value); + }; - const onSubmit = useCallback( - e => { - e.preventDefault(); - submit(); - }, - [submit], - ); + let currentSelection: ReviewType = lastReviewType ?? (currentUserReviewState === 'APPROVED' ? ReviewType.Approve : (currentUserReviewState === 'CHANGES_REQUESTED' ? ReviewType.RequestChanges : ReviewType.Comment)); + + async function submitAction(action: ReviewType): Promise<void> { + const { value } = textareaRef.current!; + if (continueOnGitHub && action !== ReviewType.Comment) { + await openOnGitHub(); + return; + } + setBusy(true); + switch (action) { + case ReviewType.RequestChanges: + await requestChanges(value); + break; + case ReviewType.Approve: + await approve(value); + break; + default: + await submit(value); + } + setBusy(false); + } const onKeyDown = useCallback( e => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { - submit(); + submitAction(currentSelection); } }, [submit], ); - const onClick = useCallback( - e => { - e.preventDefault(); - const { command } = e.target.dataset; - submit({ approve, requestChanges, close }[command]); - }, - [submit, approve, requestChanges, close], - ); + async function defaultSubmitAction(): Promise<void> { + await submitAction(currentSelection); + } + + const availableActions: { [key in ReviewType]?: string } = isAuthor + ? { [ReviewType.Comment]: 'Comment' } + : continueOnGitHub + ? { + [ReviewType.Comment]: 'Comment', + [ReviewType.Approve]: 'Approve on github.com', + [ReviewType.RequestChanges]: 'Request changes on github.com', + } + : commentMethods(isIssue); + + // Disable buttons when summary comment is empty AND there are no review comments + // Note: Approve button is allowed even with empty content and no pending review + const shouldDisableNonApproveButtons = !pendingCommentText?.trim() && !hasReviewDraft; + const shouldDisableApproveButton = false; // Approve is always allowed (when not busy) return ( - <form id="comment-form" ref={form} className="comment-form main-comment-form" onSubmit={onSubmit}> + <form id="comment-form" ref={form as React.MutableRefObject<HTMLFormElement>} className="comment-form main-comment-form" > <textarea - id="comment-textarea" + id={COMMENT_TEXTAREA_ID} name="body" - ref={textareaRef} - onInput={({ target }) => updatePR({ pendingCommentText: (target as any).value })} + ref={textareaRef as React.MutableRefObject<HTMLTextAreaElement>} + onInput={({ target }) => updatePR({ pendingCommentText: (target as HTMLTextAreaElement).value })} onKeyDown={onKeyDown} value={pendingCommentText} placeholder="Leave a comment" + onClick={() => { + if (!pendingCommentText && isCopilotOnMyBehalf && !textareaRef.current?.textContent) { + textareaRef.current!.textContent = '@copilot '; + textareaRef.current!.setSelectionRange(9, 9); + } + }} /> <div className="form-actions"> - {(hasWritePermission || isAuthor) && !isIssue ? ( + {(hasWritePermission || isAuthor) ? ( <button id="close" className="secondary" disabled={isBusy || state !== GithubItemStateEnum.Open} - onClick={onClick} + onClick={closeButton} data-command="close" > - Close Pull Request - </button> - ) : null} - {!isIssue && !isAuthor ? ( - <button - id="request-changes" - disabled={isBusy || !pendingCommentText} - className="secondary" - onClick={onClick} - data-command="requestChanges" - > - {continueOnGitHub ? 'Request changes on github.com' : 'Request Changes'} - </button> - ) : null} - {!isIssue && !isAuthor ? ( - <button - id="approve" - className="secondary" - disabled={isBusy || currentUserReviewState === 'APPROVED'} - onClick={onClick} - data-command="approve" - > - {continueOnGitHub ? 'Approve on github.com' : 'Approve'} + {isIssue ? 'Close Issue' : 'Close Pull Request'} </button> ) : null} - <button - id="reply" - type="submit" - disabled={isBusy || !pendingCommentText} - >Comment</button> + + + <ContextDropdown + optionsContext={() => makeCommentMenuContext(availableActions, pendingCommentText, shouldDisableNonApproveButtons)} + defaultAction={defaultSubmitAction} + defaultOptionLabel={() => availableActions[currentSelection]!} + defaultOptionValue={() => currentSelection} + allOptions={() => { + const actions: { label: string; value: string; optionDisabled: boolean; action: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void }[] = []; + if (availableActions.comment) { + actions.push({ label: availableActions[ReviewType.Comment]!, value: ReviewType.Comment, action: () => submitAction(ReviewType.Comment), optionDisabled: shouldDisableNonApproveButtons }); + } + if (availableActions.approve) { + actions.push({ label: availableActions[ReviewType.Approve]!, value: ReviewType.Approve, action: () => submitAction(ReviewType.Approve), optionDisabled: shouldDisableApproveButton }); + } + if (availableActions.requestChanges) { + actions.push({ label: availableActions[ReviewType.RequestChanges]!, value: ReviewType.RequestChanges, action: () => submitAction(ReviewType.RequestChanges), optionDisabled: shouldDisableNonApproveButtons }); + } + return actions; + }} + optionsTitle='Submit pull request review' + disabled={isBusy || busy} + hasSingleAction={Object.keys(availableActions).length === 1} + spreadable={true} + primaryOptionValue={ReviewType.Comment} + /> </div> </form> ); } +function commentMethods(isIssue: boolean) { + return isIssue ? ISSUE_COMMENT_METHODS : COMMENT_METHODS; +} + +const ISSUE_COMMENT_METHODS = { + comment: 'Comment', +}; + const COMMENT_METHODS = { - comment: 'Comment and Submit', - approve: 'Approve and Submit', - requestChanges: 'Request Changes and Submit', + ...ISSUE_COMMENT_METHODS, + approve: 'Approve', + requestChanges: 'Request Changes', +}; + +const makeCommentMenuContext = (availableActions: { comment?: string, approve?: string, requestChanges?: string }, pendingCommentText: string | undefined, shouldDisableNonApproveButtons: boolean) => { + const createMenuContexts: Record<string, boolean | string> = { + 'preventDefaultContextMenuItems': true, + 'github:reviewCommentMenu': true, + }; + if (availableActions.approve) { + if (availableActions.approve === COMMENT_METHODS.approve) { + createMenuContexts['github:reviewCommentApprove'] = true; + } else { + createMenuContexts['github:reviewCommentApproveOnDotCom'] = true; + } + } + if (availableActions.comment) { + createMenuContexts['github:reviewCommentComment'] = true; + if (!shouldDisableNonApproveButtons) { + createMenuContexts['github:reviewCommentCommentEnabled'] = true; + } + } + if (availableActions.requestChanges) { + if (availableActions.requestChanges === COMMENT_METHODS.requestChanges) { + createMenuContexts['github:reviewCommentRequestChanges'] = true; + if (!shouldDisableNonApproveButtons) { + createMenuContexts['github:reviewRequestChangesEnabled'] = true; + } + } else { + createMenuContexts['github:reviewCommentRequestChangesOnDotCom'] = true; + } + } + createMenuContexts['body'] = pendingCommentText ?? ''; + const stringified = JSON.stringify(createMenuContexts); + return stringified; }; export const AddCommentSimple = (pr: PullRequest) => { const { updatePR, requestChanges, approve, submit, openOnGitHub } = useContext(PullRequestContext); + const [isBusy, setBusy] = useState(false); const textareaRef = useRef<HTMLTextAreaElement>(); - let currentSelection: string = 'comment'; + let currentSelection: ReviewType = pr.lastReviewType ?? (pr.currentUserReviewState === 'APPROVED' ? ReviewType.Approve : (pr.currentUserReviewState === 'CHANGES_REQUESTED' ? ReviewType.RequestChanges : ReviewType.Comment)); - async function submitAction(selected: string): Promise<void> { + async function submitAction(action: ReviewType): Promise<void> { const { value } = textareaRef.current!; - if (pr.continueOnGitHub && selected !== ReviewType.Comment) { + if (pr.continueOnGitHub && action !== ReviewType.Comment) { await openOnGitHub(); return; } - - switch (selected) { + setBusy(true); + switch (action) { case ReviewType.RequestChanges: await requestChanges(value); break; @@ -391,50 +614,88 @@ export const AddCommentSimple = (pr: PullRequest) => { default: await submit(value); } - updatePR({ pendingCommentText: '', pendingReviewType: undefined }); + setBusy(false); + } + + async function defaultSubmitAction(): Promise<void> { + await submitAction(currentSelection); } const onChangeTextarea = (e: React.ChangeEvent<HTMLTextAreaElement>): void => { updatePR({ pendingCommentText: e.target.value }); }; - async function onDropDownChange(value: string) { - currentSelection = value; - }; - const onKeyDown = useCallback( e => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); - submitAction(currentSelection); + defaultSubmitAction(); } }, [submitAction], ); - const availableActions = pr.isAuthor - ? { comment: 'Comment and Submit' } + const availableActions: { comment?: string, approve?: string, requestChanges?: string } = pr.isAuthor + ? { comment: 'Comment' } : pr.continueOnGitHub ? { - comment: 'Comment and Submit', + comment: 'Comment', approve: 'Approve on github.com', requestChanges: 'Request changes on github.com', } - : COMMENT_METHODS; + : commentMethods(pr.isIssue); + + // Disable buttons when summary comment is empty AND there are no review comments + // Note: Approve button is allowed even with empty content and no pending review + const shouldDisableNonApproveButtons = !pr.pendingCommentText?.trim() && !pr.hasReviewDraft; + const shouldDisableApproveButton = false; // Approve is always allowed (when not busy) return ( <span className="comment-form"> <textarea - id="comment-textarea" + id={COMMENT_TEXTAREA_ID} name="body" placeholder="Leave a comment" - ref={textareaRef} - value={pr.pendingCommentText} + ref={textareaRef as React.MutableRefObject<HTMLTextAreaElement>} + value={pr.pendingCommentText ?? ''} onChange={onChangeTextarea} onKeyDown={onKeyDown} + disabled={isBusy || pr.busy} /> - <Dropdown options={availableActions} changeAction={onDropDownChange} defaultOption="comment" submitAction={submitAction} disabled={!!pr.isAuthor && !pr.hasReviewDraft} /> + <div className='comment-button'> + <ContextDropdown + optionsContext={() => makeCommentMenuContext(availableActions, pr.pendingCommentText, shouldDisableNonApproveButtons)} + defaultAction={defaultSubmitAction} + defaultOptionLabel={() => availableActions[currentSelection]!} + defaultOptionValue={() => currentSelection} + allOptions={() => { + const actions: { label: string; value: string; optionDisabled: boolean; action: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void }[] = []; + if (availableActions.comment) { + actions.push({ label: availableActions[ReviewType.Comment]!, value: ReviewType.Comment, action: () => submitAction(ReviewType.Comment), optionDisabled: shouldDisableNonApproveButtons }); + } + if (availableActions.approve) { + actions.push({ label: availableActions[ReviewType.Approve]!, value: ReviewType.Approve, action: () => submitAction(ReviewType.Approve), optionDisabled: shouldDisableApproveButton }); + } + if (availableActions.requestChanges) { + actions.push({ label: availableActions[ReviewType.RequestChanges]!, value: ReviewType.RequestChanges, action: () => submitAction(ReviewType.RequestChanges), optionDisabled: shouldDisableNonApproveButtons }); + } + return actions; + }} + optionsTitle='Submit pull request review' + disabled={isBusy || pr.busy} + hasSingleAction={Object.keys(availableActions).length === 1} + spreadable={true} + primaryOptionValue={ReviewType.Comment} + /> + </div> </span> ); }; + +function joinWithAnd(arr: string[]): string { + if (arr.length === 0) return ''; + if (arr.length === 1) return arr[0]; + if (arr.length === 2) return `${arr[0]} and ${arr[1]}`; + return `${arr.slice(0, -1).join(', ')} and ${arr[arr.length - 1]}`; +} diff --git a/webviews/components/contextDropdown.tsx b/webviews/components/contextDropdown.tsx new file mode 100644 index 0000000000..907777903d --- /dev/null +++ b/webviews/components/contextDropdown.tsx @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { chevronDownIcon } from './icon'; + +interface ContextDropdownProps { + optionsContext: () => string; + defaultOptionLabel: () => string | React.ReactNode; + defaultOptionValue: () => string; + defaultAction: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void; + allOptions?: () => { label: string; value: string; action: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void; optionDisabled?: boolean }[]; + optionsTitle: string; + disabled?: boolean; + hasSingleAction?: boolean; + spreadable: boolean; + isSecondary?: boolean; + primaryOptionValue?: string; +} + +function useWindowSize() { + const [size, setSize] = useState([0, 0]); + useLayoutEffect(() => { + function updateSize() { + setSize([window.innerWidth, window.innerHeight]); + } + window.addEventListener('resize', updateSize); + updateSize(); + return () => window.removeEventListener('resize', updateSize); + }, []); + return size; +} + +export const ContextDropdown = ({ optionsContext, defaultOptionLabel, defaultOptionValue, defaultAction, allOptions: options, optionsTitle, disabled, hasSingleAction, spreadable, isSecondary, primaryOptionValue }: ContextDropdownProps) => { + const [expanded, setExpanded] = useState(false); + const onHideAction = (e: MouseEvent | KeyboardEvent) => { + if (e.target instanceof HTMLElement && e.target.classList.contains('split-right')) { + return; + } + setExpanded(false); + }; + useEffect(() => { + const onClickOrKey = (e) => onHideAction(e); + if (expanded) { + document.addEventListener('click', onClickOrKey); + document.addEventListener('keydown', onClickOrKey); + } else { + document.removeEventListener('click', onClickOrKey); + document.removeEventListener('keydown', onClickOrKey); + } + }, [expanded, setExpanded]); + + const divRef = useRef<HTMLDivElement>(); + useWindowSize(); + + return <div className={`dropdown-container${spreadable ? ' spreadable' : ''}`} ref={divRef}> + {divRef.current && spreadable && (divRef.current.clientWidth > 375) && options && !hasSingleAction ? options().map(({ label, value, action, optionDisabled }) => { + // Only the primary option should use the primary (blue) button style when expanded + const isPrimary = primaryOptionValue && value === primaryOptionValue; + return <button className={`inlined-dropdown${isPrimary ? '' : ' secondary'}`} key={value} title={label} disabled={optionDisabled || disabled} onClick={action} value={value}>{label}</button>; + }) + : + <div className='primary-split-button'> + <button className={`split-left${isSecondary ? ' secondary' : ''}`} disabled={disabled} onClick={defaultAction} value={defaultOptionValue()} + title={typeof defaultOptionLabel() === 'string' ? defaultOptionLabel() as string : optionsTitle}> + {defaultOptionLabel()} + </button> + {hasSingleAction ? null : + <div className={`split${isSecondary ? ' secondary' : ''}${disabled ? ' disabled' : ''}`}><div className={`separator${disabled ? ' disabled' : ''}`}></div></div> + } + {hasSingleAction ? null : + <button className={`split-right${isSecondary ? ' secondary' : ''}`} title={optionsTitle} disabled={disabled} aria-expanded={expanded} onClick={(e) => { + e.preventDefault(); + const rect = (e.target as HTMLElement).getBoundingClientRect(); + const x = rect.left; + const y = rect.bottom; + e.target.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: x, clientY: y })); + e.stopPropagation(); + }} + onMouseDown={() => setExpanded(true)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + setExpanded(true); + } + }} + data-vscode-context={optionsContext()}> + {chevronDownIcon} + </button> + } + </div> + } + </div>; +}; \ No newline at end of file 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 }) => <div key={keyForDiffLine(line)} className={`diffLine ${getDiffChangeClass(line.type)}`}> <LineNumber num={line.oldLineNumber} /> <LineNumber num={line.newLineNumber} /> - <div className="diffTypeSign">{(line as any)._raw.substr(0, 1)}</div> - <div className="lineContent">{(line as any)._raw.substr(1)}</div> + <div className="diffTypeSign">{line.raw.substr(0, 1)}</div> + <div className="lineContent">{line.raw.substr(1)}</div> </div> ))} </> diff --git a/webviews/components/dropdown.tsx b/webviews/components/dropdown.tsx index 3d10df2234..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, @@ -96,8 +96,9 @@ export const Dropdown = ({ options, defaultOption, disabled, submitAction, chang submitAction={submitAction} disabled={!!disabled} /> + <div className={`split${disabled ? ' disabled' : ''}`}><div className={`separator${disabled ? ' disabled' : ''}`}></div></div> <button id={EXPAND_OPTIONS_BUTTON} className={'select-right ' + expandButtonClass} aria-label='Expand button options' onClick={onClick}> - {chevronIcon} + {chevronDownIcon} </button> </div> <div className={areOptionsVisible ? 'options-select' : 'hidden'}> diff --git a/webviews/components/header.tsx b/webviews/components/header.tsx index 59cea18ef3..c0760fe1bb 100644 --- a/webviews/components/header.tsx +++ b/webviews/components/header.tsx @@ -4,13 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import React, { useContext, useState } from 'react'; -import { GithubItemStateEnum } from '../../src/github/interface'; -import { PullRequest } from '../common/cache'; +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 { EDIT_TITLE_BUTTON_ID } from '../common/constants'; import PullRequestContext from '../common/context'; import { useStateProp } from '../common/hooks'; -import { checkIcon, mergeIcon, prClosedIcon, prDraftIcon, prOpenIcon } from './icon'; -import { nbsp } from './space'; -import { AuthorLink, Avatar } from './user'; export function Header({ canEdit, @@ -25,10 +28,16 @@ export function Header({ isCurrentlyCheckedOut, isDraft, isIssue, - repositoryDefaultBranch, + doneCheckoutBranch, + events, + owner, + repo, + busy, + stateReason }: PullRequest) { const [currentTitle, setCurrentTitle] = useStateProp(title); const [inEditMode, setEditMode] = useState(false); + const codingAgentEvent = mostRecentCopilotEvent(events); return ( <> @@ -40,21 +49,42 @@ export function Header({ inEditMode={inEditMode} setEditMode={setEditMode} setCurrentTitle={setCurrentTitle} - /> - <Subtitle state={state} head={head} base={base} author={author} isIssue={isIssue} isDraft={isDraft} /> - <ButtonGroup - isCurrentlyCheckedOut={isCurrentlyCheckedOut} - isIssue={isIssue} canEdit={canEdit} - repositoryDefaultBranch={repositoryDefaultBranch} - setEditMode={setEditMode} + owner={owner} + repo={repo} /> + <Subtitle state={state} stateReason={stateReason} head={head} base={base} author={author} isIssue={isIssue} isDraft={isDraft} codingAgentEvent={codingAgentEvent} canEdit={canEdit} /> + <div className="header-actions"> + <ButtonGroup + isCurrentlyCheckedOut={isCurrentlyCheckedOut} + isIssue={isIssue} + doneCheckoutBranch={doneCheckoutBranch} + owner={owner} + repo={repo} + number={number} + busy={busy} + /> + <CancelCodingAgentButton canEdit={canEdit} codingAgentEvent={codingAgentEvent} /> + </div> </> ); } -function Title({ title, titleHTML, number, url, inEditMode, setEditMode, setCurrentTitle }) { - const { setTitle } = useContext(PullRequestContext); +interface TitleProps { + title: string; + titleHTML: string; + number: number; + url: string; + inEditMode: boolean; + setEditMode: React.Dispatch<React.SetStateAction<boolean>>; + setCurrentTitle: React.Dispatch<React.SetStateAction<string>>; + canEdit: boolean; + owner: string; + repo: string; +} + +function Title({ title, titleHTML, number, url, inEditMode, setEditMode, setCurrentTitle, canEdit, owner, repo }: TitleProps): JSX.Element { + const { setTitle, copyPrLink } = useContext(PullRequestContext); const titleForm = ( <form @@ -62,7 +92,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 { @@ -80,15 +112,31 @@ function Title({ title, titleHTML, number, url, inEditMode, setEditMode, setCurr </form> ); + const context: OverviewContext = { + 'preventDefaultContextMenuItems': true, + owner, + repo, + number + }; + context['github:copyMenu'] = true; + const displayTitle = ( <div className="overview-title"> <h2> <span dangerouslySetInnerHTML={{ __html: titleHTML }} /> {' '} - <a href={url} title={url}> + <a href={url} title={url} data-vscode-context={JSON.stringify(context)}> #{number} </a> </h2> + {canEdit ? + <button id={EDIT_TITLE_BUTTON_ID} title="Rename" onClick={() => setEditMode(true)} className="icon-button"> + {editIcon} + </button> + : null} + <button title="Copy Link" onClick={copyPrLink} className="icon-button" aria-label="Copy Pull Request Link"> + {copyIcon} + </button> </div> ); @@ -96,56 +144,169 @@ function Title({ title, titleHTML, number, url, inEditMode, setEditMode, setCurr return editableTitle; } -function ButtonGroup({ isCurrentlyCheckedOut, canEdit, isIssue, repositoryDefaultBranch, setEditMode }) { - const { refresh, copyPrLink, copyVscodeDevLink } = useContext(PullRequestContext); +interface ButtonGroupProps { + isCurrentlyCheckedOut: boolean; + isIssue: boolean; + doneCheckoutBranch: string; + owner: string; + repo: string; + number: number; + busy?: boolean; +} + +function ButtonGroup({ isCurrentlyCheckedOut, isIssue, doneCheckoutBranch, owner, repo, number, busy }: ButtonGroupProps): JSX.Element { + const { refresh } = useContext(PullRequestContext); return ( <div className="button-group"> - <CheckoutButtons {...{ isCurrentlyCheckedOut, isIssue, repositoryDefaultBranch }} /> - <button title="Refresh with the latest data from GitHub" onClick={refresh} className="secondary small-button"> + <CheckoutButton {...{ isCurrentlyCheckedOut, isIssue, doneCheckoutBranch, owner, repo, number }} /> + <button title="Refresh with the latest data from GitHub" onClick={refresh} className="secondary"> Refresh </button> - {canEdit && ( - <> - <button title="Rename" onClick={setEditMode} className="secondary small-button"> - Rename - </button> - <button title="Copy GitHub pull request link" onClick={copyPrLink} className="secondary small-button"> - Copy Link - </button> - <button title="Copy vscode.dev link for viewing this pull request in VS Code for the Web" onClick={copyVscodeDevLink} className="secondary small-button"> - Copy vscode.dev Link - </button> - </> - )} + {busy ? ( + <div className='spinner'> + {loadingIcon} + </div> + ) : null} </div> ); } -function Subtitle({ state, isDraft, isIssue, author, base, head }) { - const { text, color, icon } = getStatus(state, isDraft); +function CancelCodingAgentButton({ canEdit, codingAgentEvent }: { canEdit: boolean; codingAgentEvent: TimelineEvent | undefined }): JSX.Element | null { + const { cancelCodingAgent, updatePR, openSessionLog } = useContext(PullRequestContext); + const [isBusy, setBusy] = useState(false); + + const cancel = async () => { + if (!codingAgentEvent) { + return; + } + setBusy(true); + const result = await cancelCodingAgent(codingAgentEvent); + if (result.events.length > 0) { + updatePR(result); + } + setBusy(false); + }; + + // Extract sessionLink from the coding agent event + const sessionLink = (codingAgentEvent as CopilotStartedEvent)?.sessionLink; + + if (!codingAgentEvent || copilotEventToStatus(codingAgentEvent) !== CopilotPRStatus.Started) { + return null; + } + + const context: CodingAgentContext = { + 'preventDefaultContextMenuItems': true, + ...sessionLink + }; + + context['github:codingAgentMenu'] = true; + const actions: { label: string; value: string; action: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void }[] = []; + + if (sessionLink) { + actions.push({ + label: 'View Session', + value: '', + action: () => openSessionLog(sessionLink) + }); + } + + if (canEdit) { + actions.unshift({ + label: 'Cancel Coding Agent', + value: '', + action: cancel + }); + } + + return <ContextDropdown + optionsContext={() => JSON.stringify(context)} + defaultAction={actions[0].action} + defaultOptionLabel={() => isBusy ? ( + <> + <span className='loading-button'>{loadingIcon}</span> + {actions[0].label} + </> + ) : actions[0].label} + defaultOptionValue={() => actions[0].value} + allOptions={() => { + return actions; + }} + optionsTitle={actions[0].label} + disabled={isBusy} + hasSingleAction={false} + spreadable={false} + isSecondary={true} + />; +} + +interface SubtitleProps { + state: GithubItemStateEnum; + stateReason?: StateReason; + isDraft?: boolean; + isIssue: boolean; + author: PullRequest['author']; + base: string; + head: string; + codingAgentEvent: TimelineEvent | undefined; + canEdit: boolean; +} + +function Subtitle({ state, stateReason, isDraft, isIssue, author, base, head, codingAgentEvent, canEdit }: SubtitleProps): JSX.Element { + const { changeBaseBranch } = useContext(PullRequestContext); + const { text, color, icon } = getStatus(state, !!isDraft, isIssue, stateReason); + const copilotStatus = copilotEventToStatus(codingAgentEvent); + let copilotStatusIcon: JSX.Element | undefined; + if (copilotStatus === CopilotPRStatus.Started) { + copilotStatusIcon = copilotInProgressIcon; + } else if (copilotStatus === CopilotPRStatus.Completed) { + copilotStatusIcon = copilotSuccessIcon; + } else if (copilotStatus === CopilotPRStatus.Failed) { + copilotStatusIcon = copilotErrorIcon; + } return ( <div className="subtitle"> <div id="status" className={`status-badge-${color}`}> - <span className='icon'>{isIssue ? null : icon}</span> + <span className='icon'>{icon}</span> <span>{text}</span> </div> <div className="author"> - {!isIssue ? <Avatar for={author} /> : null} - {!isIssue ? ( - <div className="merge-branches"> - <AuthorLink for={author} /> {getActionText(state)} into{' '} - <code className="branch-tag">{base}</code> from <code className="branch-tag">{head}</code> - </div> - ) : null} + {<Avatar for={author} substituteIcon={copilotStatusIcon} />} + <div className="merge-branches"> + <AuthorLink for={author} /> {!isIssue ? (<> + {getActionText(state)} into{' '} + {canEdit && state === GithubItemStateEnum.Open ? ( + <button + title="Change base branch" + onClick={changeBaseBranch} + className="secondary change-base" + aria-label="Change base branch" + > + <code className="branch-tag">{base} {editIcon}</code> + </button> + ) : ( + <code className="branch-tag">{base}</code> + )} + {' '}from <code className="branch-tag">{head}</code> + </>) : null} + </div> </div> </div> ); } -const CheckoutButtons = ({ isCurrentlyCheckedOut, isIssue, repositoryDefaultBranch }) => { - const { exitReviewMode, checkout } = useContext(PullRequestContext); +interface CheckoutButtonProps { + isCurrentlyCheckedOut: boolean; + isIssue: boolean; + doneCheckoutBranch: string; + owner: string; + repo: string; + number: number; +} + +const CheckoutButton: React.FC<CheckoutButtonProps> = ({ isCurrentlyCheckedOut, isIssue, doneCheckoutBranch, owner, repo, number }) => { + const { exitReviewMode, checkout, openChanges } = useContext(PullRequestContext); const [isBusy, setBusy] = useState(false); const onClick = async (command: string) => { @@ -159,6 +320,9 @@ const CheckoutButtons = ({ isCurrentlyCheckedOut, isIssue, repositoryDefaultBran case 'exitReviewMode': await exitReviewMode(); break; + case 'openChanges': + await openChanges(); + break; default: throw new Error(`Can't find action ${command}`); } @@ -167,47 +331,69 @@ const CheckoutButtons = ({ isCurrentlyCheckedOut, isIssue, repositoryDefaultBran } }; + if (isIssue) { + return null; + } + + const context: OverviewContext = { + 'preventDefaultContextMenuItems': true, + owner, + repo, + number + }; + + context['github:checkoutMenu'] = true; + const actions: { label: string; value: string; action: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void }[] = []; + if (isCurrentlyCheckedOut) { - return ( - <> - <button aria-live="polite" className="checkedOut small-button" disabled> - {checkIcon}{nbsp} Checked Out - </button> - <button - aria-live="polite" - title="Switch to a different branch than this pull request branch" - disabled={isBusy} - className='small-button' - onClick={() => onClick('exitReviewMode')} - > - Checkout '{repositoryDefaultBranch}' - </button> - </> - ); - } else if (!isIssue) { - return ( - <button - aria-live="polite" - title="Checkout a local copy of this pull request branch to verify or edit changes" - disabled={isBusy} - className='small-button' - onClick={() => onClick('checkout')} - > - Checkout - </button> - ); + actions.push({ + label: `Checkout '${doneCheckoutBranch}'`, + value: '', + action: () => onClick('exitReviewMode') + }); } else { - return null; + actions.push({ + label: 'Checkout', + value: '', + action: () => onClick('checkout') + }); } + + actions.push({ + label: 'Open Changes', + value: '', + action: () => onClick('openChanges') + }); + + return <ContextDropdown + optionsContext={() => JSON.stringify(context)} + defaultAction={actions[0].action} + defaultOptionLabel={() => actions[0].label} + defaultOptionValue={() => actions[0].value} + allOptions={() => { + return actions; + }} + optionsTitle={actions[0].label} + disabled={isBusy} + hasSingleAction={false} + spreadable={false} + />; }; -export function getStatus(state: GithubItemStateEnum, isDraft: boolean) { +export function getStatus(state: GithubItemStateEnum, isDraft: boolean, isIssue: boolean, stateReason?: StateReason) { + 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: prOpenIcon }; + return isDraft ? { text: 'Draft', color: 'draft', icon: gitPullRequestDraftIcon } : { text: 'Open', color: 'open', icon: open }; } else { - return { text: 'Closed', color: 'closed', icon: prClosedIcon }; + let closedColor: string = 'closed'; + if (isIssue) { + closedColor = stateReason !== 'COMPLETED' ? 'draft' : 'merged'; + } + return { text: 'Closed', color: closedColor, icon: closed }; } } diff --git a/webviews/components/icon.tsx b/webviews/components/icon.tsx index cff7720ff2..b9cdb6cda1 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,23 +11,47 @@ export const Icon = ({ className = '', src, title }: { className?: string; title ); export default Icon; +// Codicons +export const accountIcon = <Icon src={require('../../resources/icons/codicons/account.svg')} />; +export const addIcon = <Icon src={require('../../resources/icons/codicons/add.svg')} />; +export const checkIcon = <Icon src={require('../../resources/icons/codicons/check.svg')} className='check' />; +export const checkAllIcon = <Icon src={require('../../resources/icons/codicons/check-all.svg')} />; +export const chevronDownIcon = <Icon src={require('../../resources/icons/codicons/chevron-down.svg')} />; +export const circleFilledIcon = <Icon src={require('../../resources/icons/codicons/circle-filled.svg')} className='pending' />; +export const closeIcon = <Icon src={require('../../resources/icons/codicons/close.svg')} className='close' />; +export const commentIcon = <Icon src={require('../../resources/icons/codicons/comment.svg')} />; +export const copilotIcon = <Icon src={require('../../resources/icons/codicons/copilot.svg')} />; +export const copyIcon = <Icon src={require('../../resources/icons/codicons/copy.svg')} />; +export const editIcon = <Icon src={require('../../resources/icons/codicons/edit.svg')} />; +export const errorIcon = <Icon src={require('../../resources/icons/codicons/error.svg')} />; +export const feedbackIcon = <Icon src={require('../../resources/icons/codicons/feedback.svg')} />; +export const gitCommitIcon = <Icon src={require('../../resources/icons/codicons/git-commit.svg')} />; +export const gitCompareIcon = <Icon src={require('../../resources/icons/codicons/git-compare.svg')} />; +export const gitMergeIcon = <Icon src={require('../../resources/icons/codicons/git-merge.svg')} />; +export const gitPullRequestClosedIcon = <Icon src={require('../../resources/icons/codicons/git-pull-request-closed.svg')} />; +export const gitPullRequestDraftIcon = <Icon src={require('../../resources/icons/codicons/git-pull-request-draft.svg')} />; +export const gitPullRequestIcon = <Icon src={require('../../resources/icons/codicons/git-pull-request.svg')} />; +export const issuescon = <Icon src={require('../../resources/icons/codicons/issues.svg')} />; +export const loadingIcon = <Icon className='loading' src={require('../../resources/icons/codicons/loading.svg')} />; +export const milestoneIcon = <Icon src={require('../../resources/icons/codicons/milestone.svg')} />; +export const notebookTemplate = <Icon src={require('../../resources/icons/codicons/notebook-template.svg')} />; +export const passIcon = <Icon src={require('../../resources/icons/codicons/pass.svg')} />; +export const projectIcon = <Icon src={require('../../resources/icons/codicons/github-project.svg')} />; +export const quoteIcon = <Icon src={require('../../resources/icons/codicons/quote.svg')} />; +export const requestChangesIcon = <Icon src={require('../../resources/icons/codicons/request-changes.svg')} />; +export const settingsIcon = <Icon src={require('../../resources/icons/codicons/settings-gear.svg')} />; +export const sparkleIcon = <Icon src={require('../../resources/icons/codicons/sparkle.svg')} />; +export const stopCircleIcon = <Icon src={require('../../resources/icons/codicons/stop-circle.svg')} />; +export const syncIcon = <Icon src={require('../../resources/icons/codicons/sync.svg')} />; +export const tagIcon = <Icon src={require('../../resources/icons/codicons/tag.svg')} />; +export const tasklistIcon = <Icon src={require('../../resources/icons/codicons/tasklist.svg')} />; +export const threeBars = <Icon src={require('../../resources/icons/codicons/three-bars.svg')} />; +export const trashIcon = <Icon src={require('../../resources/icons/codicons/trash.svg')} />; +export const warningIcon = <Icon src={require('../../resources/icons/codicons/warning.svg')} />; +export const prMergeIcon = <Icon src={require('../../resources/icons/codicons/merge.svg')} />; +export const skipIcon = <Icon src={require('../../resources/icons/codicons/skip.svg')} className='skip' />; -export const alertIcon = <Icon src={require('../../resources/icons/alert.svg')} />; -export const checkIcon = <Icon src={require('../../resources/icons/check.svg')} />; -export const skipIcon = <Icon src={require('../../resources/icons/skip.svg')} />; -export const chevronIcon = <Icon src={require('../../resources/icons/chevron.svg')} />; -export const commentIcon = <Icon src={require('../../resources/icons/comment.svg')} />; -export const commitIcon = <Icon src={require('../../resources/icons/commit_icon.svg')} />; -export const copyIcon = <Icon src={require('../../resources/icons/copy.svg')} />; -export const deleteIcon = <Icon src={require('../../resources/icons/delete.svg')} />; -export const mergeIcon = <Icon src={require('../../resources/icons/merge_icon.svg')} />; -export const prClosedIcon = <Icon src={require('../../resources/icons/pr_closed.svg')} />; -export const prOpenIcon = <Icon src={require('../../resources/icons/pr.svg')} />; -export const prDraftIcon = <Icon src={require('../../resources/icons/pr_draft.svg')} />; -export const editIcon = <Icon src={require('../../resources/icons/edit.svg')} />; -export const plusIcon = <Icon src={require('../../resources/icons/plus.svg')} />; -export const pendingIcon = <Icon src={require('../../resources/icons/dot.svg')} />; -export const requestChanges = <Icon src={require('../../resources/icons/request_changes.svg')} />; -export const settingsIcon = <Icon src={require('../../resources/icons/settings.svg')} />; -export const closeIcon = <Icon src={require('../../resources/icons/close.svg')} />; -export const syncIcon = <Icon src={require('../../resources/icons/sync.svg')} />; +// Other icons +export const copilotErrorIcon = <Icon className='copilot-icon' src={require('../../resources/icons/copilot-error.svg')} />; +export const copilotInProgressIcon = <Icon className='copilot-icon' src={require('../../resources/icons/copilot-in-progress.svg')} />; +export const copilotSuccessIcon = <Icon className='copilot-icon' src={require('../../resources/icons/copilot-success.svg')} />; \ No newline at end of file diff --git a/webviews/components/merge.tsx b/webviews/components/merge.tsx index 5f44df42d5..171369b366 100644 --- a/webviews/components/merge.tsx +++ b/webviews/components/merge.tsx @@ -5,7 +5,6 @@ import React, { ChangeEventHandler, - Context, useCallback, useContext, useEffect, @@ -13,21 +12,32 @@ import React, { useRef, useState, } from 'react'; -import { groupBy } from '../../src/common/utils'; -import { GithubItemStateEnum, MergeMethod, PullRequestMergeability } from '../../src/github/interface'; -import { PullRequest } from '../common/cache'; -import PullRequestContext, { PRContext } from '../common/context'; -import { Reviewer } from '../components/reviewer'; -import { AutoMerge } from './automergeSelect'; +import { AutoMerge, QueuedToMerge } from './automergeSelect'; +import { ContextDropdown } from './contextDropdown'; import { Dropdown } from './dropdown'; -import { alertIcon, checkIcon, closeIcon, mergeIcon, pendingIcon, skipIcon } from './icon'; +import { checkIcon, circleFilledIcon, closeIcon, gitMergeIcon, 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 { + CheckState, + GithubItemStateEnum, + MergeMethod, + PullRequestCheckStatus, + PullRequestMergeability, + PullRequestReviewRequirement, + reviewerId, + ReviewState, +} from '../../src/github/interface'; +import { PullRequest } from '../../src/github/views'; +import PullRequestContext from '../common/context'; +import { Reviewer } from '../components/reviewer'; const PRStatusMessage = ({ pr, isSimple }: { pr: PullRequest; isSimple: boolean }) => { return pr.state === GithubItemStateEnum.Merged ? ( <div className="branch-status-message"> - <div className="branch-status-icon">{isSimple ? mergeIcon : null}</div>{' '} + <div className="branch-status-icon">{isSimple ? gitMergeIcon : null}</div>{' '} {'Pull request successfully merged.'} </div> ) : pr.state === GithubItemStateEnum.Closed ? ( @@ -43,11 +53,11 @@ const StatusChecks = ({ pr }: { pr: PullRequest }) => { const { state, status } = pr; const [showDetails, toggleDetails] = useReducer( show => !show, - status.statuses.some(s => s.state === 'failure'), + status?.statuses.some(s => s.state === CheckState.Failure) ?? false, ) as [boolean, () => void]; useEffect(() => { - if (status.statuses.some(s => s.state === 'failure')) { + if (status?.statuses.some(s => s.state === CheckState.Failure) ?? false) { if (!showDetails) { toggleDetails(); } @@ -56,9 +66,9 @@ const StatusChecks = ({ pr }: { pr: PullRequest }) => { toggleDetails(); } } - }, status.statuses); + }, [status?.statuses]); - return state === GithubItemStateEnum.Open && status.statuses.length ? ( + return state === GithubItemStateEnum.Open && status?.statuses.length ? ( <> <div className="status-section"> <div className="status-item"> @@ -68,6 +78,7 @@ const StatusChecks = ({ pr }: { pr: PullRequest }) => { id="status-checks-display-button" className="secondary small-button" onClick={toggleDetails} + aria-expanded={showDetails} > {showDetails ? 'Hide' : 'Show'} </button> @@ -78,17 +89,58 @@ const StatusChecks = ({ pr }: { pr: PullRequest }) => { ) : null; }; +const RequiredReviewers = ({ pr }: { pr: PullRequest }) => { + const { state, reviewRequirement } = pr; + if (!reviewRequirement || state !== GithubItemStateEnum.Open) { + return null; + } + return ( + <> + <div className="status-section"> + <div className="status-item"> + <RequiredReviewStateIcon state={reviewRequirement.state} /> + <p className="status-item-detail-text"> + {getRequiredReviewSummary(reviewRequirement)} + </p> + </div> + </div> + </> + ); +}; + const InlineReviewers = ({ pr, isSimple }: { pr: PullRequest; isSimple: boolean }) => { - return isSimple && pr.state === GithubItemStateEnum.Open ? ( - pr.reviewers ? ( - <> + if (!isSimple || pr.state !== GithubItemStateEnum.Open || pr.reviewers.length === 0) { + return null; + } + + // match an event to each reviewer + // Use events as the outer loop as there are likely to be more events than reviewers + const reviewInfos: {event: ReviewEvent, reviewState: ReviewState}[] = []; + const remainingReviewers = new Set(pr.reviewers); + let eventIndex = pr.events.length - 1; + while (eventIndex >= 0 && remainingReviewers.size > 0) { + const event = pr.events[eventIndex]; + if (event.event === EventType.Reviewed) { + for (const reviewState of remainingReviewers) { + if (event.user.id === reviewState.reviewer.id) { + reviewInfos.push({event, reviewState}); + remainingReviewers.delete(reviewState); + break; + } + } + } + eventIndex--; + } + + return ( + <div className="section"> {' '} - {pr.reviewers.map(state => ( - <Reviewer key={state.reviewer.login} {...state} /> - ))} - </> - ) : null - ) : null; + {reviewInfos.map(reviewerInfo => { + + return <Reviewer key={reviewerId(reviewerInfo.reviewState.reviewer)} {...reviewerInfo} />; + })} + </div> + ); }; export const StatusChecksSection = ({ pr, isSimple }: { pr: PullRequest; isSimple: boolean }) => { @@ -101,6 +153,7 @@ export const StatusChecksSection = ({ pr, isSimple }: { pr: PullRequest; isSimpl { <> <PRStatusMessage pr={pr} isSimple={isSimple} /> + <RequiredReviewers pr={pr} /> <StatusChecks pr={pr} /> <InlineReviewers pr={pr} isSimple={isSimple} /> <MergeStatusAndActions pr={pr} isSimple={isSimple} /> @@ -112,9 +165,9 @@ export const StatusChecksSection = ({ pr, isSimple }: { pr: PullRequest; isSimpl }; export const MergeStatusAndActions = ({ pr, isSimple }: { pr: PullRequest; isSimple: boolean }) => { - if (isSimple && pr.state !== GithubItemStateEnum.Open) { - const { create } = useContext(PullRequestContext); + const { create, checkMergeability } = useContext(PullRequestContext); + if (isSimple && pr.state !== GithubItemStateEnum.Open) { const string = 'Create New Pull Request...'; return ( <div className="branch-status-container"> @@ -135,7 +188,6 @@ export const MergeStatusAndActions = ({ pr, isSimple }: { pr: PullRequest; isSim if ((_mergeable !== mergeable) && (_mergeable !== PullRequestMergeability.Unknown)) { setMergeability(_mergeable); } - const { checkMergeability } = useContext(PullRequestContext); useEffect(() => { const handle = setInterval(async () => { @@ -148,79 +200,185 @@ export const MergeStatusAndActions = ({ pr, isSimple }: { pr: PullRequest; isSim }, [mergeable]); return ( - <span> - <MergeStatus mergeable={mergeable} isSimple={isSimple} /> + <div> + <MergeStatus mergeable={mergeable} isSimple={isSimple} canUpdateBranch={pr.canUpdateBranch} /> + <OfferToUpdate mergeable={mergeable} isSimple={isSimple} isCurrentlyCheckedOut={pr.isCurrentlyCheckedOut} canUpdateBranch={pr.canUpdateBranch} /> <PrActions pr={{ ...pr, mergeable }} isSimple={isSimple} /> - </span> + </div> ); }; export default StatusChecksSection; -export const MergeStatus = ({ mergeable, isSimple }: { mergeable: PullRequestMergeability; isSimple: boolean }) => { +export const MergeStatus = ({ mergeable, isSimple, canUpdateBranch }: { mergeable: PullRequestMergeability; isSimple: boolean; canUpdateBranch: boolean }) => { + const { updateBranch } = useContext(PullRequestContext); + const [busy, setBusy] = useState(false); + + const onClick = () => { + setBusy(true); + updateBranch().finally(() => setBusy(false)); + }; + + 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) { + icon = checkIcon; + summary = 'This branch has no conflicts with the base branch.'; + } else if (mergeable === PullRequestMergeability.Conflict) { + icon = closeIcon; + summary = 'This branch has conflicts that must be resolved.'; + action = 'Resolve conflicts'; + } else if (mergeable === PullRequestMergeability.NotMergeable) { + icon = closeIcon; + summary = 'Branch protection policy must be fulfilled before merging.'; + } else if (mergeable === PullRequestMergeability.Behind) { + icon = closeIcon; + summary = 'This branch is out-of-date with the base branch.'; + action = 'Update with merge commit'; + } + + if (isSimple) { + icon = null; + if (mergeable !== PullRequestMergeability.Conflict) { + action = null; + } + } return ( - <div className="status-item status-section"> - {isSimple - ? null - : mergeable === PullRequestMergeability.Mergeable - ? checkIcon - : mergeable === PullRequestMergeability.NotMergeable || mergeable === PullRequestMergeability.Conflict - ? closeIcon - : pendingIcon} - <p> - {mergeable === PullRequestMergeability.Mergeable - ? 'This branch has no conflicts with the base branch.' - : mergeable === PullRequestMergeability.Conflict - ? 'This branch has conflicts that must be resolved.' - : mergeable === PullRequestMergeability.NotMergeable - ? 'Branch protection policy must be fulfilled before merging.' - : 'Checking if this branch can be merged...'} - </p> + <div className="status-section"> + <div className="status-item"> + {icon} + <p> + {summary} + </p> + {(action && canUpdateBranch) ? + <div className="button-container"> + <button className="secondary" onClick={onClick} disabled={busy} > + {action} + </button> + </div> + : null} + </div> </div> ); }; -export const ReadyForReview = ({ isSimple }: { isSimple: boolean }) => { +export const OfferToUpdate = ({ mergeable, isSimple, isCurrentlyCheckedOut, canUpdateBranch }: { mergeable: PullRequestMergeability; isSimple: boolean; isCurrentlyCheckedOut: boolean, canUpdateBranch: boolean }) => { + const { updateBranch } = useContext(PullRequestContext); const [isBusy, setBusy] = useState(false); - const { readyForReview, updatePR } = useContext(PullRequestContext); + const update = () => { + setBusy(true); + updateBranch().finally(() => setBusy(false)); + }; + const isNotCheckedoutWithConflicts = !isCurrentlyCheckedOut && mergeable === PullRequestMergeability.Conflict; + if (!canUpdateBranch || isNotCheckedoutWithConflicts || isSimple || mergeable === PullRequestMergeability.Behind || mergeable === PullRequestMergeability.Conflict || mergeable === PullRequestMergeability.Unknown) { + return null; + } + return ( + <div className="status-item status-section"> + {warningIcon} + <p>This branch is out-of-date with the base branch.</p> + <button className="secondary" onClick={update} disabled={isBusy} >Update with Merge Commit</button> + </div> + ); + +}; + +export const ReadyForReview = ({ isSimple, isCopilotOnMyBehalf, mergeMethod }: { isSimple: boolean; isCopilotOnMyBehalf?: boolean; mergeMethod: MergeMethod }) => { + const { readyForReview, readyForReviewAndMerge, updatePR, pr } = useContext(PullRequestContext); + const [isBusy, setBusy] = useState(pr?.busy ?? false); const markReadyForReview = useCallback(async () => { try { setBusy(true); - await readyForReview(); - updatePR({ isDraft: false }); + const result = await readyForReview(); + updatePR(result); } finally { setBusy(false); } - }, [setBusy, readyForReview, updatePR]); + }, [readyForReview, updatePR]); + + const markReadyAndMerge = useCallback(async () => { + try { + setBusy(true); + const result = await readyForReviewAndMerge({ mergeMethod: mergeMethod }); + updatePR(result); + } finally { + setBusy(false); + } + }, [readyForReviewAndMerge, updatePR, mergeMethod]); + + const allOptions = useCallback(() => { + const actions: { label: string; value: string; action: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void }[] = [ + { + label: 'Ready for Review', + value: 'ready', + action: markReadyForReview + } + ]; + + if (isCopilotOnMyBehalf) { + actions.push({ + label: 'Ready, Approve, and Auto-Merge', + value: 'readyAndMerge', + action: markReadyAndMerge + }); + } + + return actions; + }, [isCopilotOnMyBehalf, markReadyForReview, markReadyAndMerge]); return ( <div className="ready-for-review-container"> <div className='ready-for-review-text-wrapper'> - <div className="ready-for-review-icon">{isSimple ? null : alertIcon}</div> + <div className="ready-for-review-icon">{isSimple ? null : warningIcon}</div> <div> <div className="ready-for-review-heading">This pull request is still a work in progress.</div> <div className="ready-for-review-meta">Draft pull requests cannot be merged.</div> </div> </div> <div className='button-container'> - <button disabled={isBusy} onClick={markReadyForReview}>Ready for review</button> + <ContextDropdown + optionsContext={() => JSON.stringify({ + 'preventDefaultContextMenuItems': true, + 'github:readyForReviewMenu': true, + 'github:readyForReviewMenuWithMerge': isCopilotOnMyBehalf, + 'mergeMethod': mergeMethod + })} + defaultAction={markReadyForReview} + defaultOptionLabel={() => 'Ready for Review'} + defaultOptionValue={() => 'ready'} + allOptions={allOptions} + optionsTitle='Ready for Review' + disabled={isBusy || pr?.busy} + hasSingleAction={!isCopilotOnMyBehalf} + spreadable={false} + /> </div> </div> ); }; export const Merge = (pr: PullRequest) => { + const ctx = useContext(PullRequestContext); const select = useRef<HTMLSelectElement>(); const [selectedMethod, selectMethod] = useState<MergeMethod | null>(null); + if (pr.mergeQueueMethod) { + return <div> + <div id='merge-comment-form'> + <button onClick={() => ctx.enqueue()}>Add to Merge Queue</button> + </div> + </div>; + } + if (selectedMethod) { return <ConfirmMerge pr={pr} method={selectedMethod} cancel={() => selectMethod(null)} />; } return ( <div className="automerge-section wrapper"> - <button onClick={() => selectMethod(select.current.value as MergeMethod)}>Merge Pull Request</button> + <button onClick={() => selectMethod(select.current!.value as MergeMethod)}>Merge Pull Request</button> {nbsp}using method{nbsp} <MergeSelect ref={select} {...pr} /> </div> @@ -228,28 +386,32 @@ export const Merge = (pr: PullRequest) => { }; export const PrActions = ({ pr, isSimple }: { pr: PullRequest; isSimple: boolean }) => { - const { hasWritePermission, canEdit, isDraft, mergeable, continueOnGitHub } = pr; - if (continueOnGitHub) { - return canEdit ? <MergeOnGitHub /> : null; - } + 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 ? <ReadyForReview isSimple={isSimple} /> : null; + if (!canEdit) { + return null; + } + + return <ReadyForReview isSimple={isSimple} isCopilotOnMyBehalf={isCopilotOnMyBehalf} mergeMethod={defaultMergeMethod} />; } - if (mergeable === PullRequestMergeability.Mergeable && hasWritePermission) { + if (mergeable === PullRequestMergeability.Mergeable && hasWritePermission && !pr.mergeQueueEntry) { return isSimple ? <MergeSimple {...pr} /> : <Merge {...pr} />; - } else if (hasWritePermission) { + } else if (!isSimple && hasWritePermission && !pr.mergeQueueEntry) { const ctx = useContext(PullRequestContext); return ( <AutoMerge updateState={(params: Partial<{ autoMerge: boolean; autoMergeMethod: MergeMethod }>) => { - ctx.updateAutoMerge(params); + return ctx.updateAutoMerge(params); }} {...pr} + baseHasMergeQueue={!!pr.mergeQueueMethod} defaultMergeMethod={pr.autoMergeMethod ?? pr.defaultMergeMethod} /> ); + } else if (pr.mergeQueueEntry) { + return <QueuedToMerge mergeQueueEntry={pr.mergeQueueEntry} />; } return null; @@ -265,14 +427,21 @@ export const MergeOnGitHub = () => { }; export const MergeSimple = (pr: PullRequest) => { - const { merge, updatePR } = useContext(PullRequestContext); + const { merge, updatePR, enqueue } = useContext(PullRequestContext); + + if (pr.mergeQueueMethod) { + return <div className='button-container'> + <button onClick={() => enqueue()}>Add to Merge Queue</button> + </div>; + } + async function submitAction(selected: MergeMethod): Promise<void> { - const { state } = await merge({ + const newContext = await merge({ title: '', description: '', method: selected, }); - updatePR({ state }); + updatePR(newContext); } const availableOptions = Object.keys(MERGE_METHODS) @@ -310,7 +479,7 @@ export const DeleteBranch = (pr: PullRequest) => { }} > <button disabled={isBusy} className="secondary" type="submit"> - Delete branch... + Delete Branch... </button> </form> </div> @@ -319,9 +488,9 @@ export const DeleteBranch = (pr: PullRequest) => { }; function ConfirmMerge({ pr, method, cancel }: { pr: PullRequest; method: MergeMethod; cancel: () => void }) { - const { merge, updatePR } = useContext(PullRequestContext); + const { merge, updatePR, changeEmail } = useContext(PullRequestContext); const [isBusy, setBusy] = useState(false); - + const emailForCommit = pr.emailForCommit; return ( <div> <form id='merge-comment-form' @@ -331,22 +500,33 @@ function ConfirmMerge({ pr, method, cancel }: { pr: PullRequest; method: MergeMe try { setBusy(true); const { title, description }: any = event.target; - const { state } = await merge({ - title: title.value, - description: description.value, + const mergeResult = await merge({ + title: title?.value, + description: description?.value, method, + email: emailForCommit }); - updatePR({ state }); + updatePR(mergeResult); } finally { setBusy(false); } }} > - <input type="text" name="title" defaultValue={getDefaultTitleText(method, pr)} /> - <textarea name="description" defaultValue={getDefaultDescriptionText(method, pr)} /> - <div className="form-actions"> + {method === 'rebase' ? null : (<input type="text" name="title" defaultValue={getDefaultTitleText(method, pr)} />)} + {method === 'rebase' ? null : (<textarea name="description" defaultValue={getDefaultDescriptionText(method, pr)} />)} + {(method === 'rebase' || !emailForCommit) ? null : ( + <div className='commit-association'> + <span> + Commit will be associated with <button className='input-box' title='Change email' aria-label='Change email' disabled={isBusy} onClick={() => { + setBusy(true); + changeEmail(emailForCommit).finally(() => setBusy(false)); + }}>{emailForCommit}</button> + </span> + </div> + )} + <div className="form-actions" id={method === 'rebase' ? 'rebase-actions' : ''}> <button className="secondary" onClick={cancel}>Cancel</button> - <button disabled={isBusy} type="submit" id="confirm-merge">{MERGE_METHODS[method]}</button> + <button disabled={isBusy} type="submit" id="confirm-merge">{method === 'rebase' ? 'Confirm ' : ''}{MERGE_METHODS[method]}</button> </div> </form> </div> @@ -356,16 +536,23 @@ function ConfirmMerge({ pr, method, cancel }: { pr: PullRequest; method: MergeMe function getDefaultTitleText(mergeMethod: string, pr: PullRequest) { switch (mergeMethod) { case 'merge': - return `Merge pull request #${pr.number} from ${pr.head}`; + return pr.mergeCommitMeta?.title ?? `Merge pull request #${pr.number} from ${pr.head}`; case 'squash': - return `${pr.title} (#${pr.number})`; + return pr.squashCommitMeta?.title ?? `${pr.title} (#${pr.number})`; default: return ''; } } function getDefaultDescriptionText(mergeMethod: string, pr: PullRequest) { - return mergeMethod === 'merge' ? pr.title : ''; + switch (mergeMethod) { + case 'merge': + return pr.mergeCommitMeta?.description ?? pr.title; + case 'squash': + return pr.squashCommitMeta?.description ?? ''; + default: + return ''; + } } const MERGE_METHODS = { @@ -375,57 +562,89 @@ const MERGE_METHODS = { }; type MergeSelectProps = Pick<PullRequest, 'mergeMethodsAvailability'> & - Pick<PullRequest, 'defaultMergeMethod'> & { onChange?: ChangeEventHandler<HTMLSelectElement> }; + Pick<PullRequest, 'defaultMergeMethod'> & { onChange?: ChangeEventHandler<HTMLSelectElement>, name?: string, title?: string, ariaLabel?: string, disabled?: boolean }; export const MergeSelect = React.forwardRef<HTMLSelectElement, MergeSelectProps>( - ({ defaultMergeMethod, mergeMethodsAvailability: avail, onChange }: MergeSelectProps, ref) => ( - <select ref={ref} defaultValue={defaultMergeMethod} onChange={onChange} aria-label='Select merge method'> + ({ defaultMergeMethod, mergeMethodsAvailability: avail, onChange, ariaLabel, name, title, disabled }: MergeSelectProps, ref) => { + return <select ref={ref} defaultValue={defaultMergeMethod} onChange={onChange} disabled={disabled} aria-label={ariaLabel ?? 'Select merge method'} name={name} title={title}> {Object.entries(MERGE_METHODS).map(([method, text]) => ( <option key={method} value={method} disabled={!avail[method]}> {text} {!avail[method] ? ' (not enabled)' : null} </option> ))} - </select> - ), + </select>; + }, ); -const StatusCheckDetails = ({ statuses }: Partial<PullRequest['status']>) => ( - <div> - {statuses.map(s => ( - <div key={s.id} className="status-check"> - <div className="status-check-details"> - <StateIcon state={s.state} /> - <Avatar for={{ avatarUrl: s.avatar_url, url: s.url }} /> - <span className="status-check-detail-text"> - {/* allow-any-unicode-next-line */} - {s.context} {s.description ? `— ${s.description}` : ''} - </span> +// State order for sorting status checks: failure first, then pending, neutral, success, and unknown +const CHECK_STATE_ORDER: Record<CheckState, number> = { + [CheckState.Failure]: 0, + [CheckState.Pending]: 1, + [CheckState.Unknown]: 2, + [CheckState.Neutral]: 3, + [CheckState.Success]: 4, + +}; + +const StatusCheckDetails = ({ statuses }: { statuses: PullRequestCheckStatus[] }) => { + // Sort statuses to group by state: failure first, then pending, neutral, and success + // Use slice() to avoid mutating the original array + const sortedStatuses = statuses.slice().sort((a, b) => { + return CHECK_STATE_ORDER[a.state] - CHECK_STATE_ORDER[b.state]; + }); + + return ( + <div className='status-scroll'> + {sortedStatuses.map(s => ( + <div key={s.id} className="status-check"> + <div className="status-check-details"> + <StateIcon state={s.state} /> + <Avatar for={{ avatarUrl: s.avatarUrl, url: s.url }} /> + <span className="status-check-detail-text"> + {/* allow-any-unicode-next-line */} + {s.workflowName ? `${s.workflowName} / ` : null}{s.context}{s.event ? ` (${s.event})` : null} {s.description ? `— ${s.description}` : null} + </span> + </div> + <div> + {s.isRequired ? ( + <span className="label">Required</span> + ) : null} + {!!s.targetUrl ? ( + <a href={s.targetUrl} title={s.targetUrl}> + Details + </a> + ) : null} + </div> </div> - {!!s.target_url ? ( - <a href={s.target_url} title={s.target_url}> - Details - </a> - ) : null} - </div> - ))} - </div> -); + ))} + </div> + ); +}; -function getSummaryLabel(statuses: any[]) { - const statusTypes = groupBy(statuses, (status: any) => status.state); +function getSummaryLabel(statuses: PullRequestCheckStatus[]) { + const statusTypes = groupBy(statuses, (status: PullRequestCheckStatus) => { + switch (status.state) { + case CheckState.Success: + case CheckState.Failure: + case CheckState.Neutral: + return status.state; + default: + return CheckState.Pending; + } + }); const statusPhrases: string[] = []; for (const statusType of Object.keys(statusTypes)) { const numOfType = statusTypes[statusType].length; let statusAdjective = ''; switch (statusType) { - case 'success': + case CheckState.Success: statusAdjective = 'successful'; break; - case 'failure': + case CheckState.Failure: statusAdjective = 'failed'; break; - case 'neutral': + case CheckState.Neutral: statusAdjective = 'skipped'; break; default: @@ -441,14 +660,40 @@ function getSummaryLabel(statuses: any[]) { return statusPhrases.join(' and '); } -function StateIcon({ state }: { state: string }) { +function StateIcon({ state }: { state: CheckState }) { switch (state) { - case 'neutral': + case CheckState.Neutral: return skipIcon; - case 'success': + case CheckState.Success: return checkIcon; - case 'failure': + case CheckState.Failure: + return closeIcon; + } + return circleFilledIcon; +} + +function RequiredReviewStateIcon({ state }: { state: CheckState }) { + switch (state) { + case CheckState.Pending: + return requestChangesIcon; + case CheckState.Failure: return closeIcon; } - return pendingIcon; + + return checkIcon; +} + +function getRequiredReviewSummary(requirement: PullRequestReviewRequirement) { + const approvalCount = requirement.approvals.length; + const requestedChangesCount = requirement.requestedChanges.length; + const requiredCount = requirement.count; + + switch (requirement.state) { + case CheckState.Failure: + return `At least ${requiredCount} approving review${requiredCount > 1 ? 's' : ''} is required by reviewers with write access.`; + case CheckState.Pending: + return `${requestedChangesCount} review${requestedChangesCount > 1 ? 's' : ''} requesting changes by reviewers with write access.`; + } + + return `${approvalCount} approving review${approvalCount > 1 ? 's' : ''} by reviewers with write access.`; } diff --git a/webviews/components/reviewer.tsx b/webviews/components/reviewer.tsx index a84e6929b4..be8c02be01 100644 --- a/webviews/components/reviewer.tsx +++ b/webviews/components/reviewer.tsx @@ -3,15 +3,19 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import React, { cloneElement, useContext } from 'react'; -import { ReviewState } from '../../src/github/interface'; -import PullRequestContext from '../common/context'; -import { checkIcon, commentIcon, pendingIcon, requestChanges, syncIcon } from './icon'; +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'; -export function Reviewer(reviewState: ReviewState) { - const { reviewer, state } = reviewState; +export function Reviewer(reviewInfo: { reviewState: ReviewState, event?: ReviewEvent }) { + const { reviewer, state } = reviewInfo.reviewState; const { reRequestReview } = useContext(PullRequestContext); + const ariaAnnouncement = reviewInfo.event ? ariaAnnouncementForReview(reviewInfo.event) : undefined; + return ( <div className="section-item reviewer"> <div className="avatar-with-author"> @@ -20,20 +24,21 @@ export function Reviewer(reviewState: ReviewState) { </div> <div className="reviewer-icons"> { - state !== 'REQUESTED' ? - (<button className="icon-button" onClick={() => reRequestReview(reviewState.reviewer.login)}> + ((state !== 'REQUESTED') && (isITeam(reviewer) ? true : (reviewer.accountType !== AccountType.Bot))) ? + (<button className="icon-button" title="Re-request review" onClick={() => reRequestReview(reviewInfo.reviewState.reviewer.id)}> {syncIcon}️ </button>) : null } {REVIEW_STATE[state]} + {ariaAnnouncement ? <div role='alert' aria-label={ariaAnnouncement} /> : null} </div> </div> ); } 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 7ce2bcc5ae..e8b3459226 100644 --- a/webviews/components/sidebar.tsx +++ b/webviews/components/sidebar.tsx @@ -3,91 +3,202 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import React, { useContext } from 'react'; +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 { IMilestone } from '../../src/github/interface'; -import { PullRequest } from '../common/cache'; +import { IAccount, IMilestone, IProjectItem, isITeam, reviewerId, reviewerLabel, ReviewState } from '../../src/github/interface'; +import { ChangeReviewersReply, PullRequest } from '../../src/github/views'; import PullRequestContext from '../common/context'; import { Label } from '../common/label'; import { AuthorLink, Avatar } from '../components/user'; -import { closeIcon, settingsIcon } from './icon'; -import { Reviewer } from './reviewer'; -export default function Sidebar({ reviewers, labels, hasWritePermission, isIssue, milestone, assignees }: PullRequest) { +function Section({ + id, + title, + hasWritePermission, + onHeaderClick, + children, + iconButtonGroup, +}: { + id: string, + title: string, + hasWritePermission: boolean, + onHeaderClick?: (e?: React.MouseEvent) => void | Promise<void>, + children: React.ReactNode, + iconButtonGroup?: React.ReactNode, +}) { + return ( + <div id={id} className="section"> + <div + className={`section-header ${hasWritePermission ? 'clickable' : ''}`} + onClick={hasWritePermission ? onHeaderClick : undefined} + > + <div className="section-title">{title}</div> + {hasWritePermission ? ( + iconButtonGroup ? iconButtonGroup : ( + <button + className="icon-button" + title={`Add ${title}`} + onClick={onHeaderClick} + > + {settingsIcon} + </button> + ) + ) : null} + </div> + {children} + </div> + ); +} + +export default function Sidebar({ reviewers, labels, hasWritePermission, isIssue, projectItems: projects, milestone, assignees, canAssignCopilot, canRequestCopilotReview }: PullRequest) { const { addReviewers, + addReviewerCopilot, addAssignees, addAssigneeYourself, + addAssigneeCopilot, addLabels, removeLabel, + changeProjects, addMilestone, updatePR, pr, } = useContext(PullRequestContext); + + const [assigningCopilot, setAssigningCopilot] = useState(false); + const [requestingCopilotReview, setRequestingCopilotReview] = useState(false); + + const shouldShowCopilotButton = canAssignCopilot && assignees.every(assignee => !COPILOT_LOGINS.includes(assignee.login)); + const shouldShowCopilotReviewButton = canRequestCopilotReview && reviewers.every(reviewer => !isITeam(reviewer.reviewer) && !COPILOT_LOGINS.includes(reviewer.reviewer.login)); + + const updateProjects = async () => { + const newProjects = await changeProjects(); + updatePR({ ...newProjects }); + }; + return ( <div id="sidebar"> - {!isIssue ? ( - <div id="reviewers" className="section"> - <div className="section-header" onClick={async () => { + {!isIssue && ( + <Section + id="reviewers" + title="Reviewers" + hasWritePermission={hasWritePermission} + onHeaderClick={async (e) => { + const target = e?.target as HTMLElement; + if (target?.closest && target.closest('#request-copilot-review-btn')) { + return; + } const newReviewers = await addReviewers(); updatePR({ reviewers: newReviewers.reviewers }); - }}> - <div className="section-title">Reviewers</div> - {hasWritePermission ? ( + }} + iconButtonGroup={hasWritePermission && ( + <div className="icon-button-group"> + {shouldShowCopilotReviewButton ? ( + <button + id="request-copilot-review-btn" + className="icon-button" + title="Request review from Copilot" + disabled={requestingCopilotReview} + onClick={async (e) => { + e.stopPropagation(); + setRequestingCopilotReview(true); + try { + const newReviewers: ChangeReviewersReply = await addReviewerCopilot(); + updatePR({ reviewers: newReviewers.reviewers }); + } finally { + setRequestingCopilotReview(false); + } + }}> + {copilotIcon} + </button> + ) : null} <button className="icon-button" - title="Add Reviewers"> + title="Add Reviewers" + > {settingsIcon} </button> - ) : null} - </div> + </div> + )} + > {reviewers && reviewers.length ? ( reviewers.map(state => ( - <Reviewer key={state.reviewer.login} {...state} /> + <Reviewer key={reviewerId(state.reviewer)} {...{ reviewState: state }} /> )) ) : ( <div className="section-placeholder">None yet</div> )} - </div> - ) : ( - '' + {!pr!.isDraft && (hasWritePermission || pr!.isAuthor) && ( + <ConvertToDraft /> + )} + </Section> )} - <div id="assignees" className="section"> - <div className="section-header" onClick={async () => { + + <Section + id="assignees" + title="Assignees" + hasWritePermission={hasWritePermission} + onHeaderClick={async (e) => { + const target = e?.target as HTMLElement; + if (target?.closest && target.closest('#assign-copilot-btn')) { + return; + } const newAssignees = await addAssignees(); - updatePR({ assignees: newAssignees.assignees }); - }}> - <div className="section-title">Assignees</div> - {hasWritePermission ? ( + updatePR({ assignees: newAssignees.assignees, events: newAssignees.events }); + }} + iconButtonGroup={hasWritePermission && ( + <div className="icon-button-group"> + {shouldShowCopilotButton ? ( + <button + id="assign-copilot-btn" + className="icon-button" + title="Assign for Copilot to work on" + disabled={assigningCopilot} + onClick={async (e) => { + e.stopPropagation(); + setAssigningCopilot(true); + try { + const newAssignees = await addAssigneeCopilot(); + updatePR({ assignees: newAssignees.assignees, events: newAssignees.events }); + } finally { + setAssigningCopilot(false); + } + }}> + {copilotIcon} + </button> + ) : null} <button className="icon-button" - title="Add Assignees"> + title="Add Assignees" + > {settingsIcon} </button> - ) : null} - </div> + </div> + )} + > {assignees && assignees.length ? ( - assignees.map((x, i) => { - return ( - <div key={i} className="section-item reviewer"> - <div className="avatar-with-author"> - <Avatar for={x} /> - <AuthorLink for={x} /> - </div> + assignees.map((x, i) => ( + <div key={i} className="section-item reviewer"> + <div className="avatar-with-author"> + <Avatar for={x} /> + <AuthorLink for={x} /> </div> - ); - }) + </div> + )) ) : ( <div className="section-placeholder"> None yet - {pr.hasWritePermission ? ( + {pr!.hasWritePermission ? ( <> — <a className="assign-yourself" onClick={async () => { const newAssignees = await addAssigneeYourself(); - updatePR({ assignees: newAssignees.assignees }); + updatePR({ assignees: newAssignees.assignees, events: newAssignees.events }); }} > assign yourself @@ -96,26 +207,21 @@ export default function Sidebar({ reviewers, labels, hasWritePermission, isIssue ) : null} </div> )} - </div> + </Section> - <div id="labels" className="section"> - <div className="section-header" onClick={async () => { + <Section + id="labels" + title="Labels" + hasWritePermission={hasWritePermission} + onHeaderClick={async () => { const newLabels = await addLabels(); updatePR({ labels: newLabels.added }); - }}> - <div className="section-title">Labels</div> - {hasWritePermission ? ( - <button - className="icon-button" - title="Add Labels"> - {settingsIcon} - </button> - ) : null} - </div> + }} + > {labels.length ? ( <div className="labels-list"> {labels.map(label => ( - <Label key={label.name} {...label} canDelete={hasWritePermission} isDarkTheme={pr.isDarkTheme}> + <Label key={label.name} {...label} canDelete={hasWritePermission} isDarkTheme={pr!.isDarkTheme}> {hasWritePermission ? ( <button className="icon-button" onClick={() => removeLabel(label.name)}> {closeIcon}️ @@ -127,37 +233,258 @@ export default function Sidebar({ reviewers, labels, hasWritePermission, isIssue ) : ( <div className="section-placeholder">None yet</div> )} - </div> - <div id="milestone" className="section"> - <div className="section-header" onClick={async () => { + </Section> + + {!pr!.isEnterprise && ( + <Section + id="project" + title="Project" + hasWritePermission={hasWritePermission} + onHeaderClick={updateProjects} + > + {!projects ? + <a onClick={updateProjects}>Sign in with more permissions to see projects</a> + : (projects.length > 0) + ? projects.map(project => ( + <Project key={project.project.title} {...project} canDelete={hasWritePermission} /> + )) : + <div className="section-placeholder">None yet</div> + } + </Section> + )} + + <Section + id="milestone" + title="Milestone" + hasWritePermission={hasWritePermission} + onHeaderClick={async () => { const newMilestone = await addMilestone(); updatePR({ milestone: newMilestone.added }); - }}> - <div className="section-title">Milestone</div> - {hasWritePermission ? ( - <button - className="icon-button" - title="Add Milestone"> - {settingsIcon} - </button> - ) : null} - </div> + }} + > {milestone ? ( <Milestone key={milestone.title} {...milestone} canDelete={hasWritePermission} /> ) : ( <div className="section-placeholder">No milestone</div> )} + </Section> + </div> + ); +} + +export function CollapsibleSidebar(props: PullRequest) { + const [expanded, setExpanded] = useState(false); + const contentRef = useRef<HTMLDivElement>(null); + + return ( + <div className="collapsible-sidebar"> + <div + className={`collapsible-sidebar-header ${expanded ? 'expanded' : ''}`} + onClick={() => setExpanded(e => !e)} + tabIndex={0} + role="button" + aria-expanded={expanded} + > + <span className="collapsible-sidebar-title">{expanded ? null : <CollapsedLabel {...props} />}</span> + </div> + <div + className="collapsible-sidebar-content" + ref={contentRef} + style={{ display: expanded ? 'block' : 'none' }} + > + <Sidebar {...props} /> </div> + <a className='collapsible-label-see-more' onClick={() => setExpanded(e => !e)}>{expanded ? 'See less' : 'See more'}</a> </div> ); } +function CollapsedLabel(props: PullRequest) { + const { reviewers, assignees, labels, projectItems, milestone, isIssue } = props; + const [isNarrowViewport, setIsNarrowViewport] = useState(false); + + useEffect(() => { + const checkViewportWidth = () => { + setIsNarrowViewport(window.innerWidth <= 350); + }; + + checkViewportWidth(); + window.addEventListener('resize', checkViewportWidth); + return () => window.removeEventListener('resize', checkViewportWidth); + }, []); + + const AvatarStack = ({ users }: { users: { avatarUrl: string; name: string }[] }) => ( + <span className="avatar-stack" style={{ + width: `${Math.min(users.length, 10) * 10 + 10}px` + }}> + {users.slice(0, 10).map((u, i) => ( + <span className='stacked-avatar' style={{ + left: `${i * 10}px`, + }}> + <Avatar for={u} /> + </span> + ))} + </span> + ); + + interface PillContainerProps<T> { + items: T[]; + getKey: (item: T) => string; + getColor: (item: T) => { backgroundColor: string; textColor: string; borderColor: string }; + getText: (item: T) => string; + } + + const PillContainer = <T,>({ items, getKey, getColor, getText }: PillContainerProps<T>) => { + const containerRef = useRef<HTMLSpanElement>(null); + const [visibleCount, setVisibleCount] = useState(items.length); + + useEffect(() => { + if (!containerRef.current || items.length === 0) return; + + const resizeObserver = new ResizeObserver(() => { + const container = containerRef.current; + if (!container) return; + + const containerWidth = container.offsetWidth; + const overflowTextWidth = 60; // "+N more" text width estimate + + // Start with all items and reduce until they fit + let testCount = items.length; + let characterCount = items.reduce((acc, item) => acc + getText(item).length, 0); + while (testCount > 0) { + const testWidth = ((characterCount * 6) + (14 * testCount)) + (testCount < items.length ? overflowTextWidth : 0); + if (testWidth <= containerWidth) { + break; + } + characterCount -= getText(items[testCount - 1]).length; + testCount--; + } + + setVisibleCount(Math.max(1, testCount)); + }); + + resizeObserver.observe(containerRef.current); + return () => resizeObserver.disconnect(); + }, [items.length]); + + const visibleItems = items.slice(0, visibleCount); + const hiddenCount = items.length - visibleCount; + + return <span className="pill-container" ref={containerRef}> + {visibleItems.map((item) => { + const color = getColor(item); + return ( + <span + key={getKey(item)} + className="pill-item label" + style={{ + backgroundColor: color.backgroundColor, + color: color.textColor, + borderRadius: '20px', + }} + title={getText(item)} + > + {getText(item)} + </span> + ); + })} + {hiddenCount > 0 && ( + <span className="pill-overflow">+{hiddenCount} more</span> + )} + </span>; + }; + + // Collect non-empty sections in order, with custom rendering + const sections: { label: string; value: React.ReactNode; count: number }[] = []; + + const reviewersWithAvatar = reviewers?.filter((r): r is ReviewState & { reviewer: { avatarUrl: string } } => !!r.reviewer.avatarUrl).map(r => ({ avatarUrl: r.reviewer.avatarUrl, name: reviewerLabel(r.reviewer) })); + if (!isIssue && reviewersWithAvatar && reviewersWithAvatar.length) { + sections.push({ + label: 'Reviewers', + value: <AvatarStack users={reviewersWithAvatar} />, + count: reviewersWithAvatar.length + }); + } + + const assigneesWithAvatar = assignees?.filter((a): a is IAccount & { avatarUrl: string; login: string } => !!a.avatarUrl).map(a => ({ avatarUrl: a.avatarUrl, name: reviewerLabel(a) })); + if (assigneesWithAvatar && assigneesWithAvatar.length) { + sections.push({ + label: 'Assignees', + value: <AvatarStack users={assigneesWithAvatar} />, + count: assigneesWithAvatar.length + }); + } + if (labels && labels.length) { + sections.push({ + label: 'Labels', + value: ( + <PillContainer + items={labels} + getKey={l => l.name} + getColor={l => gitHubLabelColor(l.color, props?.isDarkTheme, false)} + getText={l => l.displayName} + /> + ), + count: labels.length + }); + } + if (projectItems && projectItems.length) { + sections.push({ + label: 'Project', + value: ( + <PillContainer + items={projectItems} + getKey={p => p.project.title} + getColor={() => gitHubLabelColor('#ededed', props?.isDarkTheme, false)} + getText={p => p.project.title} + /> + ), + count: projectItems.length + }); + } + if (milestone) { + sections.push({ + label: 'Milestone', + value: ( + <PillContainer + items={[milestone]} + getKey={m => m.title} + getColor={() => gitHubLabelColor('#ededed', props?.isDarkTheme, false)} + getText={m => m.title} + /> + ), + count: 1 + }); + } + + if (!sections.length) { + return <span className="collapsed-label">{isIssue ? 'Assignees, Labels, Project, and Milestone' : 'Reviewers, Assignees, Labels, Project, and Milestone'}</span>; + } + + return ( + <span className="collapsed-label"> + {sections.map((s) => ( + <span className='collapsed-section' key={s.label}> + <span className='collapsed-section-label'>{s.label}</span> + {isNarrowViewport ? ( + <span className="collapsed-section-count"> + {s.count} + </span> + ) : ( + s.value + )} + </span> + ))} + </span> + ); +} + function Milestone(milestone: IMilestone & { canDelete: boolean }) { const { removeMilestone, updatePR, pr } = useContext(PullRequestContext); const backgroundBadgeColor = getComputedStyle(document.documentElement).getPropertyValue( '--vscode-badge-foreground', ); - const labelColor = gitHubLabelColor(backgroundBadgeColor, pr.isDarkTheme, false); + const labelColor = gitHubLabelColor(backgroundBadgeColor, pr!.isDarkTheme, false); const { canDelete, title } = milestone; return ( <div className="labels-list"> @@ -175,7 +502,41 @@ function Milestone(milestone: IMilestone & { canDelete: boolean }) { className="icon-button" onClick={async () => { await removeMilestone(); - updatePR({ milestone: null }); + updatePR({ milestone: undefined }); + }} + > + {closeIcon}️ + </button> + ) : null} + </div> + </div> + ); +} + +function Project(project: IProjectItem & { canDelete: boolean }) { + const { removeProject, updatePR, pr } = useContext(PullRequestContext); + const backgroundBadgeColor = getComputedStyle(document.documentElement).getPropertyValue( + '--vscode-badge-foreground', + ); + const labelColor = gitHubLabelColor(backgroundBadgeColor, pr!.isDarkTheme, false); + const { canDelete } = project; + return ( + <div className="labels-list"> + <div + className="section-item label" + style={{ + backgroundColor: labelColor.backgroundColor, + color: labelColor.textColor, + borderColor: `${labelColor.borderColor}`, + }} + > + {project.project.title} + {canDelete ? ( + <button + className="icon-button" + onClick={async () => { + await removeProject(project); + updatePR({ projectItems: pr!.projectItems?.filter(x => x.id !== project.id) }); }} > {closeIcon}️ @@ -185,3 +546,34 @@ function Milestone(milestone: IMilestone & { canDelete: boolean }) { </div> ); } + +function ConvertToDraft() { + const { convertToDraft, updatePR, pr } = useContext(PullRequestContext); + const [isBusy, setBusy] = useState(false); + + const handleConvertToDraft = async () => { + try { + setBusy(true); + const result = await convertToDraft(); + updatePR({ isDraft: result.isDraft }); + } finally { + setBusy(false); + } + }; + + return ( + <div className="section-placeholder" style={{ marginTop: '8px' }}> + Still in progress?{' '} + <a + onClick={handleConvertToDraft} + style={{ + pointerEvents: (isBusy || pr?.busy) ? 'none' : 'auto', + opacity: (isBusy || pr?.busy) ? 0.5 : 1, + cursor: 'pointer' + }} + > + Convert to draft + </a> + </div> + ); +} diff --git a/webviews/components/timeline.tsx b/webviews/components/timeline.tsx index 8c0d8d7bf0..7133e03783 100644 --- a/webviews/components/timeline.tsx +++ b/webviews/components/timeline.tsx @@ -4,30 +4,72 @@ *--------------------------------------------------------------------------------------------*/ import React, { useContext, useRef, useState } from 'react'; - +import { CommentView } from './comment'; +import Diff from './diff'; +import { addIcon, checkIcon, circleFilledIcon, closeIcon, commentIcon, 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, + BaseRefChangedEvent, + ClosedEvent, CommentEvent, CommitEvent, + CopilotFinishedErrorEvent, + CopilotFinishedEvent, + CopilotReviewStartedEvent, + CopilotStartedEvent, + CrossReferencedEvent, EventType, HeadRefDeleteEvent, MergedEvent, + ReopenedEvent, ReviewEvent, TimelineEvent, + UnassignEvent, } from '../../src/common/timelineEvent'; 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 { CommentBody, CommentView } from './comment'; -import Diff from './diff'; -import { commitIcon, mergeIcon, plusIcon } from './icon'; -import { nbsp, Spaced } from './space'; -import { Timestamp } from './timestamp'; -import { AuthorLink, Avatar } from './user'; -export const Timeline = ({ events }: { events: TimelineEvent[] }) => ( - <> - {events.map(event => { +function isAssignUnassignEvent(event: TimelineEvent | ConsolidatedAssignUnassignEvent): event is AssignEvent | UnassignEvent { + return event.event === EventType.Assigned || event.event === EventType.Unassigned; +} + +interface ConsolidatedAssignUnassignEvent { + id: number; + event: EventType.Assigned | EventType.Unassigned; + assignees?: IAccount[]; + unassignees?: IAccount[]; + actor: IActor; + createdAt: string; +} + +export const Timeline = ({ events, isIssue }: { events: TimelineEvent[], isIssue: boolean }) => { + const consolidatedEvents: (TimelineEvent | ConsolidatedAssignUnassignEvent)[] = []; + for (let i = 0; i < events.length; i++) { + if ((i > 0) && isAssignUnassignEvent(events[i]) && isAssignUnassignEvent(consolidatedEvents[consolidatedEvents.length - 1])) { + const lastEvent = consolidatedEvents[consolidatedEvents.length - 1] as ConsolidatedAssignUnassignEvent; + const newEvent = events[i] as ConsolidatedAssignUnassignEvent; + if ((lastEvent.actor.login === newEvent.actor.login) && (new Date(lastEvent.createdAt).getTime() + (1000 * 60 * 10) > new Date(newEvent.createdAt).getTime())) { // within 10 minutes + const assignees = lastEvent.assignees || []; + const unassignees = lastEvent.unassignees || []; + const newAssignees = newEvent.assignees?.filter(a => !assignees.some(b => b.id === a.id)) ?? []; + const newUnassignees = newEvent.unassignees?.filter(a => !unassignees.some(b => b.id === a.id)) ?? []; + lastEvent.assignees = [...assignees, ...newAssignees]; + lastEvent.unassignees = [...unassignees, ...newUnassignees]; + } else { + consolidatedEvents.push(newEvent); + } + } else { + consolidatedEvents.push(events[i]); + } + } + + return <>{consolidatedEvents.map(event => { switch (event.event) { case EventType.Committed: return <CommitEventView key={`commit${event.id}`} {...event} />; @@ -38,57 +80,127 @@ export const Timeline = ({ events }: { events: TimelineEvent[] }) => ( case EventType.Merged: return <MergedEventView key={`merged${event.id}`} {...event} />; case EventType.Assigned: - return <AssignEventView key={`assign${event.id}`} {...event} />; + return <AssignUnassignEventView key={`assign${event.id}`} event={event} />; + case EventType.Unassigned: + return <AssignUnassignEventView key={`unassign${event.id}`} event={event} />; case EventType.HeadRefDeleted: return <HeadDeleteEventView key={`head${event.id}`} {...event} />; + case EventType.CrossReferenced: + return <CrossReferencedEventView key={`cross${event.id}`} {...event} />; + case EventType.Closed: + return <ClosedEventView key={`closed${event.id}`} event={event} isIssue={isIssue} />; + case EventType.Reopened: + return <ReopenedEventView key={`reopened${event.id}`} event={event} isIssue={isIssue} />; + case EventType.BaseRefChanged: + return <BaseRefChangedEventView key={`baseref${event.id}`} event={event} />; case EventType.NewCommitsSinceReview: return <NewCommitsSinceReviewEventView key={`newCommits${event.id}`} />; + case EventType.CopilotStarted: + return <CopilotStartedEventView key={`copilotStarted${event.id}`} {...event} />; + case EventType.CopilotFinished: + return <CopilotFinishedEventView key={`copilotFinished${event.id}`} {...event} />; + case EventType.CopilotFinishedError: + return <CopilotFinishedErrorEventView key={`copilotFinishedError${event.id}`} {...event} />; + case EventType.CopilotReviewStarted: + return <CopilotReviewStartedEventView key={`copilotReviewStarted${event.id}`} {...event} />; default: throw new UnreachableCaseError(event); } - })} - </> -); + })}</>; +}; export default Timeline; -const CommitEventView = (event: CommitEvent) => ( - <div className="comment-container commit"> - <div className="commit-message"> - {commitIcon} - {nbsp} - <div className="avatar-container"> - <Avatar for={event.author} /> + +function CommitStateIcon({ status }: { status: 'EXPECTED' | 'ERROR' | 'FAILURE' | 'PENDING' | 'SUCCESS' | undefined; }) { + switch (status) { + case 'PENDING': + return circleFilledIcon; + case 'SUCCESS': + return checkIcon; + case 'FAILURE': + case 'ERROR': + return closeIcon; + } + return null; +} + +const CommitEventView = (event: CommitEvent) => { + const context = useContext(PullRequestContext); + const [clickedElement, setClickedElement] = useState<'title' | 'sha' | undefined>(undefined); + + const handleCommitClick = (e: React.MouseEvent, elementType: 'title' | 'sha') => { + e.preventDefault(); + setClickedElement(elementType); + context.openCommitChanges(event.sha).finally(() => { + setClickedElement(undefined); + }); + }; + + const isLoading = context.pr?.loadingCommit === event.sha; + + return ( + <div className="comment-container commit"> + <div className="commit-message"> + {gitCommitIcon} + {nbsp} + <div className="avatar-container"> + <Avatar for={event.author} /> + </div> + <div className="message-container"> + <a + className="message" + onClick={(e) => handleCommitClick(e, 'title')} + title={event.htmlUrl} + > + {event.message.substr(0, event.message.indexOf('\n') > -1 ? event.message.indexOf('\n') : event.message.length)} + </a> + {isLoading && clickedElement === 'title' && <span className="commit-spinner-inline">{loadingIcon}</span>} + </div> </div> - <AuthorLink for={event.author} /> - <div className="message-container"> - <a className="message" href={event.htmlUrl} title={event.htmlUrl}> - {event.message.substr(0, event.message.indexOf('\n') > -1 ? event.message.indexOf('\n') : event.message.length)} + <div className="timeline-detail"> + <div className='status-section'> + <CommitStateIcon status={event.status} /> + </div> + <a + className="sha" + onClick={(e) => handleCommitClick(e, 'sha')} + title={event.htmlUrl} + > + {isLoading && clickedElement === 'sha' && <span className="commit-spinner-before">{loadingIcon}</span>} + {event.sha.slice(0, 7)} </a> + <Timestamp date={event.committedDate} /> </div> </div> - <div className="sha-with-timestamp"> - <a className="sha" href={event.htmlUrl} title={event.htmlUrl}> - {event.sha.slice(0, 7)} - </a> - <Timestamp date={event.authoredDate} /> - </div> - </div> -); + ); +}; const NewCommitsSinceReviewEventView = () => { - const { gotoChangesSinceReview } = useContext(PullRequestContext); + const { gotoChangesSinceReview, pr } = useContext(PullRequestContext); + if (!pr.isCurrentlyCheckedOut) { + return null; + } + + const [busy, setBusy] = useState(false); + const viewChanges = async () => { + setBusy(true); + await gotoChangesSinceReview(); + setBusy(false); + }; + return ( <div className="comment-container commit"> <div className="commit-message"> - {plusIcon} + {addIcon} {nbsp} <span style={{ fontWeight: 'bold' }}>New changes since your last Review</span> </div> <button aria-live="polite" title="View the changes since your last review" - onClick={() => gotoChangesSinceReview()} + onClick={viewChanges} + disabled={busy} > View Changes </button> @@ -96,66 +208,28 @@ const NewCommitsSinceReviewEventView = () => { ); }; -const association = ({ authorAssociation }: ReviewEvent, format = (assoc: string) => `(${assoc.toLowerCase()})`) => - authorAssociation.toLowerCase() === 'user' - ? format('you') - : authorAssociation && authorAssociation !== 'NONE' - ? format(authorAssociation) - : null; - const positionKey = (comment: IComment) => comment.position !== null ? `pos:${comment.position}` : `ori:${comment.originalPosition}`; const groupCommentsByPath = (comments: IComment[]) => groupBy(comments, comment => comment.path + ':' + positionKey(comment)); -const DESCRIPTORS = { - PENDING: 'will review', - COMMENTED: 'reviewed', - CHANGES_REQUESTED: 'requested changes', - APPROVED: 'approved', -}; - -const reviewDescriptor = (state: string) => DESCRIPTORS[state] || 'reviewed'; - const ReviewEventView = (event: ReviewEvent) => { const comments = groupCommentsByPath(event.comments); - const reviewIsPending = event.state.toLocaleUpperCase() === 'PENDING'; + const reviewIsPending = event.state === 'PENDING'; return ( - <div id={reviewIsPending ? 'pending-review' : undefined} className="comment-container comment"> - <div className="review-comment-container"> - <div className="review-comment-header"> - <Spaced> - <Avatar for={event.user} /> - <AuthorLink for={event.user} /> - {association(event)} - {reviewIsPending ? ( - <em>review pending</em> - ) : ( - <> - {reviewDescriptor(event.state)} - {nbsp} - <Timestamp href={event.htmlUrl} date={event.submittedAt} /> - </> - )} - </Spaced> + <CommentView comment={event} allowEmpty={true}> + {/* Don't show the empty comment body unless a comment has been written. Shows diffs and suggested changes. */} + {event.comments.length ? ( + <div className="comment-body review-comment-body"> + {Object.entries(comments).map(([key, thread]) => { + return <CommentThread key={key} thread={thread} event={event} />; + })} </div> - {event.state !== 'PENDING' && event.body ? ( - <CommentBody body={event.body} bodyHTML={event.bodyHTML} canApplyPatch={false} /> - ) : null} - - {/* Don't show the empty comment body unless a comment has been written. Shows diffs and suggested changes. */} - {event.comments.length ? ( - <div className="comment-body review-comment-body"> - {Object.entries(comments).map(([key, thread]) => { - return <CommentThread key={key} thread={thread} event={event} />; - })} - </div> - ) : null} - - {reviewIsPending ? <AddReviewSummaryComment /> : null} - </div> - </div> + ) : null} + + {reviewIsPending ? <AddReviewSummaryComment /> : null} + </CommentView> ); }; @@ -200,9 +274,9 @@ function CommentThread({ thread, event }: { thread: IComment[]; event: ReviewEve </div> {revealed ? ( <div> - <Diff hunks={comment.diffHunks} /> + <Diff hunks={comment.diffHunks ?? []} /> {thread.map(c => ( - <CommentView key={c.id} {...c} pullRequestReviewId={event.id} /> + <CommentView key={c.id} comment={c} /> ))} {resolvePermission ? ( <div className="resolve-comment-row"> @@ -218,21 +292,75 @@ function CommentThread({ thread, event }: { thread: IComment[]; event: ReviewEve } function AddReviewSummaryComment() { - const { requestChanges, approve, submit, pr } = useContext(PullRequestContext); - const { isAuthor } = pr; + const { requestChanges, approve, submit, deleteReview, pr } = useContext(PullRequestContext); + const isAuthor = pr?.isAuthor; const comment = useRef<HTMLTextAreaElement>(); + const [isBusy, setBusy] = useState(false); + const [commentText, setCommentText] = useState(''); + + async function submitAction(event: React.MouseEvent | React.KeyboardEvent, action: ReviewType): Promise<void> { + event.preventDefault(); + const value = commentText; + setBusy(true); + switch (action) { + case ReviewType.RequestChanges: + await requestChanges(value); + break; + case ReviewType.Approve: + await approve(value); + break; + default: + await submit(value); + } + setBusy(false); + } + + async function cancelReview(event: React.MouseEvent): Promise<void> { + event.preventDefault(); + setBusy(true); + await deleteReview(); + setBusy(false); + } + + const onKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => { + if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { + submitAction(event, ReviewType.Comment); + } + }; + + const onTextareaChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => { + setCommentText(event.target.value); + }; + + // Disable buttons when summary comment is empty AND there are no review comments + // Note: Approve button is allowed even with empty content and no pending review + const shouldDisableButtons = !commentText.trim() && !pr.hasReviewDraft; + return ( <form> - <textarea ref={comment} placeholder="Leave a review summary comment"></textarea> + <textarea + id='pending-review' + ref={comment} + placeholder="Leave a review summary comment" + onKeyDown={onKeyDown} + onChange={onTextareaChange} + value={commentText} + ></textarea> <div className="form-actions"> + <button + id="cancel-review" + className='secondary' + disabled={isBusy || pr?.busy} + onClick={cancelReview} + > + Cancel Review + </button> {isAuthor ? null : ( <button id="request-changes" className='secondary' - onClick={(event) => { - event.preventDefault(); - requestChanges(comment.current!.value); - }} + disabled={isBusy || pr.busy || shouldDisableButtons} + onClick={(event) => submitAction(event, ReviewType.RequestChanges)} > Request Changes </button> @@ -240,49 +368,53 @@ function AddReviewSummaryComment() { {isAuthor ? null : ( <button id="approve" className='secondary' - onClick={(event) => { - event.preventDefault(); - approve(comment.current!.value); - }} + disabled={isBusy || pr.busy} + onClick={(event) => submitAction(event, ReviewType.Approve)} > Approve </button> )} <button - onClick={(event) => { - event.preventDefault(); - submit(comment.current!.value); - }} + disabled={isBusy || pr.busy || shouldDisableButtons} + onClick={(event) => submitAction(event, ReviewType.Comment)} >Submit Review</button> </div> </form> ); } -const CommentEventView = (event: CommentEvent) => <CommentView headerInEditMode {...event} />; +const CommentEventView = (event: CommentEvent) => <CommentView headerInEditMode comment={event} />; -const MergedEventView = (event: MergedEvent) => ( - <div className="comment-container commit"> - <div className="commit-message"> - {mergeIcon} - {nbsp} - <div className="avatar-container"> - <Avatar for={event.user} /> - </div> - <AuthorLink for={event.user} /> - <div className="message"> - merged commit{nbsp} - <a className="sha" href={event.commitUrl} title={event.commitUrl}> - {event.sha.substr(0, 7)} - </a> - {nbsp} - into {event.mergeRef} +const MergedEventView = (event: MergedEvent) => { + const { revert, pr } = useContext(PullRequestContext); + + return ( + <div className="comment-container commit"> + <div className="commit-message"> + {gitMergeIcon} {nbsp} + <div className="avatar-container"> + <Avatar for={event.user} /> + </div> + <AuthorLink for={event.user} /> + <div className="message"> + merged commit{nbsp} + <a className="sha" href={event.commitUrl} title={event.commitUrl}> + {event.sha.substr(0, 7)} + </a> + {nbsp} + into {event.mergeRef} + {nbsp} + </div> </div> + {pr.revertable ? + <div className="timeline-detail"> + <button className='secondary' disabled={pr.busy} onClick={revert}>Revert</button> + </div> : null} <Timestamp href={event.url} date={event.createdAt} /> </div> - </div> -); + ); +}; const HeadDeleteEventView = (event: HeadRefDeleteEvent) => ( <div className="comment-container commit"> @@ -294,11 +426,205 @@ const HeadDeleteEventView = (event: HeadRefDeleteEvent) => ( <div className="message"> deleted the {event.headRef} branch{nbsp} </div> - <Timestamp date={event.createdAt} /> </div> + <Timestamp date={event.createdAt} /> </div> ); -// TODO: We should show these, but the pre-React overview page didn't. Add -// support in a separate PR. -const AssignEventView = (event: AssignEvent) => null; +const CrossReferencedEventView = (event: CrossReferencedEvent) => { + const { source } = event; + return ( + <div className="comment-container commit"> + <div className="commit-message"> + <div className="avatar-container"> + <Avatar for={event.actor} /> + </div> + <AuthorLink for={event.actor} /> + <div className="message"> + linked <a href={source.extensionUrl}>#{source.number}</a> {source.title} + {nbsp} + {event.willCloseTarget ? 'which will close this issue' : ''} + </div> + </div> + <Timestamp date={event.createdAt} /> + </div> + ); +}; + +function joinWithAnd(arr: JSX.Element[]): JSX.Element { + if (arr.length === 0) return <></>; + if (arr.length === 1) return arr[0]; + if (arr.length === 2) return <>{arr[0]} and {arr[1]}</>; + return <>{arr.slice(0, -1).map(item => <>{item}, </>)} and {arr[arr.length - 1]}</>; +} + +const AssignUnassignEventView = ({ event }: { event: AssignEvent | UnassignEvent | ConsolidatedAssignUnassignEvent }) => { + const { actor } = event; + const assignees = (event as AssignEvent).assignees || []; + const unassignees = (event as UnassignEvent).unassignees || []; + const joinedAssignees = joinWithAnd(assignees.map(a => <AuthorLink key={`${a.id}a`} for={a} />)); + const joinedUnassignees = joinWithAnd(unassignees.map(a => <AuthorLink key={`${a.id}u`} for={a} />)); + + // Check if actor is assigning/unassigning themselves + const isSelfAssign = assignees.length === 1 && assignees[0].login === actor.login; + const isSelfUnassign = unassignees.length === 1 && unassignees[0].login === actor.login; + + let message: JSX.Element; + if (assignees.length > 0 && unassignees.length > 0) { + // Handle mixed case with potential self-assignment + const assignMessage = isSelfAssign ? <>self-assigned this</> : <>assigned {joinedAssignees}</>; + const unassignMessage = isSelfUnassign ? <>removed their assignment</> : <>unassigned {joinedUnassignees}</>; + message = <>{assignMessage} and {unassignMessage}</>; + } else if (assignees.length > 0) { + message = isSelfAssign ? <>self-assigned this</> : <>assigned {joinedAssignees}</>; + } else { + message = isSelfUnassign ? <>removed their assignment</> : <>unassigned {joinedUnassignees}</>; + } + + return ( + <div className="comment-container commit"> + <div className="commit-message"> + <div className="avatar-container"> + <Avatar for={actor} /> + </div> + <AuthorLink for={actor} /> + <div className="message"> + {message} + </div> + </div> + <Timestamp date={event.createdAt} /> + </div> + ); +}; + +const ClosedEventView = ({ event, isIssue }: { event: ClosedEvent, isIssue: boolean }) => { + const { actor, createdAt } = event; + return ( + <div className="comment-container commit"> + <div className="commit-message"> + <div className="avatar-container"> + <Avatar for={actor} /> + </div> + <AuthorLink for={actor} /> + <div className="message">{isIssue ? 'closed this issue' : 'closed this pull request'}</div> + </div> + <Timestamp date={createdAt} /> + </div> + ); +}; + +const ReopenedEventView = ({ event, isIssue }: { event: ReopenedEvent, isIssue: boolean }) => { + const { actor, createdAt } = event; + return ( + <div className="comment-container commit"> + <div className="commit-message"> + <div className="avatar-container"> + <Avatar for={actor} /> + </div> + <AuthorLink for={actor} /> + <div className="message">{isIssue ? 'reopened this issue' : 'reopened this pull request'}</div> + </div> + <Timestamp date={createdAt} /> + </div> + ); +}; + +const BaseRefChangedEventView = ({ event }: { event: BaseRefChangedEvent }) => { + const { actor, createdAt, currentRefName, previousRefName } = event; + return ( + <div className="comment-container commit"> + <div className="commit-message"> + <div className="avatar-container"> + <Avatar for={actor} /> + </div> + <AuthorLink for={actor} /> + <div className="message"> + changed the base branch from <code className="branch-tag">{previousRefName}</code> to <code className="branch-tag">{currentRefName}</code> + </div> + </div> + <Timestamp date={createdAt} /> + </div> + ); +}; + +const CopilotStartedEventView = (event: CopilotStartedEvent) => { + const { createdAt, onBehalfOf, sessionLink } = event; + const { openSessionLog } = useContext(PullRequestContext); + + const handleSessionLogClick = (e: React.MouseEvent) => { + if (sessionLink) { + sessionLink.openToTheSide = e.ctrlKey || e.metaKey; + openSessionLog(sessionLink); + } + }; + + return ( + <div className="comment-container commit"> + <div className="commit-message"> + {threeBars} + {nbsp} + <div className="message">Copilot started work on behalf of <AuthorLink for={onBehalfOf} /></div> + </div> + {sessionLink ? ( + <div className="timeline-detail"> + <a onClick={handleSessionLogClick}><button className='secondary' title="View session log (Ctrl/Cmd+Click to open in second editor group)">View session</button></a> + </div>) + : null} + <Timestamp date={createdAt} /> + </div> + ); +}; + +const CopilotFinishedEventView = (event: CopilotFinishedEvent) => { + const { createdAt, onBehalfOf } = event; + return ( + <div className="comment-container commit"> + <div className="commit-message"> + {tasklistIcon} + {nbsp} + <div className="message">Copilot finished work on behalf of <AuthorLink for={onBehalfOf} /></div> + </div> + <Timestamp date={createdAt} /> + </div> + ); +}; + +const CopilotFinishedErrorEventView = (event: CopilotFinishedErrorEvent) => { + const { createdAt, onBehalfOf } = event; + const { openSessionLog } = useContext(PullRequestContext); + + const handleSessionLogClick = (e: React.MouseEvent) => { + event.sessionLink.openToTheSide = e.ctrlKey || e.metaKey; + openSessionLog(event.sessionLink); + }; + + return ( + <div className="comment-container commit"> + <div className='timeline-with-detail'> + <div className='commit-message'> + {errorIcon} + {nbsp} + <div className="message">Copilot stopped work on behalf of <AuthorLink for={onBehalfOf} /> due to an error</div> + </div> + <div className="commit-message-detail"> + <a onClick={handleSessionLogClick} title="View session log (Ctrl/Cmd+Click to open in second editor group)">Copilot has encountered an error. See logs for additional details.</a> + </div> + </div> + <Timestamp date={createdAt} /> + </div> + ); +}; + +const CopilotReviewStartedEventView = (event: CopilotReviewStartedEvent) => { + const { createdAt } = event; + return ( + <div className="comment-container commit"> + <div className="commit-message"> + {commentIcon} + {nbsp} + <div className="message">Copilot started reviewing</div> + </div> + <Timestamp date={createdAt} /> + </div> + ); +}; \ No newline at end of file diff --git a/webviews/components/timestamp.tsx b/webviews/components/timestamp.tsx index 988d8ae0a5..25b15cd05c 100644 --- a/webviews/components/timestamp.tsx +++ b/webviews/components/timestamp.tsx @@ -3,19 +3,98 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as React from 'react'; +import React, { useEffect, useState } from 'react'; import { dateFromNow } from '../../src/common/utils'; export const Timestamp = ({ date, href }: { date: Date | string; href?: string }) => { + const [timeString, setTimeString] = useState(dateFromNow(date)); const title = typeof date === 'string' ? new Date(date).toLocaleString() : date.toLocaleString(); + + useEffect(() => { + // Update the time string immediately + setTimeString(dateFromNow(date)); + + // Calculate appropriate update interval based on how old the timestamp is + const getUpdateInterval = () => { + const now = Date.now(); + const timestamp = typeof date === 'string' ? new Date(date).getTime() : date.getTime(); + const ageInMinutes = (now - timestamp) / (1000 * 60); + + // For very recent timestamps (< 1 minute), update every 20 seconds + if (ageInMinutes < 1) { + return 20000; // 20 seconds + } + // For timestamps < 1 hour old, update every 2 minutes + else if (ageInMinutes < 60) { + return 2 * 60000; // 2 minutes + } + // For timestamps < 1 day old, update every 10 minutes + else if (ageInMinutes < 60 * 24) { + return 10 * 60000; // 10 minutes + } + // Older timestamps shouldn't be updated + return null; + }; + + const intervalDuration = getUpdateInterval(); + + // If intervalDuration is null, don't set up any updates for very old timestamps + if (intervalDuration === null) { + return; + } + + let intervalId: number; + + const updateTimeString = () => { + // Only update if the page is visible + if (document.visibilityState === 'visible') { + setTimeString(dateFromNow(date)); + } + }; + + const startInterval = () => { + intervalId = window.setInterval(updateTimeString, intervalDuration); + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + // Page became visible, update immediately and restart interval + setTimeString(dateFromNow(date)); + if (intervalId) { + clearInterval(intervalId); + } + startInterval(); + } else { + // Page became hidden, pause the interval + if (intervalId) { + clearInterval(intervalId); + } + } + }; + + // Start the interval + startInterval(); + + // Listen for visibility changes + document.addEventListener('visibilitychange', handleVisibilityChange); + + // Clean up on component unmount + return () => { + if (intervalId) { + clearInterval(intervalId); + } + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [date]); + return href ? ( <a href={href} className="timestamp" title={title}> - {dateFromNow(date)} + {timeString} </a> ) : ( <div className="timestamp" title={title}> - {dateFromNow(date)} + {timeString} </div> ); }; diff --git a/webviews/components/user.tsx b/webviews/components/user.tsx index 1ac5a516f5..edfd51e3d7 100644 --- a/webviews/components/user.tsx +++ b/webviews/components/user.tsx @@ -4,21 +4,31 @@ *--------------------------------------------------------------------------------------------*/ import * as React from 'react'; -import { PullRequest } from '../common/cache'; import { Icon } from './icon'; +import { IAccount, IActor, ITeam, reviewerLabel } from '../../src/github/interface'; -export const Avatar = ({ for: author }: { for: Partial<PullRequest['author']> }) => ( - <a className="avatar-link" href={author.url} title={author.url}> - {author.avatarUrl ? ( - <img className="avatar" src={author.avatarUrl} alt="" /> +const InnerAvatar = ({ for: author }: { for: Partial<IAccount> }) => ( + <> + {author.avatarUrl && author.avatarUrl.includes('githubusercontent.com') ? ( + <img className="avatar" src={author.avatarUrl} alt="" role="presentation" aria-hidden="true"/> ) : ( <Icon className="avatar-icon" src={require('../../resources/icons/dark/github.svg')} /> )} - </a> + </> ); -export const AuthorLink = ({ for: author, text = author.login }: { for: PullRequest['author']; text?: string }) => ( - <a className="author-link" href={author.url} title={author.url}> +export const Avatar = ({ for: author, link = true, substituteIcon }: { for: Partial<IAccount>, link?: boolean, substituteIcon?: JSX.Element }) => { + if (link) { + return <a className="avatar-link" href={author.url} title={author.url} aria-hidden="true"> + {substituteIcon ?? <InnerAvatar for={author} />} + </a>; + } else { + return substituteIcon ?? <InnerAvatar for={author} />; + } +}; + +export const AuthorLink = ({ for: author, text = reviewerLabel(author) }: { for: IActor | ITeam; text?: string }) => ( + <a className="author-link" href={author.url} aria-label={text} title={author.url} > {text} </a> ); diff --git a/webviews/createPullRequestView/app.tsx b/webviews/createPullRequestView/app.tsx deleted file mode 100644 index a05c625d9e..0000000000 --- a/webviews/createPullRequestView/app.tsx +++ /dev/null @@ -1,212 +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 React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; -import { render } from 'react-dom'; -import { CreateParams, RemoteInfo } from '../../common/views'; -import PullRequestContext from '../common/createContext'; -import { ErrorBoundary } from '../common/errorBoundary'; -import { Label } from '../common/label'; -import { AutoMerge } from '../components/automergeSelect'; - -export const RemoteSelect = ({ onChange, defaultOption, repos }: - { onChange: (owner: string, repositoryName: string) => Promise<void>, defaultOption: string | undefined, repos: RemoteInfo[] }) => { - let caseCorrectedDefaultOption: string | undefined; - const options = repos.map(param => { - const value = param.owner + '/' + param.repositoryName; - const label = `${param.owner}/${param.repositoryName}`; - if (label.toLowerCase() === defaultOption) { - caseCorrectedDefaultOption = label; - } - return <option - key={value} - value={value}> - {label} - </option>; - }); - - return <ErrorBoundary> - <div className='wrapper flex'> - <div className='input-label combo-box'>remote</div><select title='Choose a remote' value={caseCorrectedDefaultOption ?? defaultOption} onChange={(e) => { - const [owner, repositoryName] = e.currentTarget.value.split('/'); - onChange(owner, repositoryName); - }}> - {options} - </select> - </div> - </ErrorBoundary>; -}; - -export const BranchSelect = ({ onChange, defaultOption, branches }: - { onChange: (branch: string) => void, defaultOption: string | undefined, branches: string[] }) => { - return <ErrorBoundary> - <div className='wrapper flex'> - <div className='input-label combo-box'>branch</div><select title='Choose a branch' value={defaultOption} onChange={(e) => onChange(e.currentTarget.value)}> - {branches.map(branchName => - <option - key={branchName} - value={branchName}> - {branchName} - </option> - )} - </select> - </div> - </ErrorBoundary>; -}; - -export function main() { - render( - <Root> - {(params: CreateParams) => { - const ctx = useContext(PullRequestContext); - const [isBusy, setBusy] = useState(false); - - const titleInput = useRef<HTMLInputElement>(); - - function updateBaseBranch(branch: string): void { - ctx.changeBaseBranch(branch); - ctx.updateState({ baseBranch: branch }); - } - - function updateCompareBranch(branch: string): void { - ctx.changeCompareBranch(branch); - ctx.updateState({ compareBranch: branch }); - } - - function updateTitle(title: string): void { - if (params.validate) { - ctx.updateState({ pendingTitle: title, showTitleValidationError: !title }); - } else { - ctx.updateState({ pendingTitle: title }); - } - } - - async function create(): Promise<void> { - setBusy(true); - const hasValidTitle = ctx.validate(); - if (!hasValidTitle) { - titleInput.current.focus(); - } else { - await ctx.submit(); - } - setBusy(false); - } - - const onKeyDown = useCallback( - e => { - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { - e.preventDefault(); - create(); - } - }, - [create], - ); - - if (!ctx.initialized) { - return <div className="loading-indicator">Loading...</div>; - } - - return <div> - <div className='selector-group'> - <span className='input-label'>Merge changes from</span> - <RemoteSelect onChange={ctx.changeCompareRemote} - defaultOption={`${params.compareRemote?.owner}/${params.compareRemote?.repositoryName}`} - repos={params.availableCompareRemotes} /> - - <BranchSelect onChange={updateCompareBranch} defaultOption={params.compareBranch} branches={params.branchesForCompare} /> - </div> - - <div className='selector-group'> - <span className='input-label'>into</span> - <RemoteSelect onChange={ctx.changeBaseRemote} - defaultOption={`${params.baseRemote?.owner}/${params.baseRemote?.repositoryName}`} - repos={params.availableBaseRemotes} /> - - <BranchSelect onChange={updateBaseBranch} defaultOption={params.baseBranch} branches={params.branchesForRemote} /> - </div> - - {params.labels && (params.labels.length > 0) ? - <div> - <label className='input-label'>Labels</label> - <div className='labels-list'> - {params.labels.map(label => <Label key={label.name} {...label} canDelete={false} isDarkTheme={!!params.isDarkTheme} />)} - </div> - </div> - : null} - - <div className='wrapper'> - <label className='input-label' htmlFor='title'>Title</label> - <input - id='title' - type='text' - ref={titleInput} - name='title' - className={params.showTitleValidationError ? 'input-error' : ''} - aria-invalid={!!params.showTitleValidationError} - aria-describedby={params.showTitleValidationError ? 'title-error' : ''} - placeholder='Pull Request Title' - value={params.pendingTitle} - required - onChange={(e) => updateTitle(e.currentTarget.value)} - onKeyDown={onKeyDown}> - </input> - <div id='title-error' className={params.showTitleValidationError ? 'validation-error below-input-error' : 'hidden'}>A title is required.</div> - </div> - - <div className='wrapper'> - <label className='input-label' htmlFor='description'>Description</label> - <textarea - id='description' - name='description' - placeholder='Pull Request Description' - value={params.pendingDescription} - required - onChange={(e) => ctx.updateState({ pendingDescription: e.currentTarget.value })} - onKeyDown={onKeyDown}></textarea> - </div> - - <div className={params.validate && !!params.createError ? 'wrapper validation-error' : 'hidden'} aria-live='assertive'> - <ErrorBoundary> - {params.createError} - </ErrorBoundary> - </div> - <AutoMerge {...params} updateState={ctx.updateState}></AutoMerge> - - <div className="wrapper flex"> - <input - id="draft-checkbox" - type="checkbox" - name="draft" - checked={params.isDraft} - disabled={params.autoMerge} - onChange={() => ctx.updateState({ isDraft: !params.isDraft })} - ></input> - <label htmlFor="draft-checkbox">Create as draft</label> - </div> - <div className="actions"> - <button disabled={isBusy} className="secondary" onClick={() => ctx.cancelCreate()}> - Cancel - </button> - <button disabled={isBusy} onClick={() => create()}> - Create - </button> - </div> - </div>; - }} - </Root>, - document.getElementById('app'), - ); -} - -export function Root({ children }) { - const ctx = useContext(PullRequestContext); - const [pr, setPR] = useState<any>(ctx.createParams); - useEffect(() => { - ctx.onchange = setPR; - setPR(ctx.createParams); - }, []); - ctx.postMessage({ command: 'ready' }); - return children(pr); -} diff --git a/webviews/createPullRequestView/index.css b/webviews/createPullRequestView/index.css deleted file mode 100644 index b6a0c66585..0000000000 --- a/webviews/createPullRequestView/index.css +++ /dev/null @@ -1,114 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -input, -textarea { - padding: 5px !important; - margin-top: 5px; -} - -select { - width: 100%; -} - -textarea { - height: 100px; - /* 16px = 10px top and bottom padding + 6px of "padding" around the font */ - min-height: calc(var(--vscode-font-size) + 16px); -} - -.actions { - display: flex; - justify-content: end; - gap: 8px; - margin-top: 10px; -} - -svg, -svg path { - fill: var(--vscode-foreground); -} - -.icon svg { - width: 16px; - height: 16px; -} - -.wrapper { - margin-top: 10px; -} - -.flex { - display: flex; - align-items: center; -} - -.wrapper span { - display: flex; - margin-right: 8px; -} - -body { - padding: 5px 20px; -} - -.validation-error { - padding: 5px; - border: 1px solid var(--vscode-inputValidation-errorBorder); - background-color: var(--vscode-inputValidation-errorBackground); -} - -.below-input-error { - border-top: none !important; -} - -.input-error { - border: 1px solid var(--vscode-inputValidation-errorBorder) !important; -} - -.input-label { - text-transform: uppercase; - font-size: 11px; -} - -.input-label.combo-box { - margin-right: 0px; - min-width: 43px; -} - -.selector-group { - padding: 8px; - border: 1px solid var(--vscode-panel-border); - border-radius: 4px; - margin-bottom: 10px; -} - -.selector-group .wrapper:last-of-type { - margin-bottom: 5px; -} - -.selector-group .wrapper { - padding-right: 5px; -} - -.automerge-section { - display: flex; - align-items: center; -} - -.automerge-section .merge-select-container { - padding-top: 4px; - padding-left: 4px; -} - -.automerge-checkbox-wrapper, -.automerge-checkbox-label { - display: flex; - align-items: center; -} - -.labels-list { - padding-top: 6px; -} diff --git a/webviews/createPullRequestViewNew/app.tsx b/webviews/createPullRequestViewNew/app.tsx new file mode 100644 index 0000000000..0cf9c32f50 --- /dev/null +++ b/webviews/createPullRequestViewNew/app.tsx @@ -0,0 +1,404 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { render } from 'react-dom'; +import { RemoteInfo } from '../../common/types'; +import { CreateParamsNew } from '../../common/views'; +import { isITeam, MergeMethod } from '../../src/github/interface'; +import { ChangeTemplateReply } from '../../src/github/views'; +import PullRequestContextNew from '../common/createContextNew'; +import { ErrorBoundary } from '../common/errorBoundary'; +import { LabelCreate } from '../common/label'; +import { ContextDropdown } from '../components/contextDropdown'; +import { accountIcon, feedbackIcon, gitCompareIcon, milestoneIcon, notebookTemplate, prMergeIcon, projectIcon, settingsIcon, sparkleIcon, stopCircleIcon, tagIcon } from '../components/icon'; +import { Avatar } from '../components/user'; + +type CreateMethod = 'create-draft' | 'create' | 'create-automerge-squash' | 'create-automerge-rebase' | 'create-automerge-merge'; + +export const ChooseRemoteAndBranch = ({ onClick, defaultRemote, defaultBranch, isBase, remoteCount = 0, disabled }: + { onClick: (remote?: RemoteInfo, branch?: string) => Promise<void>, defaultRemote: RemoteInfo | undefined, defaultBranch: string | undefined, isBase: boolean, remoteCount: number | undefined, disabled: boolean }) => { + + const defaultsLabel = (defaultRemote && defaultBranch) ? `${remoteCount > 1 ? `${defaultRemote.owner}/` : ''}${defaultBranch}` : '\u2014'; + const title = isBase ? 'Base branch: ' + defaultsLabel : 'Branch to merge: ' + defaultsLabel; + + return <ErrorBoundary> + <div className='flex'> + <button className='input-box' title={disabled ? '' : title} aria-label={title} disabled={disabled} onClick={() => { + onClick(defaultRemote, defaultBranch); + }}> + {defaultsLabel} + </button> + </div> + </ErrorBoundary>; +}; + +export function main() { + render( + <Root> + {(params: CreateParamsNew) => { + const ctx = useContext(PullRequestContextNew); + const [isBusy, setBusy] = useState(params.creating); + const [isGeneratingTitle, setGeneratingTitle] = useState(false); + function createMethodLabel(isDraft?: boolean, autoMerge?: boolean, autoMergeMethod?: MergeMethod, baseHasMergeQueue?: boolean): { value: CreateMethod, label: string } { + let value: CreateMethod; + let label: string; + if (autoMerge && baseHasMergeQueue) { + value = 'create-automerge-merge'; + label = 'Create + Merge When Ready'; + } else if (autoMerge && autoMergeMethod) { + value = `create-automerge-${autoMergeMethod}` as CreateMethod; + const mergeMethodLabel = autoMergeMethod.charAt(0).toUpperCase() + autoMergeMethod.slice(1); + label = `Create + Auto-${mergeMethodLabel}`; + } else if (isDraft) { + value = 'create-draft'; + label = 'Create Draft'; + } else { + value = 'create'; + label = 'Create'; + } + + return { value, label }; + } + + const titleInput = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>; + + function updateTitle(title: string): void { + if (params.validate) { + ctx.updateState({ pendingTitle: title, showTitleValidationError: !title }); + } else { + ctx.updateState({ pendingTitle: title }); + } + } + + useEffect(() => { + if (ctx.initialized) { + titleInput.current?.focus(); + } + }, [ctx.initialized]); + + async function create(): Promise<void> { + setBusy(true); + const hasValidTitle = ctx.validate(); + if (!hasValidTitle) { + titleInput.current?.focus(); + } else { + await ctx.submit(); + } + setBusy(false); + } + + const onKeyDown = useCallback((isTitle: boolean, e) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + create(); + } else if ((e.metaKey || e.ctrlKey) && e.key === 'z') { + if (isTitle) { + ctx.popTitle(); + } else { + ctx.popDescription(); + } + } + }, + [create], + ); + + const onCreateButton: React.MouseEventHandler<HTMLButtonElement> = (event) => { + const selected = (event.target as HTMLButtonElement).value as CreateMethod; + let isDraft = false; + let autoMerge = false; + let autoMergeMethod: MergeMethod | undefined; + switch (selected) { + case 'create-draft': + isDraft = true; + autoMerge = false; + break; + case 'create-automerge-squash': + isDraft = false; + autoMerge = true; + autoMergeMethod = 'squash'; + break; + case 'create-automerge-rebase': + isDraft = false; + autoMerge = true; + autoMergeMethod = 'rebase'; + break; + case 'create-automerge-merge': + isDraft = false; + autoMerge = true; + autoMergeMethod = 'merge'; + break; + } + ctx.updateState({ isDraft, autoMerge, autoMergeMethod }); + return create(); + }; + + function makeCreateMenuContext(createParams: CreateParamsNew) { + const createMenuContexts = { + 'preventDefaultContextMenuItems': true, + 'github:createPrMenu': true, + 'github:createPrMenuDraft': true + }; + if (createParams.baseHasMergeQueue) { + createMenuContexts['github:createPrMenuMergeWhenReady'] = true; + } else { + if (createParams.allowAutoMerge && createParams.mergeMethodsAvailability && createParams.mergeMethodsAvailability['merge']) { + createMenuContexts['github:createPrMenuMerge'] = true; + } + if (createParams.allowAutoMerge && createParams.mergeMethodsAvailability && createParams.mergeMethodsAvailability['squash']) { + createMenuContexts['github:createPrMenuSquash'] = true; + } + if (createParams.allowAutoMerge && createParams.mergeMethodsAvailability && createParams.mergeMethodsAvailability['rebase']) { + createMenuContexts['github:createPrMenuRebase'] = true; + } + } + const stringified = JSON.stringify(createMenuContexts); + return stringified; + } + + if (params.creating) { + create(); + } + + function activateCommand(event: MouseEvent | KeyboardEvent, command: string): void { + if (event instanceof KeyboardEvent) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + ctx.postMessage({ command: command }); + } + } else if (event instanceof MouseEvent) { + ctx.postMessage({ command: command }); + } + } + + function openDescriptionSettings(_event: React.MouseEvent | React.KeyboardEvent): void { + ctx.postMessage({ command: 'pr.openDescriptionSettings' }); + } + + async function generateTitle(useCopilot?: boolean) { + setGeneratingTitle(true); + await ctx.generateTitle(!!useCopilot); + setGeneratingTitle(false); + } + + async function changeTemplate() { + const result: ChangeTemplateReply = await ctx.postMessage({ command: 'pr.changeTemplate' }); + if (result && result.description) { + ctx.updateState({ pendingDescription: result.description }); + } + } + + + if (!ctx.initialized) { + ctx.initialize(); + } + + if (ctx.createParams.initializeWithGeneratedTitleAndDescription) { + ctx.createParams.initializeWithGeneratedTitleAndDescription = false; + generateTitle(true); + } + + return <div className='group-main' data-vscode-context='{"preventDefaultContextMenuItems": true}'> + <div className='group-branches'> + <div className='input-label base'> + <div className="deco"> + <span title='Base branch' aria-hidden='true'>{gitCompareIcon} Base</span> + </div> + <ChooseRemoteAndBranch onClick={ctx.changeBaseRemoteAndBranch} + defaultRemote={params.baseRemote} + defaultBranch={params.baseBranch} + remoteCount={params.remoteCount} + isBase={true} + disabled={!ctx.initialized || isBusy || !ctx.createParams.canModifyBranches} /> + </div> + + + <div className='input-label merge'> + <div className="deco"> + <span title='Merge branch' aria-hidden='true'>{prMergeIcon} {params.actionDetail ? params.actionDetail : 'Merge'}</span> + </div> + {ctx.createParams.canModifyBranches ? + <ChooseRemoteAndBranch onClick={ctx.changeMergeRemoteAndBranch} + defaultRemote={params.compareRemote} + defaultBranch={params.compareBranch} + remoteCount={params.remoteCount} + isBase={false} + disabled={!ctx.initialized || isBusy} /> + : params.associatedExistingPullRequest ? + <a className='pr-link' onClick={() => ctx.openAssociatedPullRequest()}>#{params.associatedExistingPullRequest}</a> + : null} + </div> + </div> + + <label htmlFor='title' className='input-title'>Title</label> + <div className='group-title'> + <input + id='title' + type='text' + ref={titleInput} + name='title' + value={params.pendingTitle ?? ''} + className={params.showTitleValidationError ? 'input-error' : ''} + aria-invalid={!!params.showTitleValidationError} + aria-describedby={params.showTitleValidationError ? 'title-error' : ''} + placeholder='Title' + title='Required' + required + onChange={(e) => updateTitle(e.currentTarget.value)} + onKeyDown={(e) => onKeyDown(true, e)} + data-vscode-context='{"preventDefaultContextMenuItems": false}' + disabled={!ctx.initialized || isBusy || isGeneratingTitle || params.reviewing}> + </input> + {ctx.createParams.generateTitleAndDescriptionTitle ? + isGeneratingTitle ? + <a title='Cancel' className={`title-action icon-button${isBusy || !ctx.initialized ? ' disabled' : ''}`} onClick={ctx.cancelGenerateTitle} tabIndex={0}>{stopCircleIcon}</a> + : <a title={ctx.createParams.generateTitleAndDescriptionTitle} className={`title-action icon-button${isBusy || !ctx.initialized ? ' disabled' : ''}`} onClick={() => generateTitle()} tabIndex={0}>{sparkleIcon}</a> : null} + <div id='title-error' className={params.showTitleValidationError ? 'validation-error below-input-error' : 'hidden'}>A title is required</div> + </div> + + <div className='group-additions'> + {params.assignees && (params.assignees.length > 0) ? + <div className='assignees'> + <span title='Assignees' aria-hidden='true'>{accountIcon}</span> + <ul aria-label='Assignees' tabIndex={0} role='button' + onClick={(e) => activateCommand(e.nativeEvent, 'pr.changeAssignees')} + onKeyPress={(e) => activateCommand(e.nativeEvent, 'pr.changeAssignees')} + > + {params.assignees.map(assignee => + <li> + <span title={assignee.name} aria-label={assignee.name}> + <Avatar for={assignee} link={false} /> + {assignee.specialDisplayName ?? assignee.login} + </span> + </li>)} + </ul> + </div> + : null} + + {params.reviewers && (params.reviewers.length > 0) ? + <div className='reviewers'> + <span title='Reviewers' aria-hidden='true'>{feedbackIcon}</span> + <ul aria-label='Reviewers' tabIndex={0} role='button' + onClick={(e) => activateCommand(e.nativeEvent, 'pr.changeReviewers')} + onKeyPress={(e) => activateCommand(e.nativeEvent, 'pr.changeReviewers')} + > + {params.reviewers.map(reviewer => + <li> + <span title={reviewer.name} aria-label={reviewer.name}> + <Avatar for={reviewer} link={false} /> + {isITeam(reviewer) ? reviewer.slug : (reviewer.specialDisplayName ?? reviewer.login)} + </span> + </li>)} + </ul> + </div> + : null} + + {params.labels && (params.labels.length > 0) ? + <div className='labels'> + <span title='Labels' aria-hidden='true'>{tagIcon}</span> + <ul aria-label='Labels' tabIndex={0} role='button' + onClick={(e) => activateCommand(e.nativeEvent, 'pr.changeLabels')} + onKeyPress={(e) => activateCommand(e.nativeEvent, 'pr.changeLabels')} + > + {params.labels.map(label => <LabelCreate key={label.name} {...label} canDelete isDarkTheme={!!params.isDarkTheme} />)} + </ul> + </div> + : null} + + {params.milestone ? + <div className='milestone'> + <span title='Milestone' aria-hidden='true'>{milestoneIcon}</span> + <ul aria-label='Milestone' tabIndex={0} role='button' + onClick={(e) => activateCommand(e.nativeEvent, 'pr.changeMilestone')} + onKeyPress={(e) => activateCommand(e.nativeEvent, 'pr.changeMilestone')} + > + <li> + {params.milestone.title} + </li> + </ul> + </div> + : null} + + {params.projects && (params.projects.length > 0) ? + <div className='projects'> + <span title='Projects' aria-hidden='true'>{projectIcon}</span> + <ul aria-label='Project' tabIndex={0} role='button' + onClick={(e) => activateCommand(e.nativeEvent, 'pr.changeProjects')} + onKeyPress={(e) => activateCommand(e.nativeEvent, 'pr.changeProjects')} + > + {params.projects.map((project, index) => + <li> + <span key={project.id}>{index > 0 ? <span className='sep'>•</span> : null}{project.title}</span> + </li>)} + </ul> + </div> + : null} + </div> + + <div className='description-title'> + <label htmlFor='description' className='input-title'>Description</label> + <div className='description-actions'> + {ctx.createParams.usingTemplate ? + <a title='Change template' className={`title-action icon-button${isBusy || !ctx.initialized ? ' disabled' : ''}`} onClick={() => changeTemplate()} tabIndex={0}>{notebookTemplate}</a> : null} + <a role='button' title='Open pull request description settings' aria-label='Open pull request description settings' className='icon-button' onClick={openDescriptionSettings} tabIndex={0}>{settingsIcon}</a> + </div> + </div> + <div className='group-description'> + <textarea + id='description' + name='description' + placeholder='Description' + value={params.pendingDescription} + onChange={(e) => ctx.updateState({ pendingDescription: e.currentTarget.value })} + onKeyDown={(e) => onKeyDown(false, e)} + data-vscode-context='{"preventDefaultContextMenuItems": false}' + disabled={!ctx.initialized || isBusy || isGeneratingTitle || params.reviewing}></textarea> + </div> + + <div className={params.validate && !!params.createError ? 'wrapper validation-error' : 'hidden'} aria-live='assertive'> + <ErrorBoundary> + {params.createError} + </ErrorBoundary> + </div> + <div className={(!!params.warning && !params.creating && !isBusy) ? 'wrapper validation-warning' : 'hidden'} aria-live='assertive'> + <ErrorBoundary> + {params.warning} + </ErrorBoundary> + </div> + + <div className='group-actions'> + <button disabled={isBusy} className='secondary' onClick={() => ctx.cancelCreate()}> + Cancel + </button> + + <ContextDropdown optionsContext={() => makeCreateMenuContext(params)} + defaultAction={onCreateButton} + defaultOptionLabel={() => createMethodLabel(ctx.createParams.isDraft, ctx.createParams.autoMerge, ctx.createParams.autoMergeMethod, ctx.createParams.baseHasMergeQueue).label} + defaultOptionValue={() => createMethodLabel(ctx.createParams.isDraft, ctx.createParams.autoMerge, ctx.createParams.autoMergeMethod, ctx.createParams.baseHasMergeQueue).value} + optionsTitle='Create with Option' + disabled={isBusy || isGeneratingTitle || params.reviewing || !ctx.isCreatable || !ctx.initialized} + spreadable={true} + /> + + </div> + </div>; + }} + </Root>, + document.getElementById('app'), + ); +} + +interface RootProps { children: (params: CreateParamsNew) => JSX.Element } + +export function Root({ children }: RootProps): JSX.Element { + const ctx = useContext(PullRequestContextNew); + const [pr, setPR] = useState<any>(ctx.createParams); + useEffect(() => { + ctx.onchange = setPR; + setPR(ctx.createParams); + }, []); + ctx.postMessage({ command: 'ready' }); + return <>{children(pr)}</>; +} diff --git a/webviews/createPullRequestViewNew/index.css b/webviews/createPullRequestViewNew/index.css new file mode 100644 index 0000000000..4a598d4564 --- /dev/null +++ b/webviews/createPullRequestViewNew/index.css @@ -0,0 +1,329 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +body { + padding: 0 20px; +} + +input:disabled, +textarea:disabled { + opacity: 0.4; +} + +.group-main { + height: 100vh; + min-width: 160px; + display: flex; + flex-direction: column; + align-items: stretch; +} + +.icon svg { + width: 16px; + height: 16px; +} + + +/* Base + Merge Branches */ + +.group-branches { + margin-top: 20px; + margin-bottom: 2px; +} + +.input-label.base, +.input-label.merge { + display: block; +} + +.pr-link { + cursor: pointer; +} + +.base .deco, +.merge .deco { + display: block; + float: left; + margin-right: 8px; + user-select: none; +} + +button.input-box { + width: 100%; + float: left; +} + +.merge { + padding-left: 28px; +} + +.merge .icon svg { + margin-top: -16px; +} + +.flex { + display: flex; + align-items: center; +} + +.input-label { + display: flex; + align-items: center; + font-size: 11px; + text-transform: uppercase; + margin-bottom: 14px; +} + +.input-label .icon { + display: block; + float: left; + margin-right: 6px; +} + + +/* Title, Description */ + +.input-title { + display: block; + font-size: 11px; + margin-bottom: 4px; + text-transform: uppercase; +} + +#title { + padding-right: 24px; + text-overflow: ellipsis; +} + +.description-title { + display: flex; + justify-content: space-between; + align-items: center; +} + +.description-actions { + align-items: end; + display: flex; + gap: 4px; +} + +.description-actions .icon-button { + margin-bottom: 2px; + margin-top: -2px; +} + +.group-title { + position: relative; + display: flex; + flex-direction: column; + margin-bottom: 12px; +} + +.group-title .title-action:hover { + outline-style: none; + cursor: pointer; + background-color: var(--vscode-toolbar-hoverBackground); +} + +.group-title .title-action:focus { + outline-style: none; +} + +.group-title .title-action:focus-visible { + outline-width: 1px; + outline-style: solid; + outline-offset: -1px; + outline-color: var(--vscode-focusBorder); + background: unset; +} + +.group-title .title-action.disabled { + cursor: default; + background-color: unset; +} + +.group-title .title-action svg { + padding: 2px; +} + +.group-title .disabled svg path { + fill: var(--vscode-disabledForeground); +} + +.group-title .title-action { + position: absolute; + top: 6px; + right: 5px; + background: unset; + padding: unset; + margin: unset; + height: 20px; + margin-top: -2px; +} + +.group-description { + flex-grow: 1; + max-height: 500px; +} + +input[type=text], +textarea { + padding: 5px; +} + +textarea { + height: 100%; + min-height: 96px; + resize: none; +} + +.validation-error { + padding: 3px 5px; + border: 1px solid var(--vscode-inputValidation-errorBorder); + background-color: var(--vscode-inputValidation-errorBackground); + color: var(--vscode-inputValidation-errorForeground); + border-radius: 4px; + margin-top: 4px +} + +.validation-warning { + padding: 3px 5px; + border: 1px solid var(--vscode-inputValidation-warningBorder); + background-color: var(--vscode-inputValidation-warningBackground); + color: var(--vscode-inputValidation-warningForeground); + border-radius: 4px; + margin-top: 4px +} + +.below-input-error { + border-top: none !important; +} + +.input-error { + border: 1px solid var(--vscode-inputValidation-errorBorder) !important; +} + + +/* Assignees, Reviewers, Labels, Milestone */ + +.group-additions { + display: block; +} + +.group-additions div { + display: block; + position: relative; + float: left; + width: 100%; + box-sizing: border-box; + padding: 0 1px; + border-bottom: 1px solid var(--vscode-menu-separatorBackground); +} + +.group-additions div:first-child { + margin-top: 8px; +} + +.group-additions div:last-child { + border: none; + margin-bottom: 4px; +} + +.group-additions .icon { + display: block; + position: absolute; + z-index: -1; + top: 9px; + left: 9px; + width: 16px; + height: 16px; +} + +.group-additions img.avatar, +.group-additions img.avatar-icon { + margin-right: 4px; + width: 16px; + height: 16px; +} + +.group-additions ul { + display: flex; + align-content: flex-start; + flex-wrap: wrap; + gap: 4px; + list-style-type: none; + cursor: pointer; + user-select: none; + font-size: smaller; + margin: 0; + margin-top: 0; + padding: 0; + padding-top: 8px; + padding-bottom: 8px; + padding-left: 32px; + padding-right: 8px; + border-radius: 2px; +} + +.group-additions ul:focus { + outline: var(--vscode-focusBorder) solid 1px; +} + +.group-additions ul li { + padding: 2px; +} + +.group-additions ul li .sep { + padding-right: 7px; +} + +.labels ul li { + border: 1px solid var(--vscode-menu-separatorBackground); + border-radius: 2px; + padding: 2px 4px; +} + + +/* Actions */ + +.group-actions { + display: flex; + gap: 8px; + padding-top: 10px; + padding-bottom: 20px; + width: 100%; +} + +.dropdown-container { + justify-content: right; +} + +/* Auto review */ +.pre-review { + display: flex; + flex-direction: column; +} + +.auto-review { + display: flex; + justify-content: right; + cursor: pointer; +} + +.auto-review.disabled:hover, +.auto-review.disabled { + cursor: default; + color: var(--vscode-disabledForeground); + text-decoration: none; +} + +.pre-review svg path { + fill: currentColor; +} + +button.split-left { + display: block; +} \ No newline at end of file diff --git a/webviews/createPullRequestView/index.ts b/webviews/createPullRequestViewNew/index.ts similarity index 100% rename from webviews/createPullRequestView/index.ts rename to webviews/createPullRequestViewNew/index.ts diff --git a/webviews/editorWebview/app.tsx b/webviews/editorWebview/app.tsx index 6024799a60..3a3b7691d4 100644 --- a/webviews/editorWebview/app.tsx +++ b/webviews/editorWebview/app.tsx @@ -3,26 +3,55 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as debounce from 'debounce'; import React, { useContext, useEffect, useState } from 'react'; import { render } from 'react-dom'; -import { PullRequest } from '../common/cache'; -import PullRequestContext from '../common/context'; import { Overview } from './overview'; +import { PullRequest } from '../../src/github/views'; +import { COMMENT_TEXTAREA_ID } from '../common/constants'; +import PullRequestContext from '../common/context'; export function main() { - window.addEventListener('contextmenu', e => { - e.stopImmediatePropagation(); - }, true); render(<Root>{pr => <Overview {...pr} />}</Root>, document.getElementById('app')); } export function Root({ children }) { const ctx = useContext(PullRequestContext); - const [pr, setPR] = useState<PullRequest>(ctx.pr); + const [pr, setPR] = useState<PullRequest | undefined>(ctx.pr); useEffect(() => { ctx.onchange = setPR; setPR(ctx.pr); }, []); + + // Restore focus to comment textarea when window regains focus if user was typing + useEffect(() => { + const handleWindowFocus = () => { + // Delay to let the focus event settle before checking focus state + const FOCUS_SETTLE_DELAY_MS = 100; + setTimeout(() => { + const commentTextarea = document.getElementById(COMMENT_TEXTAREA_ID) as HTMLTextAreaElement; + // Only restore focus if there's content and nothing else has focus + if (commentTextarea && commentTextarea.value && document.activeElement === document.body) { + commentTextarea.focus(); + } + }, FOCUS_SETTLE_DELAY_MS); + }; + + window.addEventListener('focus', handleWindowFocus); + return () => window.removeEventListener('focus', handleWindowFocus); + }, []); + + window.onscroll = debounce(() => { + ctx.postMessage({ + command: 'scroll', + args: { + scrollPosition: { + x: window.scrollX, + y: window.scrollY + } + } + }); + }, 200); ctx.postMessage({ command: 'ready' }); ctx.postMessage({ command: 'pr.debug', args: 'initialized ' + (pr ? 'with PR' : 'without PR') }); return pr ? children(pr) : <div className="loading-indicator">Loading...</div>; diff --git a/webviews/editorWebview/index.css b/webviews/editorWebview/index.css index de690798fa..a1963b0a24 100644 --- a/webviews/editorWebview/index.css +++ b/webviews/editorWebview/index.css @@ -31,6 +31,10 @@ grid-row: 2; } +#project a { + cursor: pointer; +} + a:focus, input:focus, select:focus, @@ -46,9 +50,14 @@ textarea:focus, .title { display: flex; align-items: flex-start; - margin: 20px 0; + margin: 20px 0 24px; padding-bottom: 24px; border-bottom: 1px solid var(--vscode-list-inactiveSelectionBackground); + background: var(--vscode-editor-background); +} + +.title .details { + flex: 1; } .title .pr-number { @@ -62,16 +71,44 @@ textarea:focus, transform: translate(-50%, -50%); } +.loading-button { + display: inline-flex; + align-items: center; + margin-right: 4px; +} + .comment-body li div { display: inline; } +.comment-body li div.Box, +.comment-body li div.Box div { + display: block; +} + .comment-body code, .comment-body a, span.lineContent { overflow-wrap: anywhere; } +.comment-reactions { + display: flex; + flex-direction: row; +} + +.comment-reactions div { + font-size: 1.1em; + cursor: pointer; + user-select: none; +} + +.comment-reactions .reaction-label { + border-radius: 5px; + border: 1px solid var(--vscode-panel-border); + width: 14px; +} + #title:empty { border: none; } @@ -115,33 +152,18 @@ body .comment-container.review { background-color: var(--vscode-editor-background); } -.icon-button { - display: flex; - padding: 2px; - background: transparent; - border-radius: 4px; - line-height: 0; -} - -.icon-button:hover, -.section .icon-button:hover, -.section .icon-button:focus { - background-color: var(--vscode-toolbar-hoverBackground); -} - -.icon-button:focus, -.section .icon-button:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: unset; -} - .review-comment-container { width: 100%; + max-width: 1000px; display: flex; flex-direction: column; position: relative; } +body #main .comment-container>.review-comment-container>.review-comment-header:not(:nth-last-child(2)) { + border-bottom: 1px solid var(--vscode-editorHoverWidget-border); +} + body .comment-container .review-comment-header { position: relative; display: flex; @@ -150,10 +172,17 @@ body .comment-container .review-comment-header { padding: 8px 16px; color: var(--vscode-foreground); align-items: center; - background: var(--vscode-editorWidget-background); - border-bottom: 1px solid var(--vscode-editorHoverWidget-border); - border-top-left-radius: 3px; - border-top-right-radius: 3px; + background: var(--vscode-panel-background); + border-top-left-radius: 6px; + border-top-right-radius: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-comment-header.no-details { + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; } .description-header { @@ -182,6 +211,16 @@ body .comment-container .review-comment-header { height: 14px; } +.comment-actions .icon-button { + padding-left: 2px; + padding-top: 2px; +} + +.status-scroll { + max-height: 220px; + overflow-y: auto; +} + .status-check { display: flex; align-items: center; @@ -196,6 +235,10 @@ body .comment-container .review-comment-header { gap: 8px; } +.status-check > div:not(.status-check-details) { + display: flex; +} + #merge-on-github { margin-top: 10px; } @@ -206,9 +249,9 @@ body .comment-container .review-comment-header { } .status-item:first-of-type { - background: var(--vscode-editorWidget-background); - border-top-left-radius: 3px; - border-top-right-radius: 3px; + background: var(--vscode-panel-background); + border-top-left-radius: 6px; + border-top-right-radius: 6px; } .status-item, @@ -219,6 +262,30 @@ body .comment-container .review-comment-header { align-items: center; } +.status-item .button-container { + margin-left: auto; + margin-right: 0; +} + +.commit-association { + display: flex; + font-style: italic; + flex-direction: row-reverse; + padding-top: 7px; +} + +.commit-association span { + flex-direction: row; +} + +.email { + font-weight: bold; +} + +button.input-box { + float: right; +} + .status-item-detail-text { display: flex; gap: 8px; @@ -232,16 +299,36 @@ body .comment-container .review-comment-header { margin: 0; } +.status-section .check svg path { + fill: var(--vscode-issues-open); +} + +.status-section .close svg path { + fill: var(--vscode-errorForeground); +} + +.status-section .pending svg path, +.status-section .skip svg path { + fill: var(--vscode-list-warningForeground); +} + +.merge-queue-container, .ready-for-review-container { padding: 16px; - background-color: var(--vscode-editorWidget-background); - border-bottom-left-radius: 3px; - border-bottom-right-radius: 3px; + background-color: var(--vscode-panel-background); + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; display: flex; justify-content: space-between; align-items: center; } +.ready-for-review-container .button-container { + flex-direction: row; + display: flex; + align-items: center; +} + .ready-for-review-icon { width: 16px; height: 16px; @@ -257,7 +344,12 @@ body .comment-container .review-comment-header { #status-checks { border: 1px solid var(--vscode-editorHoverWidget-border); - border-radius: 4px; + border-radius: 6px; +} + +#status-checks .label { + display: inline-flex; + margin-right: 16px; } #status-checks a { @@ -286,16 +378,16 @@ body .comment-container .review-comment-header { #status-checks .merge-select-container { display: flex; align-items: center; - background-color: var(--vscode-editorWidget-background); - border-bottom-left-radius: 3px; - border-bottom-right-radius: 3px; + background-color: var(--vscode-panel-background); + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; } -#status-checks .merge-select-container > * { +#status-checks .merge-select-container>* { margin-right: 5px; } -#status-checks .merge-select-container > select { +#status-checks .merge-select-container>select { margin-left: 5px; } @@ -309,13 +401,16 @@ body .comment-container .review-comment-header { padding: 16px; } -body .comment-container .review-comment-header > span, -body .comment-container .review-comment-header > a, -body .commit .commit-message > a, -body .merged .merged-message > a { +body .comment-container .review-comment-header>span, +body .comment-container .review-comment-header>a, +body .merged .merged-message>a { margin-right: 6px; } +body .commit .commit-message>a { + margin-right: 3px; +} + body .comment-container .review-comment-container .pending-label, body .resolved-container .outdatedLabel { background: var(--vscode-badge-background); @@ -336,8 +431,10 @@ body .diff .diffPath { margin-right: 4px; } -.comment-container form, #merge-comment-form { +.comment-container form, +#merge-comment-form { padding: 16px; + background-color: var(--vscode-panel-background); } body .comment-container .comment-body, @@ -353,16 +450,17 @@ body .comment-container .review-comment-container .review-comment-body { border: none; } -body .comment-container .comment-body > p, -body .comment-container .comment-body > div > p, -.comment-container .review-body > p { +body .comment-container .comment-body>p, +body .comment-container .comment-body>div>p, +body .comment-container .comment-body>div>ul, +.comment-container .review-body>p { margin-top: 0; line-height: 1.5em; } -body .comment-container .comment-body > p:last-child, -body .comment-container .comment-body > div > p:last-child, -.comment-container .review-body > p:last-child { +body .comment-container .comment-body>p:last-child, +body .comment-container .comment-body>div>p:last-child, +.comment-container .review-body>p:last-child { margin-bottom: 0; } @@ -402,8 +500,14 @@ body button .icon { margin-top: 18px; } +.overview-title { + display: flex; + align-items: center; +} + .overview-title h2 { font-size: 32px; + margin-right: 6px; } .overview-title textarea { @@ -439,7 +543,8 @@ body button .icon { } .branch-tag { - padding: 2px 4px; + margin-top: 0; + padding: 4px; background: var(--vscode-editorInlayHint-background); color: var(--vscode-editorInlayHint-foreground); border-radius: 4px; @@ -453,15 +558,48 @@ body button .icon { .button-group { display: flex; gap: 8px; + flex-wrap: wrap; + align-items: flex-start; } -.small-button { +small-button { display: flex; font-size: 11px; - font-weight: 600; padding: 0 5px; } +.header-actions { + display: flex; + gap: 8px; + padding-top: 4px; + align-items: center; +} + +.header-actions>div:first-of-type { + flex: 1; +} + +button.secondary.change-base { + background-color: transparent; + padding: unset; + border-radius: 4px; + margin-top: 2px; +} + +.change-base code { + display: flex; + align-items: center; + padding: 2px 4px; +} + +.change-base .icon { + margin-left: 4px; +} + +:not(.status-item)>.small-button { + font-weight: 600; +} + #status { box-sizing: border-box; line-height: 18px; @@ -505,11 +643,11 @@ body button .icon { } .section { - padding-bottom: 24px; + padding-bottom: 16px; border-bottom: 1px solid var(--vscode-editorWidget-border); display: flex; flex-direction: column; - gap: 12px; + gap: 8px; } .section:last-of-type { @@ -521,6 +659,9 @@ body button .icon { display: flex; justify-content: space-between; align-items: center; +} + +.section-header.clickable { cursor: pointer; } @@ -543,9 +684,18 @@ body button .icon { margin-right: 0; } -.label .icon-button:hover, -.label .icon-button:focus { - background-color: transparent; +.section .icon-button, +.section .icon-button .icon { + color: currentColor; +} + +.icon-button-group { + display: flex; + flex-direction: row; +} + +.section svg path { + fill: currentColor; } .commit svg { @@ -580,19 +730,36 @@ body button .icon { } .commit .commit-message, +.commit .timeline-with-detail, .merged .merged-message { - display: flex; align-items: center; overflow: hidden; flex-grow: 1; } +.commit .commit-message, +.merged .merged-message { + display: flex; +} + +.commit .timeline-with-detail { + display: block; +} + +.commit-message-detail { + margin-left: 20px; +} + .commit .commit-message .avatar-container, .merged .merged-message .avatar-container { margin-right: 4px; flex-shrink: 0; } +.commit-message .icon { + padding-top: 2px; +} + .commit .avatar-container .avatar, .commit .avatar-container .avatar-icon, .commit .avatar-container .avatar-icon svg, @@ -614,7 +781,11 @@ body button .icon { white-space: nowrap; } -.sha-with-timestamp { +.commit .commit-message a.message { + cursor: pointer; +} + +.timeline-detail { display: flex; align-items: center; gap: 8px; @@ -624,6 +795,7 @@ body button .icon { min-width: 50px; font-family: var(--vscode-editor-font-family); margin-bottom: -2px; + cursor: pointer; } .merged .merged-message .message, @@ -655,7 +827,7 @@ body button .icon { display: flex; margin: 0; align-items: center; - border-radius: 4px; + border-radius: 6px; border: 1px solid var(--vscode-editorHoverWidget-border); } @@ -664,7 +836,7 @@ body button .icon { border: none; } -.comment-container[data-type='commit'] + .comment-container[data-type='commit'] { +.comment-container[data-type='commit']+.comment-container[data-type='commit'] { border-top: none; } @@ -679,8 +851,8 @@ body button .icon { padding: 16px; background-color: var(--vscode-editorHoverWidget-background); border-top: 1px solid var(--vscode-editorHoverWidget-border); - border-bottom-left-radius: 3px; - border-bottom-right-radius: 3px; + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; } .review-comment-container .review-comment .review-comment-header { @@ -727,10 +899,56 @@ textarea { max-height: 500px; } +.textarea-wrapper { + position: relative; + display: flex; + width: 100%; +} + +.textarea-wrapper textarea { + flex: 1; + padding-right: 40px; +} + +.textarea-wrapper .title-action { + position: absolute; + top: 6px; + right: 5px; + border: none; + background: none; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.textarea-wrapper .title-action:hover { + outline-style: none; + cursor: pointer; + background-color: var(--vscode-toolbar-hoverBackground); +} + +.textarea-wrapper .title-action:focus { + outline-style: none; +} + +.textarea-wrapper .title-action:focus-visible { + outline-width: 1px; + outline-style: solid; + outline-offset: -1px; + outline-color: var(--vscode-focusBorder); + background: unset; +} + +.textarea-wrapper .title-action svg { + padding: 2px; +} + .editing-form { padding: 5px 0; display: flex; - flex-direction: column; + flex-direction: row; min-width: 300px; } @@ -740,19 +958,30 @@ textarea { justify-content: flex-end; } -.comment-form .form-actions > button, -.comment-form .form-actions > input[type='submit'] { +.comment-form .form-actions>button, +.comment-form .form-actions>input[type='submit'] { margin-right: 0; margin-left: 0; } -.form-actions { - display: flex; +.primary-split-button { + flex-grow: unset; +} + +:not(.button-group) .dropdown-container { + justify-content: right; +} + +:not(.title-editing-form)>.form-actions { justify-content: flex-end; padding-top: 10px; } -.main-comment-form > .form-actions { +#rebase-actions { + flex-direction: row-reverse; +} + +.main-comment-form>.form-actions { margin-bottom: 10px; } @@ -821,24 +1050,24 @@ h3 { border-collapse: collapse; } -.comment-body table > thead > tr > th { +.comment-body table>thead>tr>th { text-align: left; border-bottom: 1px solid; } -.comment-body table > thead > tr > th, -.comment-body table > thead > tr > td, -.comment-body table > tbody > tr > th, -.comment-body table > tbody > tr > td { +.comment-body table>thead>tr>th, +.comment-body table>thead>tr>td, +.comment-body table>tbody>tr>th, +.comment-body table>tbody>tr>td { padding: 5px 10px; } -.comment-body table > tbody > tr + tr > td { +.comment-body table>tbody>tr+tr>td { border-top: 1px solid; } code { - font-family: Menlo, Monaco, Consolas, 'Droid Sans Mono', 'Courier New', monospace, 'Droid Sans Fallback'; + font-family: var(--vscode-editor-font-family), Menlo, Monaco, Consolas, 'Droid Sans Mono', 'Courier New', monospace, 'Droid Sans Fallback'; } .comment-body .snippet-clipboard-content { @@ -873,18 +1102,24 @@ code { } .comment-body pre:not(.hljs), -.comment-body pre.hljs code > div { +.comment-body pre.hljs code>div { padding: 16px; - border-radius: 3px; + border-radius: 6px; overflow: auto; } .timestamp, .timestamp:hover { - color: inherit; + color: var(--vscode-descriptionForeground); white-space: nowrap; } +.timestamp { + overflow: hidden; + text-overflow: ellipsis; + padding-left: 8px; +} + /** Theming */ .comment-body pre code { @@ -892,12 +1127,12 @@ code { } .vscode-light .comment-body pre:not(.hljs), -.vscode-light .comment-body code > div { +.vscode-light .comment-body code>div { background-color: rgba(220, 220, 220, 0.4); } .vscode-dark .comment-body pre:not(.hljs), -.vscode-dark .comment-body code > div { +.vscode-dark .comment-body code>div { background-color: rgba(10, 10, 10, 0.4); } @@ -922,23 +1157,23 @@ code { border: 1px solid var(--vscode-panel-border); } -.vscode-light .comment-body table > thead > tr > th { +.vscode-light .comment-body table>thead>tr>th { border-color: rgba(0, 0, 0, 0.69); } -.vscode-dark .comment-body table > thead > tr > th { +.vscode-dark .comment-body table>thead>tr>th { border-color: rgba(255, 255, 255, 0.69); } .vscode-light .comment-body h1, .vscode-light .comment-body hr, -.vscode-light .comment-body table > tbody > tr + tr > td { +.vscode-light .comment-body table>tbody>tr+tr>td { border-color: rgba(0, 0, 0, 0.18); } .vscode-dark .comment-body h1, .vscode-dark .comment-body hr, -.vscode-dark .comment-body table > tbody > tr + tr > td { +.vscode-dark .comment-body table>tbody>tr+tr>td { border-color: rgba(255, 255, 255, 0.18); } @@ -966,6 +1201,7 @@ code { .review-comment-body .diff-container .diff { border-top: 1px solid var(--vscode-editorWidget-border); + overflow: scroll; } .resolved-container { @@ -973,9 +1209,9 @@ code { display: flex; align-items: center; justify-content: space-between; - background: var(--vscode-editorWidget-background); - border-top-left-radius: 3px; - border-top-right-radius: 3px; + background: var(--vscode-panel-background); + border-top-left-radius: 6px; + border-top-right-radius: 6px; } .resolved-container .diffPath:hover { @@ -991,15 +1227,15 @@ code { } .win32 .diff .diffLine { - font-family: Consolas, Inconsolata, 'Courier New', monospace; + font-family: var(--vscode-editor-font-family), Consolas, Inconsolata, 'Courier New', monospace; } .darwin .diff .diffLine { - font-family: Monaco, Menlo, Inconsolata, 'Courier New', monospace; + font-family: var(--vscode-editor-font-family), Monaco, Menlo, Inconsolata, 'Courier New', monospace; } .linux .diff .diffLine { - font-family: 'Droid Sans Mono', Inconsolata, 'Courier New', monospace, 'Droid Sans Fallback'; + font-family: var(--vscode-editor-font-family), 'Droid Sans Mono', Inconsolata, 'Courier New', monospace, 'Droid Sans Fallback'; } .diff .diffLine.add { @@ -1049,6 +1285,10 @@ code { border-bottom: 1px solid var(--vscode-contrastBorder); } +.vscode-high-contrast .title.stuck::after { + box-shadow: none; +} + .vscode-high-contrast .diff .diffLine { background: none; } @@ -1069,7 +1309,16 @@ code { border: 1px dashed var(--vscode-diffEditor-removedTextBorder); } -@media (max-width: 925px) { +@media (max-width: 768px) { + .title { + border-bottom: none; + padding-bottom: 0px; + } + + .title.stuck .overview-title h2 { + font-size: 16px; + } + #app { display: block; } @@ -1077,7 +1326,8 @@ code { #sidebar { display: grid; column-gap: 20px; - grid-template-columns: 50% 50%; + row-gap: 12px; + grid-template-columns: calc(50% - 10px) calc(50% - 10px); padding: 0; } @@ -1096,7 +1346,6 @@ code { } .section-header button { - margin-left: 8px; display: flex; } @@ -1118,6 +1367,10 @@ code { display: flex; } +.icon.copilot-icon { + margin-right: 6px; +} + .action-bar { position: absolute; display: flex; @@ -1136,8 +1389,8 @@ code { min-width: 42px; } -.action-bar > button, -.flex-action-bar > button { +.action-bar>button, +.flex-action-bar>button { margin-left: 4px; margin-right: 4px; } @@ -1146,6 +1399,276 @@ code { flex-grow: 1; } -.title-editing-form > .form-actions { - margin-left: 0; +.title-editing-form>.form-actions { + margin-left: 8px; +} + +/* permalinks */ +.comment-body .Box p { + margin-block-start: 0px; + margin-block-end: 0px; +} + +.comment-body .Box { + border-radius: 4px; + border-style: solid; + border-width: 1px; + border-color: var(--vscode-editorHoverWidget-border); +} + +.comment-body .Box-header { + background-color: var(--vscode-panel-background); + color: var(--vscode-disabledForeground); + border-bottom-style: solid; + border-bottom-width: 1px; + padding: 8px 16px; + border-bottom-color: var(--vscode-editorHoverWidget-border); + border-top-left-radius: 6px; + border-top-right-radius: 6px; +} + +.comment-body .blob-num { + word-wrap: break-word; + box-sizing: border-box; + border: 0 !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + min-width: 50px; + font-family: var(--vscode-editor-font-family); + font-size: 12px; + color: var(--vscode-editorLineNumber-foreground); + line-height: 20px; + text-align: right; + white-space: nowrap; + vertical-align: top; + cursor: pointer; + user-select: none; +} + +.comment-body .blob-num::before { + content: attr(data-line-number); +} + +.comment-body .blob-code-inner { + tab-size: 8; + border: 0 !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + line-height: 20px; + vertical-align: top; + display: table-cell; + overflow: visible; + font-family: var(--vscode-editor-font-family); + font-size: 12px; + word-wrap: anywhere; + text-indent: 0; + white-space: pre-wrap; +} + +.comment-body .commit-tease-sha { + font-family: var(--vscode-editor-font-family); + font-size: 12px; +} + +/* Suggestion */ +.comment-body .blob-wrapper.data.file .d-table { + border-radius: 4px; + border-style: solid; + border-width: 1px; + border-collapse: unset; + border-color: var(--vscode-editorHoverWidget-border); +} + +.comment-body .js-suggested-changes-blob { + border-collapse: collapse; +} + +.blob-code-deletion, +.blob-num-deletion { + border-collapse: collapse; + background-color: var(--vscode-diffEditor-removedLineBackground); +} + +.blob-code-addition, +.blob-num-addition { + border-collapse: collapse; + background-color: var(--vscode-diffEditor-insertedLineBackground); +} + +.blob-code-marker-addition::before { + content: "+ "; +} + +.blob-code-marker-deletion::before { + content: "- "; +} + +.markdown-alert.markdown-alert-warning { + border-left: .25em solid var(--vscode-editorWarning-foreground); +} + +.markdown-alert.markdown-alert-warning .markdown-alert-title { + color: var(--vscode-editorWarning-foreground); +} + +.markdown-alert.markdown-alert-note { + border-left: .25em solid var(--vscode-editorInfo-foreground); +} + +.markdown-alert.markdown-alert-note .markdown-alert-title { + color: var(--vscode-editorInfo-foreground); +} + +.markdown-alert.markdown-alert-tip { + border-left: .25em solid var(--vscode-testing-iconPassed); +} + +.markdown-alert.markdown-alert-tip .markdown-alert-title { + color: var(--vscode-testing-iconPassed); +} + +.markdown-alert.markdown-alert-important { + border-left: .25em solid var(--vscode-statusBar-debuggingBackground); } + +.markdown-alert.markdown-alert-important .markdown-alert-title { + color: var(--vscode-statusBar-debuggingBackground); +} + +.markdown-alert.markdown-alert-caution { + border-left: .25em solid var(--vscode-editorError-foreground); +} + +.markdown-alert.markdown-alert-caution .markdown-alert-title { + color: var(--vscode-editorError-foreground); +} + +.markdown-alert { + padding: .5rem .5rem; + margin-bottom: 1rem; + color: inherit; +} + +.markdown-alert .markdown-alert-title { + display: flex; + align-items: center; + line-height: 1; +} + +.markdown-alert-title svg { + padding-right: 3px; +} + +.markdown-alert>:first-child { + margin-top: 0; +} + +svg.octicon path { + display: inline-block; + overflow: visible !important; + vertical-align: text-bottom; + fill: currentColor; +} + +.collapsible-sidebar { + border-top: 1px solid var(--vscode-editorWidget-border); + border-bottom: 1px solid var(--vscode-editorWidget-border); + margin-bottom: 24px; +} + +.collapsible-sidebar-header { + display: flex; + align-items: center; + cursor: pointer; + padding: 16px 0px 8px; + user-select: none; + outline: none; +} + +.collapsible-sidebar-header.expanded { + padding: 8px 0px; +} + +.collapsible-sidebar-header:focus { + outline: 1px solid var(--vscode-focusBorder); +} + +.collapsible-sidebar-title { + font-size: 13px; + width: 100%; +} + +.collapsible-sidebar-content { + padding-bottom: 16px; +} + +.collapsed-label { + gap: 8px; + display: grid; + grid-template-columns: 1fr 1fr; + grid-gap: 8px 20px; +} + +.collapsed-section { + gap: 8px; + display: inline-flex; + align-items: center; + min-width: 0; + overflow: hidden; + height: 22px; +} + +.collapsed-section-label { + padding-right: 4px; + font-weight: 600; + flex-shrink: 0; +} + +.collapsed-section-count { + color: var(--vscode-descriptionForeground); +} + +.pill-container { + display: flex; + align-items: center; + min-width: 0; + flex: 1; + flex-wrap: nowrap; + overflow: hidden; +} + +.pill-item { + flex-shrink: 0; + white-space: nowrap; + border-radius: 20px; + margin-right: 2px; + border-style: none; + text-overflow: ellipsis; + max-width: -webkit-fill-available; + overflow: hidden; + display: inline-block; +} + +.pill-overflow { + color: var(--vscode-descriptionForeground); + font-size: 13px; + margin-left: 4px; + flex-shrink: 0; + white-space: nowrap; +} + +.collapsed-section .stacked-avatar { + position: absolute; +} + +.avatar-stack { + position: relative; + height: 22px; +} + +.collapsible-label-see-more { + padding-bottom: 16px; + display: block; + font-size: 13px; + cursor: pointer; +} \ No newline at end of file diff --git a/webviews/editorWebview/overview.tsx b/webviews/editorWebview/overview.tsx index c61402b0ff..36aeea2834 100644 --- a/webviews/editorWebview/overview.tsx +++ b/webviews/editorWebview/overview.tsx @@ -4,29 +4,61 @@ *--------------------------------------------------------------------------------------------*/ import * as React from 'react'; -import { PullRequest } from '../common/cache'; +import { PullRequest } from '../../src/github/views'; import { AddComment, CommentView } from '../components/comment'; import { Header } from '../components/header'; import { StatusChecksSection } from '../components/merge'; -import Sidebar from '../components/sidebar'; +import Sidebar, { CollapsibleSidebar } from '../components/sidebar'; import { Timeline } from '../components/timeline'; -export const Overview = (pr: PullRequest) => ( - <> +const useMediaQuery = (query: string) => { + const [matches, setMatches] = React.useState(window.matchMedia(query).matches); + + React.useEffect(() => { + const mediaQueryList = window.matchMedia(query); + const documentChangeHandler = () => setMatches(mediaQueryList.matches); + + mediaQueryList.addEventListener('change', documentChangeHandler); + + return () => { + mediaQueryList.removeEventListener('change', documentChangeHandler); + }; + }, [query]); + + return matches; +}; + +export const Overview = (pr: PullRequest) => { + const isSingleColumnLayout = useMediaQuery('(max-width: 768px)'); + + return <> <div id="title" className="title"> <div className="details"> <Header {...pr} /> </div> </div> - <Sidebar {...pr} /> - <div id="main"> - <div id="description"> - <CommentView isPRDescription {...pr} /> - </div> - <Timeline events={pr.events} /> - <StatusChecksSection pr={pr} isSimple={false} /> - <AddComment {...pr} /> + {isSingleColumnLayout ? + <> + <CollapsibleSidebar {...pr}/> + <Main {...pr} /> + </> + : + <> + <Main {...pr} /> + <Sidebar {...pr} /> + </> + } + </>; +}; + +const Main = (pr: PullRequest) => ( + <div id="main"> + <div id="description"> + <CommentView isPRDescription comment={pr} /> </div> - </> + <Timeline events={pr.events} isIssue={pr.isIssue} /> + <StatusChecksSection pr={pr} isSimple={false} /> + <AddComment {...pr} /> + </div> ); diff --git a/webviews/editorWebview/test/builder/account.ts b/webviews/editorWebview/test/builder/account.ts index ce32630b5a..c6fcd4fdb9 100644 --- a/webviews/editorWebview/test/builder/account.ts +++ b/webviews/editorWebview/test/builder/account.ts @@ -1,4 +1,9 @@ -import { IAccount } from '../../../../src/github/interface'; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AccountType, IAccount } from '../../../../src/github/interface'; import { createBuilderClass } from '../../../../src/test/builders/base'; export const AccountBuilder = createBuilderClass<IAccount>()({ @@ -6,4 +11,6 @@ export const AccountBuilder = createBuilderClass<IAccount>()({ name: { default: 'Myself' }, avatarUrl: { default: 'https://avatars3.githubusercontent.com/u/17565?v=4' }, url: { default: 'https://github.com/me' }, + id: { default: '123' }, + accountType: { default: AccountType.User }, }); diff --git a/webviews/editorWebview/test/builder/pullRequest.ts b/webviews/editorWebview/test/builder/pullRequest.ts index ca8f6ae236..5355ec2efb 100644 --- a/webviews/editorWebview/test/builder/pullRequest.ts +++ b/webviews/editorWebview/test/builder/pullRequest.ts @@ -3,14 +3,16 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { AccountBuilder } from './account'; import { GithubItemStateEnum, PullRequestMergeability } from '../../../../src/github/interface'; +import { PullRequest } from '../../../../src/github/views'; import { createBuilderClass } from '../../../../src/test/builders/base'; import { CombinedStatusBuilder } from '../../../../src/test/builders/rest/combinedStatusBuilder'; -import { PullRequest } from '../../../common/cache'; -import { AccountBuilder } from './account'; export const PullRequestBuilder = createBuilderClass<PullRequest>()({ + owner: { default: 'owner' }, + repo: { default: 'name' }, number: { default: 1234 }, title: { default: 'the default title' }, titleHTML: { default: 'the default title' }, @@ -28,24 +30,39 @@ export const PullRequestBuilder = createBuilderClass<PullRequest>()({ isLocalHeadDeleted: { default: false }, head: { default: 'my-fork:my-branch' }, labels: { default: [] }, + isAuthor: { default: true }, commitsCount: { default: 10 }, repositoryDefaultBranch: { default: 'main' }, + doneCheckoutBranch: { default: 'main' }, canEdit: { default: true }, hasWritePermission: { default: true }, - pendingCommentText: { default: null }, - pendingCommentDrafts: { default: null }, + pendingCommentText: { default: undefined }, + pendingCommentDrafts: { default: undefined }, status: { linked: CombinedStatusBuilder }, + reviewRequirement: { default: null }, mergeable: { default: PullRequestMergeability.Mergeable }, defaultMergeMethod: { default: 'merge' }, mergeMethodsAvailability: { default: { merge: true, squash: true, rebase: true } }, allowAutoMerge: { default: false }, + mergeQueueEntry: { default: undefined }, + mergeQueueMethod: { default: undefined }, + canUpdateBranch: { default: false }, reviewers: { default: [] }, isDraft: { default: false }, isIssue: { default: false }, assignees: { default: [] }, + projectItems: { default: undefined }, milestone: { default: undefined }, continueOnGitHub: { default: false }, currentUserReviewState: { default: 'REQUESTED' }, + emailForCommit: { default: 'email-address' }, isDarkTheme: { default: true }, - hasReviewDraft: { default: false } + isEnterprise: { default: false }, + hasReviewDraft: { default: false }, + busy: { default: undefined }, + lastReviewType: { default: undefined }, + canAssignCopilot: { default: false }, + canRequestCopilotReview: { default: false }, + isCopilotOnMyBehalf: { default: false }, + reactions: { default: [] }, }); diff --git a/webviews/editorWebview/test/overview.test.tsx b/webviews/editorWebview/test/overview.test.tsx new file mode 100644 index 0000000000..9a6e5980a3 --- /dev/null +++ b/webviews/editorWebview/test/overview.test.tsx @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * 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 React from 'react'; +import { cleanup, render } from 'react-testing-library'; +import { createSandbox, SinonSandbox } from 'sinon'; + +import { PRContext, default as PullRequestContext } from '../../common/context'; +import { Overview } from '../overview'; +import { PullRequestBuilder } from './builder/pullRequest'; + +describe('Overview', function () { + let sinon: SinonSandbox; + + beforeEach(function () { + sinon = createSandbox(); + }); + + afterEach(function () { + cleanup(); + sinon.restore(); + }); + + it('renders the PR header with title', function () { + const pr = new PullRequestBuilder().build(); + const context = new PRContext(pr); + + const out = render( + <PullRequestContext.Provider value={context}> + <Overview {...pr} /> + </PullRequestContext.Provider>, + ); + + assert(out.container.querySelector('.title')); + assert(out.container.querySelector('.overview-title')); + }); + + it('applies sticky class when scrolled', function () { + const pr = new PullRequestBuilder().build(); + const context = new PRContext(pr); + + const out = render( + <PullRequestContext.Provider value={context}> + <Overview {...pr} /> + </PullRequestContext.Provider>, + ); + + const titleElement = out.container.querySelector('.title'); + assert(titleElement); + + // Initial state should not have sticky class + assert(!titleElement.classList.contains('sticky')); + }); +}); diff --git a/yarn.lock b/yarn.lock index bf89ce4dbc..0c5f377bd2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,26 +2,86 @@ # yarn lockfile v1 -"@babel/code-frame@7.12.11": - version "7.12.11" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" - integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw== +"@azure/abort-controller@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@azure/abort-controller/-/abort-controller-1.1.0.tgz#788ee78457a55af8a1ad342acb182383d2119249" + integrity sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw== + dependencies: + tslib "^2.2.0" + +"@azure/core-auth@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@azure/core-auth/-/core-auth-1.4.0.tgz#6fa9661c1705857820dbc216df5ba5665ac36a9e" + integrity sha512-HFrcTgmuSuukRf/EdPmqBrc5l6Q5Uu+2TbuhaKbgaCpP2TfAeiNaQPAadxO+CYBRHGUzIDteMAjFspFLDLnKVQ== + dependencies: + "@azure/abort-controller" "^1.0.0" + tslib "^2.2.0" + +"@azure/core-rest-pipeline@^1.10.0": + version "1.10.1" + resolved "https://registry.yarnpkg.com/@azure/core-rest-pipeline/-/core-rest-pipeline-1.10.1.tgz#348290847ca31b9eecf9cf5de7519aaccdd30968" + integrity sha512-Kji9k6TOFRDB5ZMTw8qUf2IJ+CeJtsuMdAHox9eqpTf1cefiNMpzrfnF6sINEBZJsaVaWgQ0o48B6kcUH68niA== + dependencies: + "@azure/abort-controller" "^1.0.0" + "@azure/core-auth" "^1.4.0" + "@azure/core-tracing" "^1.0.1" + "@azure/core-util" "^1.0.0" + "@azure/logger" "^1.0.0" + form-data "^4.0.0" + http-proxy-agent "^5.0.0" + https-proxy-agent "^5.0.0" + tslib "^2.2.0" + uuid "^8.3.0" + +"@azure/core-tracing@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@azure/core-tracing/-/core-tracing-1.0.1.tgz#352a38cbea438c4a83c86b314f48017d70ba9503" + integrity sha512-I5CGMoLtX+pI17ZdiFJZgxMJApsK6jjfm85hpgp3oazCdq5Wxgh4wMr7ge/TTWW1B5WBuvIOI1fMU/FrOAMKrw== dependencies: - "@babel/highlight" "^7.10.4" + tslib "^2.2.0" -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.8.3": +"@azure/core-util@^1.0.0": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@azure/core-util/-/core-util-1.1.1.tgz#8f87b3dd468795df0f0849d9f096c3e7b29452c1" + integrity sha512-A4TBYVQCtHOigFb2ETiiKFDocBoI1Zk2Ui1KpI42aJSIDexF7DHQFpnjonltXAIU/ceH+1fsZAWWgvX6/AKzog== + dependencies: + "@azure/abort-controller" "^1.0.0" + tslib "^2.2.0" + +"@azure/logger@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@azure/logger/-/logger-1.0.3.tgz#6e36704aa51be7d4a1bae24731ea580836293c96" + integrity sha512-aK4s3Xxjrx3daZr3VylxejK3vG5ExXck5WOHDJ8in/k9AqlfIyFMMT1uG7u8mNjX+QRILTIn0/Xgschfh/dQ9g== + dependencies: + tslib "^2.2.0" + +"@babel/code-frame@^7.0.0": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.13.tgz#dcfc826beef65e75c50e21d3837d7d95798dd658" integrity sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g== dependencies: "@babel/highlight" "^7.12.13" +"@babel/code-frame@^7.16.7": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" + integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== + dependencies: + "@babel/helper-validator-identifier" "^7.27.1" + js-tokens "^4.0.0" + picocolors "^1.1.1" + "@babel/helper-validator-identifier@^7.12.11": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed" integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw== -"@babel/highlight@^7.10.4", "@babel/highlight@^7.12.13": +"@babel/helper-validator-identifier@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8" + integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow== + +"@babel/highlight@^7.12.13": version "7.13.10" resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.13.10.tgz#a8b2a66148f5b27d666b15d81774347a731d52d1" integrity sha512-5aPpe5XQPzflQrFwL1/QoeHkP2MsA4JCntcXHRhEsdsfPVkvPi2w7Qix4iV7t5S/oC9OodGrggd8aco1g3SZFg== @@ -37,21 +97,261 @@ dependencies: regenerator-runtime "^0.13.4" -"@eslint/eslintrc@^0.4.0": - version "0.4.0" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.0.tgz#99cc0a0584d72f1df38b900fb062ba995f395547" - integrity sha512-2ZPCc+uNbjV5ERJr+aKSPRwZgKd2z11x0EgLvb1PURmUrn9QNRXFqje0Ldq454PfAVyaJYyrDvvIKSFP4NnBog== +"@bcoe/v8-coverage@^0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" + integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== + +"@cspotcode/source-map-support@^0.8.0": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" + integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== + dependencies: + "@jridgewell/trace-mapping" "0.3.9" + +"@emnapi/core@^1.4.3": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.5.0.tgz#85cd84537ec989cebb2343606a1ee663ce4edaf0" + integrity sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg== + dependencies: + "@emnapi/wasi-threads" "1.1.0" + tslib "^2.4.0" + +"@emnapi/runtime@^1.4.3": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.5.0.tgz#9aebfcb9b17195dce3ab53c86787a6b7d058db73" + integrity sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf" + integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ== + dependencies: + tslib "^2.4.0" + +"@esbuild/aix-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" + integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ== + +"@esbuild/android-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" + integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A== + +"@esbuild/android-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" + integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg== + +"@esbuild/android-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" + integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA== + +"@esbuild/darwin-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" + integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ== + +"@esbuild/darwin-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" + integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw== + +"@esbuild/freebsd-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" + integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g== + +"@esbuild/freebsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" + integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ== + +"@esbuild/linux-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" + integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q== + +"@esbuild/linux-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" + integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA== + +"@esbuild/linux-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" + integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg== + +"@esbuild/linux-loong64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" + integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg== + +"@esbuild/linux-mips64el@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" + integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg== + +"@esbuild/linux-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" + integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w== + +"@esbuild/linux-riscv64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" + integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA== + +"@esbuild/linux-s390x@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" + integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A== + +"@esbuild/linux-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" + integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ== + +"@esbuild/netbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" + integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg== + +"@esbuild/openbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" + integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow== + +"@esbuild/sunos-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" + integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg== + +"@esbuild/win32-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" + integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A== + +"@esbuild/win32-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" + integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA== + +"@esbuild/win32-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" + integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== + +"@eslint-community/eslint-utils@^4.7.0", "@eslint-community/eslint-utils@^4.8.0": + version "4.9.0" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz#7308df158e064f0dd8b8fdb58aa14fa2a7f913b3" + integrity sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g== + dependencies: + eslint-visitor-keys "^3.4.3" + +"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.12.1": + version "4.12.1" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0" + integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== + +"@eslint/config-array@^0.21.0": + version "0.21.0" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.21.0.tgz#abdbcbd16b124c638081766392a4d6b509f72636" + integrity sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ== + dependencies: + "@eslint/object-schema" "^2.1.6" + debug "^4.3.1" + minimatch "^3.1.2" + +"@eslint/config-helpers@^0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.3.1.tgz#d316e47905bd0a1a931fa50e669b9af4104d1617" + integrity sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA== + +"@eslint/core@^0.15.2": + version "0.15.2" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.15.2.tgz#59386327d7862cc3603ebc7c78159d2dcc4a868f" + integrity sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg== + dependencies: + "@types/json-schema" "^7.0.15" + +"@eslint/eslintrc@^3.3.1": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.1.tgz#e55f7f1dd400600dd066dbba349c4c0bac916964" + integrity sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ== dependencies: ajv "^6.12.4" - debug "^4.1.1" - espree "^7.3.0" - globals "^12.1.0" - ignore "^4.0.6" + debug "^4.3.2" + espree "^10.0.1" + globals "^14.0.0" + ignore "^5.2.0" import-fresh "^3.2.1" - js-yaml "^3.13.1" - minimatch "^3.0.4" + js-yaml "^4.1.0" + minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@eslint/js@9.36.0", "@eslint/js@^9.36.0": + version "9.36.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.36.0.tgz#b1a3893dd6ce2defed5fd49de805ba40368e8fef" + integrity sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw== + +"@eslint/object-schema@^2.1.6": + version "2.1.6" + resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.6.tgz#58369ab5b5b3ca117880c0f6c0b0f32f6950f24f" + integrity sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA== + +"@eslint/plugin-kit@^0.3.5": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz#fd8764f0ee79c8ddab4da65460c641cefee017c5" + integrity sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w== + dependencies: + "@eslint/core" "^0.15.2" + levn "^0.4.1" + +"@humanfs/core@^0.19.1": + version "0.19.1" + resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" + integrity sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA== + +"@humanfs/node@^0.16.6": + version "0.16.7" + resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.7.tgz#822cb7b3a12c5a240a24f621b5a2413e27a45f26" + integrity sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ== + dependencies: + "@humanfs/core" "^0.19.1" + "@humanwhocodes/retry" "^0.4.0" + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" + integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== + +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@istanbuljs/schema@^0.1.2", "@istanbuljs/schema@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" + integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== + "@jest/types@^24.9.0": version "24.9.0" resolved "https://registry.yarnpkg.com/@jest/types/-/types-24.9.0.tgz#63cb26cb7500d069e5a389441a7c6ab5e909fc59" @@ -61,6 +361,11 @@ "@types/istanbul-reports" "^1.1.1" "@types/yargs" "^13.0.0" +"@joaomoreno/unique-names-generator@^5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@joaomoreno/unique-names-generator/-/unique-names-generator-5.2.0.tgz#f7cf42c8734ef4b8f2c8d2ecb2baf9d4e08664ce" + integrity sha512-JEh3qZ85Z6syFvQlhRGRyTPI1M5VticiiP8Xl8EV0XfyfI4Mwzd6Zw28BBrEgUJCYv/cpKCQClVj3J8Tn0KFiA== + "@jridgewell/gen-mapping@^0.3.0": version "0.3.2" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz#c1aedc61e853f2bb9f5dfe6d4442d3b565b253b9" @@ -70,16 +375,35 @@ "@jridgewell/sourcemap-codec" "^1.4.10" "@jridgewell/trace-mapping" "^0.3.9" +"@jridgewell/gen-mapping@^0.3.5": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz#dcce6aff74bdf6dad1a95802b69b04a2fcb1fb36" + integrity sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg== + dependencies: + "@jridgewell/set-array" "^1.2.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.24" + "@jridgewell/resolve-uri@^3.0.3": version "3.1.0" resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w== +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + "@jridgewell/set-array@^1.0.1": version "1.1.2" resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw== +"@jridgewell/set-array@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280" + integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== + "@jridgewell/source-map@^0.3.2": version "0.3.2" resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.2.tgz#f45351aaed4527a298512ec72f81040c998580fb" @@ -88,11 +412,48 @@ "@jridgewell/gen-mapping" "^0.3.0" "@jridgewell/trace-mapping" "^0.3.9" +"@jridgewell/source-map@^0.3.3": + version "0.3.6" + resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.6.tgz#9d71ca886e32502eb9362c9a74a46787c36df81a" + integrity sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + "@jridgewell/sourcemap-codec@^1.4.10": version "1.4.14" resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== +"@jridgewell/sourcemap-codec@^1.4.14": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" + integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== + +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + +"@jridgewell/trace-mapping@^0.3.12": + version "0.3.31" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@jridgewell/trace-mapping@^0.3.20", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": + version "0.3.25" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" + integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + "@jridgewell/trace-mapping@^0.3.9": version "0.3.14" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz#b231a081d8f66796e475ad588a1ef473112701ed" @@ -101,59 +462,102 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@koa/cors@^3.3.0": - version "3.3.0" - resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.3.0.tgz#b4c1c7ee303b7c968c8727f2a638a74675b50bb2" - integrity sha512-lzlkqLlL5Ond8jb6JLnVVDmD2OPym0r5kvZlMgAWiS9xle+Q5ulw1T358oW+RVguxUkANquZQz82i/STIRmsqQ== +"@koa/cors@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-5.0.0.tgz#0029b5f057fa0d0ae0e37dd2c89ece315a0daffd" + integrity sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw== dependencies: vary "^1.1.2" -"@koa/router@^10.1.1": - version "10.1.1" - resolved "https://registry.yarnpkg.com/@koa/router/-/router-10.1.1.tgz#8e5a85c9b243e0bc776802c0de564561e57a5f78" - integrity sha512-ORNjq5z4EmQPriKbR0ER3k4Gh7YGNhWDL7JBW+8wXDrHLbWYKYSJaOJ9aN06npF5tbTxe2JBOsurpJDAvjiXKw== +"@koa/router@^13.1.0": + version "13.1.1" + resolved "https://registry.yarnpkg.com/@koa/router/-/router-13.1.1.tgz#1291b8adca7f61a31a7f8974d28654654211469d" + integrity sha512-JQEuMANYRVHs7lm7KY9PCIjkgJk73h4m4J+g2mkw2Vo1ugPZ17UJVqEH8F+HeAdjKz5do1OaLe7ArDz+z308gw== dependencies: - debug "^4.1.1" - http-errors "^1.7.3" + debug "^4.4.1" + http-errors "^2.0.0" koa-compose "^4.1.0" - methods "^1.1.2" - path-to-regexp "^6.1.0" - -"@microsoft/1ds-core-js@3.2.3", "@microsoft/1ds-core-js@^3.2.3": - version "3.2.3" - resolved "https://registry.yarnpkg.com/@microsoft/1ds-core-js/-/1ds-core-js-3.2.3.tgz#2217d92ec8b073caa4577a13f40ea3a5c4c4d4e7" - integrity sha512-796A8fd90oUKDRO7UXUT9BwZ3G+a9XzJj5v012FcCN/2qRhEsIV3x/0wkx2S08T4FiQEUPkB2uoYHpEjEneM7g== - dependencies: - "@microsoft/applicationinsights-core-js" "2.8.4" - "@microsoft/applicationinsights-shims" "^2.0.1" - "@microsoft/dynamicproto-js" "^1.1.6" + path-to-regexp "^6.3.0" + +"@microsoft/1ds-core-js@3.2.9", "@microsoft/1ds-core-js@^3.2.8": + version "3.2.9" + resolved "https://registry.yarnpkg.com/@microsoft/1ds-core-js/-/1ds-core-js-3.2.9.tgz#8a26935966e4871d1f1e40d992828bdd52bba84e" + integrity sha512-3pCfM2TzHn3gU9pxHztduKcVRdb/nzruvPFfHPZD0IM0mb0h6TGo2isELF3CTMahTx50RAC51ojNIw2/7VRkOg== + dependencies: + "@microsoft/applicationinsights-core-js" "2.8.10" + "@microsoft/applicationinsights-shims" "^2.0.2" + "@microsoft/dynamicproto-js" "^1.1.7" + +"@microsoft/1ds-post-js@^3.2.8": + version "3.2.9" + resolved "https://registry.yarnpkg.com/@microsoft/1ds-post-js/-/1ds-post-js-3.2.9.tgz#07030f7455cb4ac8993e9b0bfa6c78ebfe25b499" + integrity sha512-D/RtqkQ2Nr4cuoGqmhi5QTmi3cBlxehIThJ1u3BaH9H/YkLNTKEcHZRWTXy14bXheCefNHciLuadg37G2Kekcg== + dependencies: + "@microsoft/1ds-core-js" "3.2.9" + "@microsoft/applicationinsights-shims" "^2.0.2" + "@microsoft/dynamicproto-js" "^1.1.7" + +"@microsoft/applicationinsights-channel-js@2.8.10": + version "2.8.10" + resolved "https://registry.yarnpkg.com/@microsoft/applicationinsights-channel-js/-/applicationinsights-channel-js-2.8.10.tgz#9d7077d7daa74d02d15bc26cc5440b4b5802cf5d" + integrity sha512-jXEUw3+U6WABygDOjEIlCLsniUpPqH5d/1Rfj1MVWMW6FFZo1vvYZoziOqb+dWWn41Dn5GF4EgXnvsfdkpz29w== + dependencies: + "@microsoft/applicationinsights-common" "2.8.10" + "@microsoft/applicationinsights-core-js" "2.8.10" + "@microsoft/applicationinsights-shims" "2.0.2" + "@microsoft/dynamicproto-js" "^1.1.7" + +"@microsoft/applicationinsights-common@2.8.10": + version "2.8.10" + resolved "https://registry.yarnpkg.com/@microsoft/applicationinsights-common/-/applicationinsights-common-2.8.10.tgz#927227db35e4448692726f68deb0f6af576b483c" + integrity sha512-wXji97I1eANL5PG8RxZ/st+HCwKgAB1uySSxEvVNj3VcOiUyTYTtBYYEK2xhjBGR49+A2/fIJQHvu1ygco2b3Q== + dependencies: + "@microsoft/applicationinsights-core-js" "2.8.10" + "@microsoft/applicationinsights-shims" "2.0.2" + "@microsoft/dynamicproto-js" "^1.1.7" + +"@microsoft/applicationinsights-core-js@2.8.10": + version "2.8.10" + resolved "https://registry.yarnpkg.com/@microsoft/applicationinsights-core-js/-/applicationinsights-core-js-2.8.10.tgz#beb96a97a046ddb031d6adecf0d3143b635edf42" + integrity sha512-jQrufDW0+sV8fBhRvzIPNGiCC6dELH+Ug0DM5CfN9757TBqZJz8CSWyDjex39as8+jD0F/8HRU9QdmrVgq5vFg== + dependencies: + "@microsoft/applicationinsights-shims" "2.0.2" + "@microsoft/dynamicproto-js" "^1.1.7" + +"@microsoft/applicationinsights-shims@2.0.2", "@microsoft/applicationinsights-shims@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@microsoft/applicationinsights-shims/-/applicationinsights-shims-2.0.2.tgz#92b36a09375e2d9cb2b4203383b05772be837085" + integrity sha512-PoHEgsnmcqruLNHZ/amACqdJ6YYQpED0KSRe6J7gIJTtpZC1FfFU9b1fmDKDKtFoUSrPzEh1qzO3kmRZP0betg== -"@microsoft/1ds-post-js@^3.2.3": - version "3.2.3" - resolved "https://registry.yarnpkg.com/@microsoft/1ds-post-js/-/1ds-post-js-3.2.3.tgz#1fa7d51615a44f289632ae8c588007ba943db216" - integrity sha512-tcGJQXXr2LYoBbIXPoUVe1KCF3OtBsuKDFL7BXfmNtuSGtWF0yejm6H83DrR8/cUIGMRMUP9lqNlqFGwDYiwAQ== +"@microsoft/applicationinsights-web-basic@^2.8.9": + version "2.8.10" + resolved "https://registry.yarnpkg.com/@microsoft/applicationinsights-web-basic/-/applicationinsights-web-basic-2.8.10.tgz#3bc52c2252a6dc41fa14679d941f7da0658d3676" + integrity sha512-Iay9y4eYxcX5vIrqbAuOx51hCqABopiQljGQjdxKO/aEET1nHrOxXxcrTUYGUJF/aYoR3+RxaiFcqcuujLPiOg== dependencies: - "@microsoft/1ds-core-js" "3.2.3" - "@microsoft/applicationinsights-shims" "^2.0.1" - "@microsoft/dynamicproto-js" "^1.1.6" + "@microsoft/applicationinsights-channel-js" "2.8.10" + "@microsoft/applicationinsights-common" "2.8.10" + "@microsoft/applicationinsights-core-js" "2.8.10" + "@microsoft/applicationinsights-shims" "2.0.2" + "@microsoft/dynamicproto-js" "^1.1.7" -"@microsoft/applicationinsights-core-js@2.8.4": - version "2.8.4" - resolved "https://registry.yarnpkg.com/@microsoft/applicationinsights-core-js/-/applicationinsights-core-js-2.8.4.tgz#607e531bb241a8920d43960f68a7c76a6f9af596" - integrity sha512-FoA0FNOsFbJnLyTyQlYs6+HR7HMEa6nAOE6WOm9WVejBHMHQ/Bdb+hfVFi6slxwCimr/ner90jchi4/sIYdnyQ== - dependencies: - "@microsoft/applicationinsights-shims" "2.0.1" - "@microsoft/dynamicproto-js" "^1.1.6" +"@microsoft/applicationinsights-web-snippet@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@microsoft/applicationinsights-web-snippet/-/applicationinsights-web-snippet-1.0.1.tgz#6bb788b2902e48bf5d460c38c6bb7fedd686ddd7" + integrity sha512-2IHAOaLauc8qaAitvWS+U931T+ze+7MNWrDHY47IENP5y2UA0vqJDu67kWZDdpCN1fFC77sfgfB+HV7SrKshnQ== -"@microsoft/applicationinsights-shims@2.0.1", "@microsoft/applicationinsights-shims@^2.0.1": - version "2.0.1" - resolved "https://registry.yarnpkg.com/@microsoft/applicationinsights-shims/-/applicationinsights-shims-2.0.1.tgz#5d72fb7aaf4056c4fda54f9d7c93ccf8ca9bcbfd" - integrity sha512-G0MXf6R6HndRbDy9BbEj0zrLeuhwt2nsXk2zKtF0TnYo39KgYqhYC2ayIzKPTm2KAE+xzD7rgyLdZnrcRvt9WQ== +"@microsoft/dynamicproto-js@^1.1.7": + version "1.1.7" + resolved "https://registry.yarnpkg.com/@microsoft/dynamicproto-js/-/dynamicproto-js-1.1.7.tgz#ede48dd3f85af14ee369c805e5ed5b84222b9fe2" + integrity sha512-SK3D3aVt+5vOOccKPnGaJWB5gQ8FuKfjboUJHedMP7gu54HqSCXX5iFXhktGD8nfJb0Go30eDvs/UDoTnR2kOA== -"@microsoft/dynamicproto-js@^1.1.6": - version "1.1.6" - resolved "https://registry.yarnpkg.com/@microsoft/dynamicproto-js/-/dynamicproto-js-1.1.6.tgz#6fe03468862861f5f88ac4c3959a652b3797f1bc" - integrity sha512-D1Oivw1A4bIXhzBIy3/BBPn3p2On+kpO2NiYt9shICDK7L/w+cR6FFBUsBZ05l6iqzTeL+Jm8lAYn0g6G7DmDg== +"@napi-rs/wasm-runtime@^0.2.11": + version "0.2.12" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz#3e78a8b96e6c33a6c517e1894efbd5385a7cb6f2" + integrity sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ== + dependencies: + "@emnapi/core" "^1.4.3" + "@emnapi/runtime" "^1.4.3" + "@tybys/wasm-util" "^0.10.0" "@nodelib/fs.scandir@2.1.4": version "2.1.4" @@ -176,133 +580,188 @@ "@nodelib/fs.scandir" "2.1.4" fastq "^1.6.0" -"@octokit/auth-token@^2.4.4": - version "2.4.5" - resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-2.4.5.tgz#568ccfb8cb46f36441fac094ce34f7a875b197f3" - integrity sha512-BpGYsPgJt05M7/L/5FoE1PiAbdxXFZkX/3kDYcsvd1v6UhlnE5e96dTDr0ezX/EFwciQxf3cNV0loipsURU+WA== +"@octokit/auth-token@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-6.0.0.tgz#b02e9c08a2d8937df09a2a981f226ad219174c53" + integrity sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w== + +"@octokit/core@^7.0.2": + version "7.0.3" + resolved "https://registry.yarnpkg.com/@octokit/core/-/core-7.0.3.tgz#0b5288995fed66920128d41cfeea34979d48a360" + integrity sha512-oNXsh2ywth5aowwIa7RKtawnkdH6LgU1ztfP9AIUCQCvzysB+WeU8o2kyyosDPwBZutPpjZDKPQGIzzrfTWweQ== + dependencies: + "@octokit/auth-token" "^6.0.0" + "@octokit/graphql" "^9.0.1" + "@octokit/request" "^10.0.2" + "@octokit/request-error" "^7.0.0" + "@octokit/types" "^14.0.0" + before-after-hook "^4.0.0" + universal-user-agent "^7.0.0" + +"@octokit/endpoint@^11.0.0": + version "11.0.0" + resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-11.0.0.tgz#189fcc022721b4c49d0307eea6be3de1cfb53026" + integrity sha512-hoYicJZaqISMAI3JfaDr1qMNi48OctWuOih1m80bkYow/ayPw6Jj52tqWJ6GEoFTk1gBqfanSoI1iY99Z5+ekQ== dependencies: - "@octokit/types" "^6.0.3" + "@octokit/types" "^14.0.0" + universal-user-agent "^7.0.2" -"@octokit/core@^3.2.3": - version "3.3.1" - resolved "https://registry.yarnpkg.com/@octokit/core/-/core-3.3.1.tgz#c6bb6ba171ad84a5f430853a98892cfe8f93d8cd" - integrity sha512-Dc5NNQOYjgZU5S1goN6A/E500yXOfDUFRGQB8/2Tl16AcfvS3H9PudyOe3ZNE/MaVyHPIfC0htReHMJb1tMrvw== - dependencies: - "@octokit/auth-token" "^2.4.4" - "@octokit/graphql" "^4.5.8" - "@octokit/request" "^5.4.12" - "@octokit/request-error" "^2.0.5" - "@octokit/types" "^6.0.3" - before-after-hook "^2.2.0" - universal-user-agent "^6.0.0" - -"@octokit/endpoint@^6.0.1": - version "6.0.11" - resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-6.0.11.tgz#082adc2aebca6dcefa1fb383f5efb3ed081949d1" - integrity sha512-fUIPpx+pZyoLW4GCs3yMnlj2LfoXTWDUVPTC4V3MUEKZm48W+XYpeWSZCv+vYF1ZABUm2CqnDVf1sFtIYrj7KQ== - dependencies: - "@octokit/types" "^6.0.3" - is-plain-object "^5.0.0" - universal-user-agent "^6.0.0" - -"@octokit/graphql@^4.5.8": - version "4.6.1" - resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-4.6.1.tgz#f975486a46c94b7dbe58a0ca751935edc7e32cc9" - integrity sha512-2lYlvf4YTDgZCTXTW4+OX+9WTLFtEUc6hGm4qM1nlZjzxj+arizM4aHWzBVBCxY9glh7GIs0WEuiSgbVzv8cmA== - dependencies: - "@octokit/request" "^5.3.0" - "@octokit/types" "^6.0.3" - universal-user-agent "^6.0.0" - -"@octokit/openapi-types@^12.11.0": - version "12.11.0" - resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-12.11.0.tgz#da5638d64f2b919bca89ce6602d059f1b52d3ef0" - integrity sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ== - -"@octokit/openapi-types@^5.1.0", "@octokit/openapi-types@^5.3.2": - version "5.3.2" - resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-5.3.2.tgz#b8ac43c5c3d00aef61a34cf744e315110c78deb4" - integrity sha512-NxF1yfYOUO92rCx3dwvA2onF30Vdlg7YUkMVXkeptqpzA3tRLplThhFleV/UKWFgh7rpKu1yYRbvNDUtzSopKA== +"@octokit/graphql@^9.0.1": + version "9.0.1" + resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-9.0.1.tgz#eb258fc9981403d2d751720832652c385b6c1613" + integrity sha512-j1nQNU1ZxNFx2ZtKmL4sMrs4egy5h65OMDmSbVyuCzjOcwsHq6EaYjOTGXPQxgfiN8dJ4CriYHk6zF050WEULg== + dependencies: + "@octokit/request" "^10.0.2" + "@octokit/types" "^14.0.0" + universal-user-agent "^7.0.0" + +"@octokit/openapi-types@^25.1.0": + version "25.1.0" + resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-25.1.0.tgz#5a72a9dfaaba72b5b7db375fd05e90ca90dc9682" + integrity sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA== -"@octokit/plugin-paginate-rest@^2.6.2": - version "2.13.2" - resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.13.2.tgz#7b8244a0dd7a31135ba2adc58a533213837bfe87" - integrity sha512-mjfBcla00UNS4EI/NN7toEbUM45ow3kk4go+LxsXAFLQodsrXcIZbftUhXTqi6ZKd+r6bcqMI+Lv4dshLtFjww== +"@octokit/plugin-paginate-rest@^13.0.1": + version "13.1.1" + resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-13.1.1.tgz#ca5bb1c7b85a583691263c1f788f607e9bcb74b3" + integrity sha512-q9iQGlZlxAVNRN2jDNskJW/Cafy7/XE52wjZ5TTvyhyOD904Cvx//DNyoO3J/MXJ0ve3rPoNWKEg5iZrisQSuw== dependencies: - "@octokit/types" "^6.11.0" + "@octokit/types" "^14.1.0" -"@octokit/plugin-request-log@^1.0.2": - version "1.0.3" - resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-1.0.3.tgz#70a62be213e1edc04bb8897ee48c311482f9700d" - integrity sha512-4RFU4li238jMJAzLgAwkBAw+4Loile5haQMQr+uhFq27BmyJXcXSKvoQKqh0agsZEiUlW6iSv3FAgvmGkur7OQ== +"@octokit/plugin-request-log@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz#de1c1e557df6c08adb631bf78264fa741e01b317" + integrity sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q== -"@octokit/plugin-rest-endpoint-methods@4.12.2": - version "4.12.2" - resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.12.2.tgz#d2bd0b794d6c11a13113db6199baf44a39b06f50" - integrity sha512-5+MmGusB7wPw7OholtcGaMyjfrsFSpFqtJW8VsrbfU/TuaiQepY4wgVkS7P3TAObX257jrTbbGo/sJLcoGf16g== +"@octokit/plugin-rest-endpoint-methods@^16.0.0": + version "16.0.0" + resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-16.0.0.tgz#ba30ca387fc2ac8bd93cf9f951174736babebd97" + integrity sha512-kJVUQk6/dx/gRNLWUnAWKFs1kVPn5O5CYZyssyEoNYaFedqZxsfYs7DwI3d67hGz4qOwaJ1dpm07hOAD1BXx6g== dependencies: - "@octokit/types" "^6.10.1" - deprecation "^2.3.1" + "@octokit/types" "^14.1.0" -"@octokit/request-error@^2.0.0", "@octokit/request-error@^2.0.5": - version "2.0.5" - resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-2.0.5.tgz#72cc91edc870281ad583a42619256b380c600143" - integrity sha512-T/2wcCFyM7SkXzNoyVNWjyVlUwBvW3igM3Btr/eKYiPmucXTtkxt2RBsf6gn3LTzaLSLTQtNmvg+dGsOxQrjZg== +"@octokit/request-error@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-7.0.0.tgz#48ae2cd79008315605d00e83664891a10a5ddb97" + integrity sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg== dependencies: - "@octokit/types" "^6.0.3" - deprecation "^2.0.0" - once "^1.4.0" + "@octokit/types" "^14.0.0" -"@octokit/request@^5.3.0", "@octokit/request@^5.4.12": - version "5.4.14" - resolved "https://registry.yarnpkg.com/@octokit/request/-/request-5.4.14.tgz#ec5f96f78333bb2af390afa5ff66f114b063bc96" - integrity sha512-VkmtacOIQp9daSnBmDI92xNIeLuSRDOIuplp/CJomkvzt7M18NXgG044Cx/LFKLgjKt9T2tZR6AtJayba9GTSA== - dependencies: - "@octokit/endpoint" "^6.0.1" - "@octokit/request-error" "^2.0.0" - "@octokit/types" "^6.7.1" - deprecation "^2.0.0" - is-plain-object "^5.0.0" - node-fetch "^2.6.1" - once "^1.4.0" - universal-user-agent "^6.0.0" +"@octokit/request@^10.0.2": + version "10.0.3" + resolved "https://registry.yarnpkg.com/@octokit/request/-/request-10.0.3.tgz#2ffdb88105ce20d25dcab8a592a7040ea48306c7" + integrity sha512-V6jhKokg35vk098iBqp2FBKunk3kMTXlmq+PtbV9Gl3TfskWlebSofU9uunVKhUN7xl+0+i5vt0TGTG8/p/7HA== + dependencies: + "@octokit/endpoint" "^11.0.0" + "@octokit/request-error" "^7.0.0" + "@octokit/types" "^14.0.0" + fast-content-type-parse "^3.0.0" + universal-user-agent "^7.0.2" + +"@octokit/rest@22.0.0": + version "22.0.0" + resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-22.0.0.tgz#9026f47dacba9c605da3d43cce9432c4c532dc5a" + integrity sha512-z6tmTu9BTnw51jYGulxrlernpsQYXpui1RK21vmXn8yF5bp6iX16yfTtJYGK5Mh1qDkvDOmp2n8sRMcQmR8jiA== + dependencies: + "@octokit/core" "^7.0.2" + "@octokit/plugin-paginate-rest" "^13.0.1" + "@octokit/plugin-request-log" "^6.0.0" + "@octokit/plugin-rest-endpoint-methods" "^16.0.0" -"@octokit/rest@18.2.1": - version "18.2.1" - resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-18.2.1.tgz#04835fe9ab0d90ca2a93898cde2aa944c78c70bc" - integrity sha512-DdQ1vps41JSyB2axyL1mBwJiXAPibgugIQPOmt0mL/yhwheQ6iuq2aKiJWgGWa9ldMfe3v9gIFYlrFgxQ5ThGQ== +"@octokit/types@14.1.0", "@octokit/types@^14.0.0", "@octokit/types@^14.1.0": + version "14.1.0" + resolved "https://registry.yarnpkg.com/@octokit/types/-/types-14.1.0.tgz#3bf9b3a3e3b5270964a57cc9d98592ed44f840f2" + integrity sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g== dependencies: - "@octokit/core" "^3.2.3" - "@octokit/plugin-paginate-rest" "^2.6.2" - "@octokit/plugin-request-log" "^1.0.2" - "@octokit/plugin-rest-endpoint-methods" "4.12.2" + "@octokit/openapi-types" "^25.1.0" -"@octokit/types@6.10.1": - version "6.10.1" - resolved "https://registry.yarnpkg.com/@octokit/types/-/types-6.10.1.tgz#5955dc0cf344bb82a46283a0c332651f5dd9f1ad" - integrity sha512-hgNC5jxKG8/RlqxU/6GThkGrvFpz25+cPzjQjyiXTNBvhyltn2Z4GhFY25+kbtXwZ4Co4zM0goW5jak1KLp1ug== +"@opentelemetry/api@^1.0.4": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.4.0.tgz#2c91791a9ba6ca0a0f4aaac5e45d58df13639ac8" + integrity sha512-IgMK9i3sFGNUqPMbjABm0G26g0QCKCUBfglhQ7rQq6WcxbKfEHRcmwsoER4hZcuYqJgkYn2OeuoJIv7Jsftp7g== + +"@opentelemetry/core@1.9.1", "@opentelemetry/core@^1.0.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-1.9.1.tgz#e343337e1a7bf30e9a6aef3ef659b9b76379762a" + integrity sha512-6/qon6tw2I8ZaJnHAQUUn4BqhTbTNRS0WP8/bA0ynaX+Uzp/DDbd0NS0Cq6TMlh8+mrlsyqDE7mO50nmv2Yvlg== dependencies: - "@octokit/openapi-types" "^5.1.0" + "@opentelemetry/semantic-conventions" "1.9.1" -"@octokit/types@^6.0.3", "@octokit/types@^6.11.0", "@octokit/types@^6.7.1": - version "6.12.2" - resolved "https://registry.yarnpkg.com/@octokit/types/-/types-6.12.2.tgz#5b44add079a478b8eb27d78cf384cc47e4411362" - integrity sha512-kCkiN8scbCmSq+gwdJV0iLgHc0O/GTPY1/cffo9kECu1MvatLPh9E+qFhfRIktKfHEA6ZYvv6S1B4Wnv3bi3pA== +"@opentelemetry/resources@1.9.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-1.9.1.tgz#5ad3d80ba968a3a0e56498ce4bc82a6a01f2682f" + integrity sha512-VqBGbnAfubI+l+yrtYxeLyOoL358JK57btPMJDd3TCOV3mV5TNBmzvOfmesM4NeTyXuGJByd3XvOHvFezLn3rQ== dependencies: - "@octokit/openapi-types" "^5.3.2" + "@opentelemetry/core" "1.9.1" + "@opentelemetry/semantic-conventions" "1.9.1" -"@octokit/types@^6.10.1": - version "6.41.0" - resolved "https://registry.yarnpkg.com/@octokit/types/-/types-6.41.0.tgz#e58ef78d78596d2fb7df9c6259802464b5f84a04" - integrity sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg== +"@opentelemetry/sdk-trace-base@^1.0.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.9.1.tgz#c349491b432a7e0ae7316f0b48b2d454d79d2b84" + integrity sha512-Y9gC5M1efhDLYHeeo2MWcDDMmR40z6QpqcWnPCm4Dmh+RHAMf4dnEBBntIe1dDpor686kyU6JV1D29ih1lZpsQ== dependencies: - "@octokit/openapi-types" "^12.11.0" + "@opentelemetry/core" "1.9.1" + "@opentelemetry/resources" "1.9.1" + "@opentelemetry/semantic-conventions" "1.9.1" + +"@opentelemetry/semantic-conventions@1.9.1", "@opentelemetry/semantic-conventions@^1.0.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.9.1.tgz#ad3367684a57879392513479e0a436cb2ac46dad" + integrity sha512-oPQdbFDmZvjXk5ZDoBGXG8B4tSB/qW5vQunJWQMFUBp7Xe8O1ByPANueJ+Jzg58esEBegyyxZ7LRmfJr7kFcFg== + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +"@playwright/browser-chromium@^1.53.1": + version "1.54.1" + resolved "https://registry.yarnpkg.com/@playwright/browser-chromium/-/browser-chromium-1.54.1.tgz#453bd419d2b2438f57f290d429ba93b8e5db4cb5" + integrity sha512-GFiRk7OvwlPrUXM3JGm5QgmzA0w2nyke0sYwigDL+rriQ+Ok7Vub0F3lIsxjHPEp5pfq+KQvzSWCMDXs0efMKQ== + dependencies: + playwright-core "1.54.1" + +"@rtsao/scc@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" + integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== "@sheerun/mutationobserver-shim@^0.3.2": version "0.3.3" resolved "https://registry.yarnpkg.com/@sheerun/mutationobserver-shim/-/mutationobserver-shim-0.3.3.tgz#5405ee8e444ed212db44e79351f0c70a582aae25" integrity sha512-DetpxZw1fzPD5xUBrIAoplLChO2VB8DlL5Gg+I1IR9b2wPqYIca2WSUxL5g1vLeR4MsQq1NeWriXAVffV+U1Fw== +"@shikijs/core@3.7.0": + version "3.7.0" + resolved "https://registry.yarnpkg.com/@shikijs/core/-/core-3.7.0.tgz#5300db80449f3c2d2fad825f3ec8095ea38355af" + integrity sha512-yilc0S9HvTPyahHpcum8eonYrQtmGTU0lbtwxhA6jHv4Bm1cAdlPFRCJX4AHebkCm75aKTjjRAW+DezqD1b/cg== + dependencies: + "@shikijs/types" "3.7.0" + "@shikijs/vscode-textmate" "^10.0.2" + "@types/hast" "^3.0.4" + hast-util-to-html "^9.0.5" + +"@shikijs/monaco@^3.7.0": + version "3.7.0" + resolved "https://registry.yarnpkg.com/@shikijs/monaco/-/monaco-3.7.0.tgz#036e97e9155dbdcb6971f76902e337e94a016ca1" + integrity sha512-WUPcNREKJnNz/oSsBNK2V2Z5sQCqS6hoWCEW9BXrIXNVjA7awLYniSmyXlckM1tHzI2hwDQuMxWApaGWMFH6BA== + dependencies: + "@shikijs/core" "3.7.0" + "@shikijs/types" "3.7.0" + "@shikijs/vscode-textmate" "^10.0.2" + +"@shikijs/types@3.7.0": + version "3.7.0" + resolved "https://registry.yarnpkg.com/@shikijs/types/-/types-3.7.0.tgz#265641647708663ec8a18a9fab29449076da5a17" + integrity sha512-MGaLeaRlSWpnP0XSAum3kP3a8vtcTsITqoEPYdt3lQG3YCdQH4DnEhodkYcNMcU0uW0RffhoD1O3e0vG5eSBBg== + dependencies: + "@shikijs/vscode-textmate" "^10.0.2" + "@types/hast" "^3.0.4" + +"@shikijs/vscode-textmate@^10.0.2": + version "10.0.2" + resolved "https://registry.yarnpkg.com/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz#a90ab31d0cc1dfb54c66a69e515bf624fa7b2224" + integrity sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg== + "@sinonjs/commons@^1", "@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0": version "1.8.2" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.2.tgz#858f5c4b48d80778fde4b9d541f27edc0d56488b" @@ -339,46 +798,52 @@ resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz#8da5c6530915653f3a1f38fd5f101d8c3f8079c5" integrity sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ== -"@tootallnate/once@1": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" - integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== - "@tootallnate/once@2": version "2.0.0" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== +"@tsconfig/node10@^1.0.7": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2" + integrity sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw== + +"@tsconfig/node12@^1.0.7": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d" + integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== + +"@tsconfig/node14@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1" + integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== + +"@tsconfig/node16@^1.0.2": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" + integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== + +"@tybys/wasm-util@^0.10.0": + version "0.10.1" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" + integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== + dependencies: + tslib "^2.4.0" + "@types/chai@^4.1.4": version "4.2.15" resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.15.tgz#b7a6d263c2cecf44b6de9a051cf496249b154553" integrity sha512-rYff6FI+ZTKAPkJUoyz7Udq3GaoDZnxYDEvdEdFZASiA7PoErltHezDishqQiSDWrGxvxmplH304jyzQmjp0AQ== -"@types/eslint-scope@^3.7.0": - version "3.7.0" - resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.0.tgz#4792816e31119ebd506902a482caec4951fabd86" - integrity sha512-O/ql2+rrCUe2W2rs7wMR+GqPRcgB6UiqN5RhrR5xruFlY7l9YLMn0ZkDzjoHLeiFkR8MCQZVudUuuvQ2BLC9Qw== - dependencies: - "@types/eslint" "*" - "@types/estree" "*" - -"@types/eslint@*": - version "7.2.7" - resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-7.2.7.tgz#f7ef1cf0dceab0ae6f9a976a0a9af14ab1baca26" - integrity sha512-EHXbc1z2GoQRqHaAT7+grxlTJ3WE2YNeD6jlpPoRc83cCoThRY+NUWjCUZaYmk51OICkPXn2hhphcWcWXgNW0Q== - dependencies: - "@types/estree" "*" - "@types/json-schema" "*" - -"@types/estree@*": - version "0.0.46" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.46.tgz#0fb6bfbbeabd7a30880504993369c4bf1deab1fe" - integrity sha512-laIjwTQaD+5DukBZaygQ79K1Z0jb1bPEMRrkXSLjtCcZm+abyp5YbrqpSLzD42FwWW6gK/aS4NYpJ804nG2brg== +"@types/estree@^1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" + integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== -"@types/estree@^0.0.50": - version "0.0.50" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.50.tgz#1e0caa9364d3fccd2931c3ed96fdbeaa5d4cca83" - integrity sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw== +"@types/estree@^1.0.6": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== "@types/glob@7.1.3": version "7.1.3" @@ -388,11 +853,23 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/hast@^3.0.0", "@types/hast@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.4.tgz#1d6b39993b82cea6ad783945b0508c25903e15aa" + integrity sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ== + dependencies: + "@types/unist" "*" + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762" integrity sha512-sz7iLqvVUg1gIedBOvlkxPlc8/uVzyS5OwGz1cKjXzkl3FpL3al0crU8YGU1WoHkxn0Wxbw5tyi6hvzJKNzFsw== +"@types/istanbul-lib-coverage@^2.0.1": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" + integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== + "@types/istanbul-lib-report@*": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#c14c24f18ea8190c118ee7562b7ff99a36552686" @@ -408,10 +885,15 @@ "@types/istanbul-lib-coverage" "*" "@types/istanbul-lib-report" "*" -"@types/json-schema@*", "@types/json-schema@^7.0.3", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.6": - version "7.0.7" - resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.7.tgz#98a993516c859eb0d5c4c8f098317a9ea68db9ad" - integrity sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA== +"@types/js-yaml@^4.0.9": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.9.tgz#cd82382c4f902fed9691a2ed79ec68c5898af4c2" + integrity sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg== + +"@types/json-schema@^7.0.15": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== "@types/json-schema@^7.0.8": version "7.0.9" @@ -433,6 +915,13 @@ resolved "https://registry.yarnpkg.com/@types/marked/-/marked-0.7.4.tgz#607685669bb1bbde2300bc58ba43486cbbee1f0a" integrity sha512-fdg0NO4qpuHWtZk6dASgsrBggY+8N4dWthl1bAQG9ceKUNKFjqpHaDKCAhRUI6y8vavG7hLSJ4YBwJtZyZEXqw== +"@types/mdast@^4.0.0": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-4.0.4.tgz#7ccf72edd2f1aa7dd3437e180c64373585804dd6" + integrity sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA== + dependencies: + "@types/unist" "*" + "@types/minimatch@*": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" @@ -443,6 +932,11 @@ resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40" integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ== +"@types/mocha@^10.0.2": + version "10.0.10" + resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.10.tgz#91f62905e8d23cbd66225312f239454a23bebfa0" + integrity sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q== + "@types/mocha@^8.2.2": version "8.2.3" resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-8.2.3.tgz#bbeb55fbc73f28ea6de601fbfa4613f58d785323" @@ -453,15 +947,12 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.35.tgz#42c953a4e2b18ab931f72477e7012172f4ffa313" integrity sha512-Lt+wj8NVPx0zUmUwumiVXapmaLUcAk3yPuHCFVXras9k5VT9TdhJqKqGVUQCD60OTMCl0qxJ57OiTL0Mic3Iag== -"@types/node@12.12.70": - version "12.12.70" - resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.70.tgz#adf70b179c3ee17620215ee4cb5c68c95f7c37ec" - integrity sha512-i5y7HTbvhonZQE+GnUM2rz1Bi8QkzxdQmEv1LKOv4nWyaQk/gdeiTApuQR3PDJHX7WomAbpx2wlWSEpxXGZ/UQ== - -"@types/parse-json@^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" - integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/node@22": + version "22.18.6" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.18.6.tgz#38172ef0b65e09d1a4fc715eb09a7d5decfdc748" + integrity sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ== + dependencies: + undici-types "~6.21.0" "@types/prop-types@*": version "15.7.3" @@ -501,10 +992,15 @@ dependencies: "@types/node" "*" -"@types/vscode@1.69.0": - version "1.69.0" - resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.69.0.tgz#a472011af392fbcf82cbb82f60b4c239c21b921c" - integrity sha512-RlzDAnGqUoo9wS6d4tthNyAdZLxOIddLiX3djMoWk29jFfSA1yJbIwr0epBYqqYarWB6s2Z+4VaZCQ80Jaa3kA== +"@types/unist@*", "@types/unist@^3.0.0": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c" + integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q== + +"@types/vscode@1.103.0": + version "1.103.0" + resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.103.0.tgz#4a0d9777d952992c9ebdbe8dad067032d2fbc1fb" + integrity sha512-o4hanZAQdNfsKecexq9L3eHICd0AAvdbLk6hA60UzGXbGH/q8b/9xv2RgR7vV3ZcHuyKVq7b37IGd/+gM4Tu+Q== "@types/webpack-env@^1.16.0": version "1.16.2" @@ -528,238 +1024,406 @@ resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.8.2.tgz#808c9fa7e4517274ed555fa158f2de4b4f468e71" integrity sha512-HrCIVMLjE1MOozVoD86622S7aunluLb2PJdPfb3nYiEtohm8mIB/vyv0Fd37AdeMFrTUQXEunw78YloMA3Qilg== -"@typescript-eslint/eslint-plugin@4.18.0": - version "4.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.18.0.tgz#50fbce93211b5b690895d20ebec6fe8db48af1f6" - integrity sha512-Lzkc/2+7EoH7+NjIWLS2lVuKKqbEmJhtXe3rmfA8cyiKnZm3IfLf51irnBcmow8Q/AptVV0XBZmBJKuUJTe6cQ== - dependencies: - "@typescript-eslint/experimental-utils" "4.18.0" - "@typescript-eslint/scope-manager" "4.18.0" - debug "^4.1.1" - functional-red-black-tree "^1.0.1" - lodash "^4.17.15" - regexpp "^3.0.0" - semver "^7.3.2" - tsutils "^3.17.1" - -"@typescript-eslint/experimental-utils@4.18.0": - version "4.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.18.0.tgz#ed6c955b940334132b17100d2917449b99a91314" - integrity sha512-92h723Kblt9JcT2RRY3QS2xefFKar4ZQFVs3GityOKWQYgtajxt/tuXIzL7sVCUlM1hgreiV5gkGYyBpdOwO6A== - dependencies: - "@types/json-schema" "^7.0.3" - "@typescript-eslint/scope-manager" "4.18.0" - "@typescript-eslint/types" "4.18.0" - "@typescript-eslint/typescript-estree" "4.18.0" - eslint-scope "^5.0.0" - eslint-utils "^2.0.0" - -"@typescript-eslint/parser@4.18.0": - version "4.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.18.0.tgz#a211edb14a69fc5177054bec04c95b185b4dde21" - integrity sha512-W3z5S0ZbecwX3PhJEAnq4mnjK5JJXvXUDBYIYGoweCyWyuvAKfGHvzmpUzgB5L4cRBb+cTu9U/ro66dx7dIimA== - dependencies: - "@typescript-eslint/scope-manager" "4.18.0" - "@typescript-eslint/types" "4.18.0" - "@typescript-eslint/typescript-estree" "4.18.0" - debug "^4.1.1" - -"@typescript-eslint/scope-manager@4.18.0": - version "4.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.18.0.tgz#d75b55234c35d2ff6ac945758d6d9e53be84a427" - integrity sha512-olX4yN6rvHR2eyFOcb6E4vmhDPsfdMyfQ3qR+oQNkAv8emKKlfxTWUXU5Mqxs2Fwe3Pf1BoPvrwZtwngxDzYzQ== - dependencies: - "@typescript-eslint/types" "4.18.0" - "@typescript-eslint/visitor-keys" "4.18.0" - -"@typescript-eslint/types@4.18.0": - version "4.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.18.0.tgz#bebe323f81f2a7e2e320fac9415e60856267584a" - integrity sha512-/BRociARpj5E+9yQ7cwCF/SNOWwXJ3qhjurMuK2hIFUbr9vTuDeu476Zpu+ptxY2kSxUHDGLLKy+qGq2sOg37A== - -"@typescript-eslint/typescript-estree@4.18.0": - version "4.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.18.0.tgz#756d3e61da8c16ab99185532c44872f4cd5538cb" - integrity sha512-wt4xvF6vvJI7epz+rEqxmoNQ4ZADArGQO9gDU+cM0U5fdVv7N+IAuVoVAoZSOZxzGHBfvE3XQMLdy+scsqFfeg== - dependencies: - "@typescript-eslint/types" "4.18.0" - "@typescript-eslint/visitor-keys" "4.18.0" - debug "^4.1.1" - globby "^11.0.1" - is-glob "^4.0.1" - semver "^7.3.2" - tsutils "^3.17.1" - -"@typescript-eslint/visitor-keys@4.18.0": - version "4.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.18.0.tgz#4e6fe2a175ee33418318a029610845a81e2ff7b6" - integrity sha512-Q9t90JCvfYaN0OfFUgaLqByOfz8yPeTAdotn/XYNm5q9eHax90gzdb+RJ6E9T5s97Kv/UHWKERTmqA0jTKAEHw== - dependencies: - "@typescript-eslint/types" "4.18.0" - eslint-visitor-keys "^2.0.0" +"@typescript-eslint/eslint-plugin@8.44.1", "@typescript-eslint/eslint-plugin@^8.44.0": + version "8.44.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz#011a2b5913d297b3d9d77f64fb78575bab01a1b3" + integrity sha512-molgphGqOBT7t4YKCSkbasmu1tb1MgrZ2szGzHbclF7PNmOkSTQVHy+2jXOSnxvR3+Xe1yySHFZoqMpz3TfQsw== + dependencies: + "@eslint-community/regexpp" "^4.10.0" + "@typescript-eslint/scope-manager" "8.44.1" + "@typescript-eslint/type-utils" "8.44.1" + "@typescript-eslint/utils" "8.44.1" + "@typescript-eslint/visitor-keys" "8.44.1" + graphemer "^1.4.0" + ignore "^7.0.0" + natural-compare "^1.4.0" + ts-api-utils "^2.1.0" + +"@typescript-eslint/parser@8.44.1", "@typescript-eslint/parser@^8.44.0": + version "8.44.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.44.1.tgz#d4c85791389462823596ad46e2b90d34845e05eb" + integrity sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw== + dependencies: + "@typescript-eslint/scope-manager" "8.44.1" + "@typescript-eslint/types" "8.44.1" + "@typescript-eslint/typescript-estree" "8.44.1" + "@typescript-eslint/visitor-keys" "8.44.1" + debug "^4.3.4" + +"@typescript-eslint/project-service@8.44.1": + version "8.44.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.44.1.tgz#1bccd9796d25032b190f355f55c5fde061158abb" + integrity sha512-ycSa60eGg8GWAkVsKV4E6Nz33h+HjTXbsDT4FILyL8Obk5/mx4tbvCNsLf9zret3ipSumAOG89UcCs/KRaKYrA== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.44.1" + "@typescript-eslint/types" "^8.44.1" + debug "^4.3.4" + +"@typescript-eslint/scope-manager@8.44.1": + version "8.44.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.44.1.tgz#31c27f92e4aed8d0f4d6fe2b9e5187d1d8797bd7" + integrity sha512-NdhWHgmynpSvyhchGLXh+w12OMT308Gm25JoRIyTZqEbApiBiQHD/8xgb6LqCWCFcxFtWwaVdFsLPQI3jvhywg== + dependencies: + "@typescript-eslint/types" "8.44.1" + "@typescript-eslint/visitor-keys" "8.44.1" + +"@typescript-eslint/tsconfig-utils@8.44.1", "@typescript-eslint/tsconfig-utils@^8.44.1": + version "8.44.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.44.1.tgz#e1d9d047078fac37d3e638484ab3b56215963342" + integrity sha512-B5OyACouEjuIvof3o86lRMvyDsFwZm+4fBOqFHccIctYgBjqR3qT39FBYGN87khcgf0ExpdCBeGKpKRhSFTjKQ== + +"@typescript-eslint/type-utils@8.44.1": + version "8.44.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.44.1.tgz#be9d31e0f911d17ee8ac99921bb74cf1f9df3906" + integrity sha512-KdEerZqHWXsRNKjF9NYswNISnFzXfXNDfPxoTh7tqohU/PRIbwTmsjGK6V9/RTYWau7NZvfo52lgVk+sJh0K3g== + dependencies: + "@typescript-eslint/types" "8.44.1" + "@typescript-eslint/typescript-estree" "8.44.1" + "@typescript-eslint/utils" "8.44.1" + debug "^4.3.4" + ts-api-utils "^2.1.0" + +"@typescript-eslint/types@8.44.1", "@typescript-eslint/types@^8.44.1": + version "8.44.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.44.1.tgz#85d1cad1290a003ff60420388797e85d1c3f76ff" + integrity sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ== + +"@typescript-eslint/typescript-estree@8.44.1": + version "8.44.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.44.1.tgz#4f17650e5adabecfcc13cd8c517937a4ef5cd424" + integrity sha512-qnQJ+mVa7szevdEyvfItbO5Vo+GfZ4/GZWWDRRLjrxYPkhM+6zYB2vRYwCsoJLzqFCdZT4mEqyJoyzkunsZ96A== + dependencies: + "@typescript-eslint/project-service" "8.44.1" + "@typescript-eslint/tsconfig-utils" "8.44.1" + "@typescript-eslint/types" "8.44.1" + "@typescript-eslint/visitor-keys" "8.44.1" + debug "^4.3.4" + fast-glob "^3.3.2" + is-glob "^4.0.3" + minimatch "^9.0.4" + semver "^7.6.0" + ts-api-utils "^2.1.0" + +"@typescript-eslint/utils@8.44.1": + version "8.44.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.44.1.tgz#f23d48eb90791a821dc17d4f67bb96faeb75d63d" + integrity sha512-DpX5Fp6edTlocMCwA+mHY8Mra+pPjRZ0TfHkXI8QFelIKcbADQz1LUPNtzOFUriBB2UYqw4Pi9+xV4w9ZczHFg== + dependencies: + "@eslint-community/eslint-utils" "^4.7.0" + "@typescript-eslint/scope-manager" "8.44.1" + "@typescript-eslint/types" "8.44.1" + "@typescript-eslint/typescript-estree" "8.44.1" + +"@typescript-eslint/visitor-keys@8.44.1": + version "8.44.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.44.1.tgz#1d96197a7fcceaba647b3bd6a8594df8dc4deb5a" + integrity sha512-576+u0QD+Jp3tZzvfRfxon0EA2lzcDt3lhUbsC6Lgzy9x2VR4E+JUiNyGHi5T8vk0TV+fpJ5GLG1JsJuWCaKhw== + dependencies: + "@typescript-eslint/types" "8.44.1" + eslint-visitor-keys "^4.2.1" "@ungap/promise-all-settled@1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44" integrity sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q== -"@vscode/extension-telemetry@0.6.2": - version "0.6.2" - resolved "https://registry.yarnpkg.com/@vscode/extension-telemetry/-/extension-telemetry-0.6.2.tgz#b86814ee680615730da94220c2b03ea9c3c14a8e" - integrity sha512-yb/wxLuaaCRcBAZtDCjNYSisAXz3FWsSqAha5nhHcYxx2ZPdQdWuZqVXGKq0ZpHVndBWWtK6XqtpCN2/HB4S1w== - dependencies: - "@microsoft/1ds-core-js" "^3.2.3" - "@microsoft/1ds-post-js" "^3.2.3" +"@ungap/structured-clone@^1.0.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" + integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== -"@vscode/test-electron@^2.1.5": - version "2.1.5" - resolved "https://registry.yarnpkg.com/@vscode/test-electron/-/test-electron-2.1.5.tgz#ac98f8f445ea4590753f5fa0c7f6e4298f08c3b7" - integrity sha512-O/ioqFpV+RvKbRykX2ItYPnbcZ4Hk5V0rY4uhQjQTLhGL9WZUvS7exzuYQCCI+ilSqJpctvxq2llTfGXf9UnnA== - dependencies: - http-proxy-agent "^4.0.1" - https-proxy-agent "^5.0.0" - rimraf "^3.0.2" - unzipper "^0.10.11" +"@unrs/resolver-binding-android-arm-eabi@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz#9f5b04503088e6a354295e8ea8fe3cb99e43af81" + integrity sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw== -"@vscode/test-web@^0.0.29": - version "0.0.29" - resolved "https://registry.yarnpkg.com/@vscode/test-web/-/test-web-0.0.29.tgz#00f19159cf3ae70fdfae4a909df66e407c8b5e56" - integrity sha512-QJwu3F6U+IT/X6UiRVQEe1tKSB1aRVDlWi5jAfnbXaAH8Gk4NrUFLxAB33mms82XQK4PuCTXAqNd/eC8v3ZQDA== - dependencies: - "@koa/cors" "^3.3.0" - "@koa/router" "^10.1.1" - decompress "^4.2.1" - decompress-targz "^4.1.1" - get-stream "6.0.1" - http-proxy-agent "^5.0.0" - https-proxy-agent "^5.0.1" - koa "^2.13.4" - koa-morgan "^1.0.1" - koa-mount "^4.0.0" - koa-static "^5.0.0" - minimist "^1.2.6" - playwright "^1.23.1" - vscode-uri "^3.0.3" +"@unrs/resolver-binding-android-arm64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz#7414885431bd7178b989aedc4d25cccb3865bc9f" + integrity sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g== -"@webassemblyjs/ast@1.11.1": +"@unrs/resolver-binding-darwin-arm64@1.11.1": version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.1.tgz#2bfd767eae1a6996f432ff7e8d7fc75679c0b6a7" - integrity sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw== - dependencies: - "@webassemblyjs/helper-numbers" "1.11.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz#b4a8556f42171fb9c9f7bac8235045e82aa0cbdf" + integrity sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g== -"@webassemblyjs/floating-point-hex-parser@1.11.1": +"@unrs/resolver-binding-darwin-x64@1.11.1": version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz#f6c61a705f0fd7a6aecaa4e8198f23d9dc179e4f" - integrity sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ== + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz#fd4d81257b13f4d1a083890a6a17c00de571f0dc" + integrity sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ== -"@webassemblyjs/helper-api-error@1.11.1": +"@unrs/resolver-binding-freebsd-x64@1.11.1": version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz#1a63192d8788e5c012800ba6a7a46c705288fd16" - integrity sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg== + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz#d2513084d0f37c407757e22f32bd924a78cfd99b" + integrity sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw== -"@webassemblyjs/helper-buffer@1.11.1": +"@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1": version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz#832a900eb444884cde9a7cad467f81500f5e5ab5" - integrity sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA== + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz#844d2605d057488d77fab09705f2866b86164e0a" + integrity sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw== -"@webassemblyjs/helper-numbers@1.11.1": +"@unrs/resolver-binding-linux-arm-musleabihf@1.11.1": version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz#64d81da219fbbba1e3bd1bfc74f6e8c4e10a62ae" - integrity sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ== - dependencies: - "@webassemblyjs/floating-point-hex-parser" "1.11.1" - "@webassemblyjs/helper-api-error" "1.11.1" - "@xtuc/long" "4.2.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz#204892995cefb6bd1d017d52d097193bc61ddad3" + integrity sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw== -"@webassemblyjs/helper-wasm-bytecode@1.11.1": +"@unrs/resolver-binding-linux-arm64-gnu@1.11.1": version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz#f328241e41e7b199d0b20c18e88429c4433295e1" - integrity sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q== + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz#023eb0c3aac46066a10be7a3f362e7b34f3bdf9d" + integrity sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ== -"@webassemblyjs/helper-wasm-section@1.11.1": +"@unrs/resolver-binding-linux-arm64-musl@1.11.1": version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz#21ee065a7b635f319e738f0dd73bfbda281c097a" - integrity sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg== - dependencies: - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/helper-buffer" "1.11.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.1" - "@webassemblyjs/wasm-gen" "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz#9e6f9abb06424e3140a60ac996139786f5d99be0" + integrity sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w== -"@webassemblyjs/ieee754@1.11.1": +"@unrs/resolver-binding-linux-ppc64-gnu@1.11.1": version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz#963929e9bbd05709e7e12243a099180812992614" - integrity sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ== - dependencies: - "@xtuc/ieee754" "^1.2.0" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz#b111417f17c9d1b02efbec8e08398f0c5527bb44" + integrity sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA== -"@webassemblyjs/leb128@1.11.1": +"@unrs/resolver-binding-linux-riscv64-gnu@1.11.1": version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.11.1.tgz#ce814b45574e93d76bae1fb2644ab9cdd9527aa5" - integrity sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw== - dependencies: - "@xtuc/long" "4.2.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz#92ffbf02748af3e99873945c9a8a5ead01d508a9" + integrity sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ== -"@webassemblyjs/utf8@1.11.1": +"@unrs/resolver-binding-linux-riscv64-musl@1.11.1": version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.11.1.tgz#d1f8b764369e7c6e6bae350e854dec9a59f0a3ff" - integrity sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ== + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz#0bec6f1258fc390e6b305e9ff44256cb207de165" + integrity sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew== -"@webassemblyjs/wasm-edit@1.11.1": +"@unrs/resolver-binding-linux-s390x-gnu@1.11.1": version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz#ad206ebf4bf95a058ce9880a8c092c5dec8193d6" - integrity sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA== - dependencies: - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/helper-buffer" "1.11.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.1" - "@webassemblyjs/helper-wasm-section" "1.11.1" - "@webassemblyjs/wasm-gen" "1.11.1" - "@webassemblyjs/wasm-opt" "1.11.1" - "@webassemblyjs/wasm-parser" "1.11.1" - "@webassemblyjs/wast-printer" "1.11.1" - -"@webassemblyjs/wasm-gen@1.11.1": + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz#577843a084c5952f5906770633ccfb89dac9bc94" + integrity sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg== + +"@unrs/resolver-binding-linux-x64-gnu@1.11.1": version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz#86c5ea304849759b7d88c47a32f4f039ae3c8f76" - integrity sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA== - dependencies: - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.1" - "@webassemblyjs/ieee754" "1.11.1" - "@webassemblyjs/leb128" "1.11.1" - "@webassemblyjs/utf8" "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz#36fb318eebdd690f6da32ac5e0499a76fa881935" + integrity sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w== -"@webassemblyjs/wasm-opt@1.11.1": +"@unrs/resolver-binding-linux-x64-musl@1.11.1": version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz#657b4c2202f4cf3b345f8a4c6461c8c2418985f2" - integrity sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw== - dependencies: - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/helper-buffer" "1.11.1" - "@webassemblyjs/wasm-gen" "1.11.1" - "@webassemblyjs/wasm-parser" "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz#bfb9af75f783f98f6a22c4244214efe4df1853d6" + integrity sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA== -"@webassemblyjs/wasm-parser@1.11.1": +"@unrs/resolver-binding-wasm32-wasi@1.11.1": version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz#86ca734534f417e9bd3c67c7a1c75d8be41fb199" - integrity sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA== + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz#752c359dd875684b27429500d88226d7cc72f71d" + integrity sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ== dependencies: - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/helper-api-error" "1.11.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.1" - "@webassemblyjs/ieee754" "1.11.1" - "@webassemblyjs/leb128" "1.11.1" - "@webassemblyjs/utf8" "1.11.1" + "@napi-rs/wasm-runtime" "^0.2.11" + +"@unrs/resolver-binding-win32-arm64-msvc@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz#ce5735e600e4c2fbb409cd051b3b7da4a399af35" + integrity sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw== -"@webassemblyjs/wast-printer@1.11.1": +"@unrs/resolver-binding-win32-ia32-msvc@1.11.1": version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz#d0c73beda8eec5426f10ae8ef55cee5e7084c2f0" - integrity sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg== + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz#72fc57bc7c64ec5c3de0d64ee0d1810317bc60a6" + integrity sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ== + +"@unrs/resolver-binding-win32-x64-msvc@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777" + integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g== + +"@vscode/codicons@^0.0.36": + version "0.0.36" + resolved "https://registry.yarnpkg.com/@vscode/codicons/-/codicons-0.0.36.tgz#ccdabfaef5db596b266644ab85fc25aa701058f0" + integrity sha512-wsNOvNMMJ2BY8rC2N2MNBG7yOowV3ov8KlvUE/AiVUlHKTfWsw3OgAOQduX7h0Un6GssKD3aoTVH+TF3DSQwKQ== + +"@vscode/dts@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@vscode/dts/-/dts-0.4.1.tgz#1946cf09db412def5fe5ecac9b9ff3e058546654" + integrity sha512-o8cI5Vqt6S6Y5mCI7yCkSQdiLQaLG5DMUpciJV3zReZwE+dA5KERxSVX8H3cPEhyKw21XwKGmIrg6YmN6M5uZA== dependencies: - "@webassemblyjs/ast" "1.11.1" + https-proxy-agent "^7.0.0" + minimist "^1.2.8" + prompts "^2.4.2" + +"@vscode/extension-telemetry@0.7.5": + version "0.7.5" + resolved "https://registry.yarnpkg.com/@vscode/extension-telemetry/-/extension-telemetry-0.7.5.tgz#bf965731816e08c3f146f96d901ec67954fc913b" + integrity sha512-fJ5y3TcpqqkFYHneabYaoB4XAhDdVflVm+TDKshw9VOs77jkgNS4UA7LNXrWeO0eDne3Sh3JgURf+xzc1rk69w== + dependencies: + "@microsoft/1ds-core-js" "^3.2.8" + "@microsoft/1ds-post-js" "^3.2.8" + "@microsoft/applicationinsights-web-basic" "^2.8.9" + applicationinsights "2.4.1" + +"@vscode/prompt-tsx@^0.3.0-alpha.12": + version "0.3.0-alpha.12" + resolved "https://registry.yarnpkg.com/@vscode/prompt-tsx/-/prompt-tsx-0.3.0-alpha.12.tgz#c9afbf7029ea0289e3cd359798e4bfc237c1ab92" + integrity sha512-2ANm569UBXIzjPbaDFjzRkucelhsnlnmYIPdDo+USeFq2Do0Q70gKiiRWYrQf5rPqCxrChDvgU14nsdJLUSaOQ== + +"@vscode/test-cli@^0.0.11": + version "0.0.11" + resolved "https://registry.yarnpkg.com/@vscode/test-cli/-/test-cli-0.0.11.tgz#043b2c920ef1b115626eaabc5b02cd956044a51d" + integrity sha512-qO332yvzFqGhBMJrp6TdwbIydiHgCtxXc2Nl6M58mbH/Z+0CyLR76Jzv4YWPEthhrARprzCRJUqzFvTHFhTj7Q== + dependencies: + "@types/mocha" "^10.0.2" + c8 "^9.1.0" + chokidar "^3.5.3" + enhanced-resolve "^5.15.0" + glob "^10.3.10" + minimatch "^9.0.3" + mocha "^11.1.0" + supports-color "^9.4.0" + yargs "^17.7.2" + +"@vscode/test-electron@^2.5.2": + version "2.5.2" + resolved "https://registry.yarnpkg.com/@vscode/test-electron/-/test-electron-2.5.2.tgz#f7d4078e8230ce9c94322f2a29cc16c17954085d" + integrity sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg== + dependencies: + http-proxy-agent "^7.0.2" + https-proxy-agent "^7.0.5" + jszip "^3.10.1" + ora "^8.1.0" + semver "^7.6.2" + +"@vscode/test-web@^0.0.71": + version "0.0.71" + resolved "https://registry.yarnpkg.com/@vscode/test-web/-/test-web-0.0.71.tgz#b29211b9f34dfc45b3fcea44fbe9c9aa14580cdd" + integrity sha512-uj9a3A3QD1qBOw1ZL19SKNSG6c6rvP9N4XrMvBVKSeAOkmOQftAZoBERLMJPEaJ8Z5dF7aLmA79drjOBk+VTRg== + dependencies: + "@koa/cors" "^5.0.0" + "@koa/router" "^13.1.0" + "@playwright/browser-chromium" "^1.53.1" + gunzip-maybe "^1.4.2" + http-proxy-agent "^7.0.2" + https-proxy-agent "^7.0.6" + koa "^3.0.0" + koa-morgan "^1.0.1" + koa-mount "^4.2.0" + koa-static "^5.0.0" + minimist "^1.2.8" + playwright "^1.53.1" + tar-fs "^3.1.0" + tinyglobby "0.2.14" + vscode-uri "^3.1.0" + +"@webassemblyjs/ast@1.12.1", "@webassemblyjs/ast@^1.12.1": + version "1.12.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.12.1.tgz#bb16a0e8b1914f979f45864c23819cc3e3f0d4bb" + integrity sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg== + dependencies: + "@webassemblyjs/helper-numbers" "1.11.6" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + +"@webassemblyjs/floating-point-hex-parser@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz#dacbcb95aff135c8260f77fa3b4c5fea600a6431" + integrity sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw== + +"@webassemblyjs/helper-api-error@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz#6132f68c4acd59dcd141c44b18cbebbd9f2fa768" + integrity sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q== + +"@webassemblyjs/helper-buffer@1.12.1": + version "1.12.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz#6df20d272ea5439bf20ab3492b7fb70e9bfcb3f6" + integrity sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw== + +"@webassemblyjs/helper-numbers@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz#cbce5e7e0c1bd32cf4905ae444ef64cea919f1b5" + integrity sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g== + dependencies: + "@webassemblyjs/floating-point-hex-parser" "1.11.6" + "@webassemblyjs/helper-api-error" "1.11.6" + "@xtuc/long" "4.2.2" + +"@webassemblyjs/helper-wasm-bytecode@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz#bb2ebdb3b83aa26d9baad4c46d4315283acd51e9" + integrity sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA== + +"@webassemblyjs/helper-wasm-section@1.12.1": + version "1.12.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz#3da623233ae1a60409b509a52ade9bc22a37f7bf" + integrity sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g== + dependencies: + "@webassemblyjs/ast" "1.12.1" + "@webassemblyjs/helper-buffer" "1.12.1" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + "@webassemblyjs/wasm-gen" "1.12.1" + +"@webassemblyjs/ieee754@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz#bb665c91d0b14fffceb0e38298c329af043c6e3a" + integrity sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg== + dependencies: + "@xtuc/ieee754" "^1.2.0" + +"@webassemblyjs/leb128@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.11.6.tgz#70e60e5e82f9ac81118bc25381a0b283893240d7" + integrity sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ== + dependencies: + "@xtuc/long" "4.2.2" + +"@webassemblyjs/utf8@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.11.6.tgz#90f8bc34c561595fe156603be7253cdbcd0fab5a" + integrity sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA== + +"@webassemblyjs/wasm-edit@^1.12.1": + version "1.12.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz#9f9f3ff52a14c980939be0ef9d5df9ebc678ae3b" + integrity sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g== + dependencies: + "@webassemblyjs/ast" "1.12.1" + "@webassemblyjs/helper-buffer" "1.12.1" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + "@webassemblyjs/helper-wasm-section" "1.12.1" + "@webassemblyjs/wasm-gen" "1.12.1" + "@webassemblyjs/wasm-opt" "1.12.1" + "@webassemblyjs/wasm-parser" "1.12.1" + "@webassemblyjs/wast-printer" "1.12.1" + +"@webassemblyjs/wasm-gen@1.12.1": + version "1.12.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz#a6520601da1b5700448273666a71ad0a45d78547" + integrity sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w== + dependencies: + "@webassemblyjs/ast" "1.12.1" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + "@webassemblyjs/ieee754" "1.11.6" + "@webassemblyjs/leb128" "1.11.6" + "@webassemblyjs/utf8" "1.11.6" + +"@webassemblyjs/wasm-opt@1.12.1": + version "1.12.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz#9e6e81475dfcfb62dab574ac2dda38226c232bc5" + integrity sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg== + dependencies: + "@webassemblyjs/ast" "1.12.1" + "@webassemblyjs/helper-buffer" "1.12.1" + "@webassemblyjs/wasm-gen" "1.12.1" + "@webassemblyjs/wasm-parser" "1.12.1" + +"@webassemblyjs/wasm-parser@1.12.1", "@webassemblyjs/wasm-parser@^1.12.1": + version "1.12.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz#c47acb90e6f083391e3fa61d113650eea1e95937" + integrity sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ== + dependencies: + "@webassemblyjs/ast" "1.12.1" + "@webassemblyjs/helper-api-error" "1.11.6" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + "@webassemblyjs/ieee754" "1.11.6" + "@webassemblyjs/leb128" "1.11.6" + "@webassemblyjs/utf8" "1.11.6" + +"@webassemblyjs/wast-printer@1.12.1": + version "1.12.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz#bcecf661d7d1abdaf989d8341a4833e33e2b31ac" + integrity sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA== + dependencies: + "@webassemblyjs/ast" "1.12.1" "@xtuc/long" "4.2.2" "@webpack-cli/info@^1.1.0": @@ -804,13 +1468,13 @@ abab@^2.0.5, abab@^2.0.6: resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== -accepts@^1.3.5: - version "1.3.7" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" - integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA== +accepts@^1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== dependencies: - mime-types "~2.1.24" - negotiator "0.6.2" + mime-types "~2.1.34" + negotiator "0.6.3" acorn-globals@^6.0.0: version "6.0.0" @@ -820,30 +1484,42 @@ acorn-globals@^6.0.0: acorn "^7.1.1" acorn-walk "^7.1.1" -acorn-import-assertions@^1.7.6: - version "1.8.0" - resolved "https://registry.yarnpkg.com/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz#ba2b5939ce62c238db6d93d81c9b111b29b855e9" - integrity sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw== +acorn-import-attributes@^1.9.5: + version "1.9.5" + resolved "https://registry.yarnpkg.com/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz#7eb1557b1ba05ef18b5ed0ec67591bfab04688ef" + integrity sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ== -acorn-jsx@^5.3.1: - version "5.3.1" - resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.1.tgz#fc8661e11b7ac1539c47dbfea2e72b3af34d267b" - integrity sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng== +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== acorn-walk@^7.1.1: version "7.2.0" resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== -acorn@^7.1.1, acorn@^7.4.0: +acorn-walk@^8.1.1: + version "8.3.4" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.4.tgz#794dd169c3977edf4ba4ea47583587c5866236b7" + integrity sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g== + dependencies: + acorn "^8.11.0" + +acorn@^7.1.1: version "7.4.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -acorn@^8.4.1, acorn@^8.5.0: - version "8.7.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" - integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== +acorn@^8.11.0, acorn@^8.15.0, acorn@^8.4.1: + version "8.15.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== + +acorn@^8.5.0, acorn@^8.7.1, acorn@^8.8.2: + version "8.12.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.1.tgz#71616bdccbe25e27a54439e0046e89ca76df2248" + integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg== agent-base@6: version "6.0.2" @@ -852,12 +1528,17 @@ agent-base@6: dependencies: debug "4" -ajv-keywords@^3.4.1, ajv-keywords@^3.5.2: +agent-base@^7.1.0, agent-base@^7.1.2: + version "7.1.4" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8" + integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== + +ajv-keywords@^3.5.2: version "3.5.2" resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== -ajv@^6.10.0, ajv@^6.12.2, ajv@^6.12.4, ajv@^6.12.5: +ajv@^6.12.4, ajv@^6.12.5: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== @@ -867,16 +1548,6 @@ ajv@^6.10.0, ajv@^6.12.2, ajv@^6.12.4, ajv@^6.12.5: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ajv@^7.0.2: - version "7.2.1" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-7.2.1.tgz#a5ac226171912447683524fa2f1248fcf8bac83d" - integrity sha512-+nu0HDv7kNSOua9apAVc979qd932rrZeb3WOvoiD31A/p1mIE5/9bN2027pE2rOPYEdS3UHzsvof4hY+lM9/WQ== - dependencies: - fast-deep-equal "^3.1.1" - json-schema-traverse "^1.0.0" - require-from-string "^2.0.2" - uri-js "^4.2.2" - ansi-colors@4.1.1, ansi-colors@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" @@ -899,10 +1570,15 @@ ansi-regex@^4.0.0: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== -ansi-regex@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" - integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.1.0.tgz#95ec409c69619d6cb1b8b34f14b660ef28ebd654" + integrity sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA== ansi-styles@^3.2.0, ansi-styles@^3.2.1: version "3.2.1" @@ -918,23 +1594,20 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" +ansi-styles@^6.1.0: + version "6.2.3" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== + ansi-wrap@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/ansi-wrap/-/ansi-wrap-0.1.0.tgz#a82250ddb0015e9a27ca82e82ea603bbfa45efaf" integrity sha512-ZyznvL8k/FZeQHr2T6LzcJ/+vBApDnMNZvfVFy3At0knswWd6rJ3/0Hhmpu8oqa6C92npmozs890sX9Dl6q+Qw== -anymatch@~3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.1.tgz#c55ecf02185e2469259399310c173ce31233b142" - integrity sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg== - dependencies: - normalize-path "^3.0.0" - picomatch "^2.0.4" - anymatch@~3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" - integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== dependencies: normalize-path "^3.0.0" picomatch "^2.0.4" @@ -1049,12 +1722,27 @@ append-buffer@^1.0.2: dependencies: buffer-equal "^1.0.0" -argparse@^1.0.7: - version "1.0.10" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" - integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== - dependencies: - sprintf-js "~1.0.2" +applicationinsights@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/applicationinsights/-/applicationinsights-2.4.1.tgz#4de4c4dd3c7c4a44445cfbf3d15808fc0dcc423d" + integrity sha512-0n0Ikd0gzSm460xm+M0UTWIwXrhrH/0bqfZatcJjYObWyefxfAxapGEyNnSGd1Tg90neHz+Yhf+Ff/zgvPiQYA== + dependencies: + "@azure/core-auth" "^1.4.0" + "@azure/core-rest-pipeline" "^1.10.0" + "@microsoft/applicationinsights-web-snippet" "^1.0.1" + "@opentelemetry/api" "^1.0.4" + "@opentelemetry/core" "^1.0.1" + "@opentelemetry/sdk-trace-base" "^1.0.1" + "@opentelemetry/semantic-conventions" "^1.0.1" + cls-hooked "^4.2.2" + continuation-local-storage "^3.2.1" + diagnostic-channel "1.1.0" + diagnostic-channel-publishers "1.0.5" + +arg@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== argparse@^2.0.1: version "2.0.1" @@ -1076,35 +1764,83 @@ array-back@^4.0.1: resolved "https://registry.yarnpkg.com/array-back/-/array-back-4.0.1.tgz#9b80312935a52062e1a233a9c7abeb5481b30e90" integrity sha512-Z/JnaVEXv+A9xabHzN43FiiiWEE7gPCRXMrVmRm00tWbjZRul1iHm7ECzlyNq1p4a4ATXz+G9FJ3GqGOkOV3fg== +array-buffer-byte-length@^1.0.1, array-buffer-byte-length@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz#384d12a37295aec3769ab022ad323a18a51ccf8b" + integrity sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw== + dependencies: + call-bound "^1.0.3" + is-array-buffer "^3.0.5" + array-differ@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/array-differ/-/array-differ-3.0.0.tgz#3cbb3d0f316810eafcc47624734237d6aee4ae6b" integrity sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg== -array-includes@^3.1.1: - version "3.1.3" - resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.3.tgz#c7f619b382ad2afaf5326cddfdc0afc61af7690a" - integrity sha512-gcem1KlBU7c9rB+Rq8/3PPKsK2kjqeEBa3bD5kkQo4nYlOHQCJqIJFqBXDEfwaRuYTT4E+FxA9xez7Gf/e3Q7A== +array-includes@^3.1.8: + version "3.1.9" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.9.tgz#1f0ccaa08e90cdbc3eb433210f903ad0f17c3f3a" + integrity sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ== dependencies: - call-bind "^1.0.2" - define-properties "^1.1.3" - es-abstract "^1.18.0-next.2" - get-intrinsic "^1.1.1" - is-string "^1.0.5" + call-bind "^1.0.8" + call-bound "^1.0.4" + define-properties "^1.2.1" + es-abstract "^1.24.0" + es-object-atoms "^1.1.1" + get-intrinsic "^1.3.0" + is-string "^1.1.1" + math-intrinsics "^1.1.0" array-union@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== -array.prototype.flat@^1.2.3: - version "1.2.4" - resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.4.tgz#6ef638b43312bd401b4c6199fdec7e2dc9e9a123" - integrity sha512-4470Xi3GAPAjZqFcljX2xzckv1qeKPizoNkiS0+O4IoPR2ZNpcjE0pkhdihlDouK+x6QOast26B4Q/O9DJnwSg== +array.prototype.findlastindex@^1.2.5: + version "1.2.6" + resolved "https://registry.yarnpkg.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz#cfa1065c81dcb64e34557c9b81d012f6a421c564" + integrity sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.4" + define-properties "^1.2.1" + es-abstract "^1.23.9" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + es-shim-unscopables "^1.1.0" + +array.prototype.flat@^1.3.2: + version "1.3.3" + resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz#534aaf9e6e8dd79fb6b9a9917f839ef1ec63afe5" + integrity sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg== dependencies: - call-bind "^1.0.0" - define-properties "^1.1.3" - es-abstract "^1.18.0-next.1" + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-shim-unscopables "^1.0.2" + +array.prototype.flatmap@^1.3.2: + version "1.3.3" + resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz#712cc792ae70370ae40586264629e33aab5dd38b" + integrity sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-shim-unscopables "^1.0.2" + +arraybuffer.prototype.slice@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz#9d760d84dbdd06d0cbf92c8849615a1a7ab3183c" + integrity sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ== + dependencies: + array-buffer-byte-length "^1.0.1" + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + is-array-buffer "^3.0.4" arrify@^2.0.1: version "2.0.1" @@ -1136,37 +1872,85 @@ assign-symbols@^1.0.0: resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" integrity sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw== -astral-regex@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" - integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== +async-function@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/async-function/-/async-function-1.0.0.tgz#509c9fca60eaf85034c6829838188e4e4c8ffb2b" + integrity sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA== + +async-hook-jl@^1.7.6: + version "1.7.6" + resolved "https://registry.yarnpkg.com/async-hook-jl/-/async-hook-jl-1.7.6.tgz#4fd25c2f864dbaf279c610d73bf97b1b28595e68" + integrity sha512-gFaHkFfSxTjvoxDMYqDuGHlcRyUuamF8s+ZTtJdDzqjws4mCt7v0vuV79/E2Wr2/riMQgtG4/yUtXWs1gZ7JMg== + dependencies: + stack-chain "^1.3.7" + +async-listener@^0.6.0: + version "0.6.10" + resolved "https://registry.yarnpkg.com/async-listener/-/async-listener-0.6.10.tgz#a7c97abe570ba602d782273c0de60a51e3e17cbc" + integrity sha512-gpuo6xOyF4D5DE5WvyqZdPA3NGhiT6Qf07l7DCB0wwDEsLvDIbCr6j9S5aj5Ch96dLace5tXVzWBZkxU/c5ohw== + dependencies: + semver "^5.3.0" + shimmer "^1.1.0" asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= -at-least-node@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" - integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== - available-typed-arrays@^1.0.2: version "1.0.4" resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.4.tgz#9e0ae84ecff20caae6a94a1c3bc39b955649b7a9" integrity sha512-SA5mXJWrId1TaQjfxUYghbqQ/hYioKmLJvPJyDuYRtXXenFNMjj4hSSt1Cf1xsuXSXrtxrVC5Ot4eU6cOtBDdA== -axios@^0.21.1: - version "0.21.4" - resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575" - integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg== +available-typed-arrays@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" + integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== dependencies: - follow-redirects "^1.14.0" + possible-typed-array-names "^1.0.0" + +b4a@^1.6.4: + version "1.6.7" + resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.6.7.tgz#a99587d4ebbfbd5a6e3b21bdb5d5fa385767abe4" + integrity sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg== balanced-match@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" - integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +bare-events@^2.2.0, bare-events@^2.5.4: + version "2.6.0" + resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.6.0.tgz#11d9506da109e363a2f3af050fbb005ccdb3ee8f" + integrity sha512-EKZ5BTXYExaNqi3I3f9RtEsaI/xBSGjE0XZCZilPzFAV/goswFHuPd9jEZlPIZ/iNZJwDSao9qRiScySz7MbQg== + +bare-fs@^4.0.1: + version "4.1.6" + resolved "https://registry.yarnpkg.com/bare-fs/-/bare-fs-4.1.6.tgz#0925521e7310f65cb1f154cab264f0b647a7cdef" + integrity sha512-25RsLF33BqooOEFNdMcEhMpJy8EoR88zSMrnOQOaM3USnOK2VmaJ1uaQEwPA6AQjrv1lXChScosN6CzbwbO9OQ== + dependencies: + bare-events "^2.5.4" + bare-path "^3.0.0" + bare-stream "^2.6.4" + +bare-os@^3.0.1: + version "3.6.1" + resolved "https://registry.yarnpkg.com/bare-os/-/bare-os-3.6.1.tgz#9921f6f59edbe81afa9f56910658422c0f4858d4" + integrity sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g== + +bare-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/bare-path/-/bare-path-3.0.0.tgz#b59d18130ba52a6af9276db3e96a2e3d3ea52178" + integrity sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw== + dependencies: + bare-os "^3.0.1" + +bare-stream@^2.6.4: + version "2.6.5" + resolved "https://registry.yarnpkg.com/bare-stream/-/bare-stream-2.6.5.tgz#bba8e879674c4c27f7e27805df005c15d7a2ca07" + integrity sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA== + dependencies: + streamx "^2.21.0" base64-js@^1.3.1: version "1.5.1" @@ -1180,15 +1964,10 @@ basic-auth@~2.0.1: dependencies: safe-buffer "5.1.2" -before-after-hook@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.0.tgz#09c40d92e936c64777aa385c4e9b904f8147eaf0" - integrity sha512-jH6rKQIfroBbhEXVmI7XmXe3ix5S/PgJqpzdDPnR8JGLHWNYLsYZ6tK5iWOF/Ra3oqEX0NobXGlzbiylIzVphQ== - -big-integer@^1.6.17: - version "1.6.48" - resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.48.tgz#8fd88bd1632cba4a1c8c3e3d7159f08bb95b4b9e" - integrity sha512-j51egjPa7/i+RdiRuJbPdJ2FIUYYPhvYLjzoYbcMMm62ooO6F94fETG4MTs46zPAF9Brs04OajboA/qTGuz78w== +before-after-hook@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-4.0.0.tgz#cf1447ab9160df6a40f3621da64d6ffc36050cb9" + integrity sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ== big.js@^5.2.2: version "5.2.2" @@ -1196,55 +1975,46 @@ big.js@^5.2.2: integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== binary-extensions@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" - integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== - -binary@~0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/binary/-/binary-0.3.0.tgz#9f60553bc5ce8c3386f3b553cff47462adecaa79" - integrity sha1-n2BVO8XOjDOG87VTz/R0Yq3sqnk= - dependencies: - buffers "~0.1.1" - chainsaw "~0.1.0" - -bl@^1.0.0: - version "1.2.3" - resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.3.tgz#1e8dd80142eac80d7158c9dccc047fb620e035e7" - integrity sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww== - dependencies: - readable-stream "^2.3.5" - safe-buffer "^5.1.1" - -bluebird@~3.4.1: - version "3.4.7" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.7.tgz#f72d760be09b7f76d08ed8fae98b289a8d05fab3" - integrity sha1-9y12C+Cbf3bQjtj66Ysomo0F+rM= + version "2.3.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9: version "4.12.0" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88" integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA== -bn.js@^5.0.0, bn.js@^5.1.1: +bn.js@^5.0.0: version "5.2.0" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.0.tgz#358860674396c6997771a9d051fcc1b57d4ae002" integrity sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw== +bn.js@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.1.tgz#0bc527a6a0d18d0aa8d5b0538ce4a77dccfa7b70" + integrity sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ== + brace-expansion@^1.1.7: - version "1.1.11" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" - integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + version "1.1.12" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" + integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== dependencies: balanced-match "^1.0.0" concat-map "0.0.1" -braces@^3.0.1, braces@~3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" - integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== +brace-expansion@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" + integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== + dependencies: + balanced-match "^1.0.0" + +braces@^3.0.3, braces@~3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== dependencies: - fill-range "^7.0.1" + fill-range "^7.1.1" brorand@^1.0.1, brorand@^1.1.0: version "1.1.0" @@ -1256,7 +2026,7 @@ browser-process-hrtime@^1.0.0: resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626" integrity sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow== -browser-stdout@1.3.1: +browser-stdout@1.3.1, browser-stdout@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== @@ -1292,7 +2062,7 @@ browserify-des@^1.0.0: inherits "^2.0.1" safe-buffer "^5.1.2" -browserify-rsa@^4.0.0, browserify-rsa@^4.0.1: +browserify-rsa@^4.0.0, browserify-rsa@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.1.0.tgz#b2fd06b5b75ae297f7ce2dc651f918f5be158c8d" integrity sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog== @@ -1301,82 +2071,52 @@ browserify-rsa@^4.0.0, browserify-rsa@^4.0.1: randombytes "^2.0.1" browserify-sign@^4.0.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.2.1.tgz#eaf4add46dd54be3bb3b36c0cf15abbeba7956c3" - integrity sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg== + version "4.2.2" + resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.2.2.tgz#e78d4b69816d6e3dd1c747e64e9947f9ad79bc7e" + integrity sha512-1rudGyeYY42Dk6texmv7c4VcQ0EsvVbLwZkA+AQB7SxvXxmcD93jcHie8bzecJ+ChDlmAm2Qyu0+Ccg5uhZXCg== dependencies: - bn.js "^5.1.1" - browserify-rsa "^4.0.1" + bn.js "^5.2.1" + browserify-rsa "^4.1.0" create-hash "^1.2.0" create-hmac "^1.1.7" - elliptic "^6.5.3" + elliptic "^6.5.4" inherits "^2.0.4" - parse-asn1 "^5.1.5" - readable-stream "^3.6.0" - safe-buffer "^5.2.0" + parse-asn1 "^5.1.6" + readable-stream "^3.6.2" + safe-buffer "^5.2.1" -browserslist@^4.14.5: - version "4.16.6" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.6.tgz#d7901277a5a88e554ed305b183ec9b0c08f66fa2" - integrity sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ== +browserify-zlib@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.1.4.tgz#bb35f8a519f600e0fa6b8485241c979d0141fb2d" + integrity sha512-19OEpq7vWgsH6WkvkBJQDFvJS1uPcbFOQ4v9CU839dO+ZZXUZO6XpE6hNCqvlIIj+4fZvRiJ6DsAQ382GwiyTQ== dependencies: - caniuse-lite "^1.0.30001219" - colorette "^1.2.2" - electron-to-chromium "^1.3.723" - escalade "^3.1.1" - node-releases "^1.1.71" - -buffer-alloc-unsafe@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0" - integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg== + pako "~0.2.0" -buffer-alloc@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec" - integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow== +browserslist@^4.21.10: + version "4.23.3" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.23.3.tgz#debb029d3c93ebc97ffbc8d9cbb03403e227c800" + integrity sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA== dependencies: - buffer-alloc-unsafe "^1.1.0" - buffer-fill "^1.0.0" - -buffer-crc32@~0.2.3: - version "0.2.13" - resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" - integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI= + caniuse-lite "^1.0.30001646" + electron-to-chromium "^1.5.4" + node-releases "^2.0.18" + update-browserslist-db "^1.1.0" buffer-equal@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/buffer-equal/-/buffer-equal-1.0.0.tgz#59616b498304d556abd466966b22eeda3eca5fbe" integrity sha512-tcBWO2Dl4e7Asr9hTGcpVrCe+F7DubpmqWCTbj4FHLmjqO2hIaC383acQubWtRJhdceqs5uBHs6Es+Sk//RKiQ== -buffer-fill@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c" - integrity sha1-+PeLdniYiO858gXNY39o5wISKyw= - buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== -buffer-indexof-polyfill@~1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz#d2732135c5999c64b277fcf9b1abe3498254729c" - integrity sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A== - buffer-xor@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" integrity sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk= -buffer@^5.2.1: - version "5.7.1" - resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" - integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== - dependencies: - base64-js "^1.3.1" - ieee754 "^1.1.13" - buffer@^6.0.3: version "6.0.3" resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" @@ -1385,18 +2125,35 @@ buffer@^6.0.3: base64-js "^1.3.1" ieee754 "^1.2.1" -buffers@~0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/buffers/-/buffers-0.1.1.tgz#b24579c3bed4d6d396aeee6d9a8ae7f5482ab7bb" - integrity sha1-skV5w77U1tOWru5tmorn9Ugqt7s= +builtin-status-codes@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" + integrity sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ== -cache-content-type@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/cache-content-type/-/cache-content-type-1.0.1.tgz#035cde2b08ee2129f4a8315ea8f00a00dba1453c" - integrity sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA== +c8@^9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/c8/-/c8-9.1.0.tgz#0e57ba3ab9e5960ab1d650b4a86f71e53cb68112" + integrity sha512-mBWcT5iqNir1zIkzSPyI3NCR9EZCVI3WUD+AVO17MVWTSFNyUueXE82qTeampNtTr+ilN/5Ua3j24LgbCKjDVg== + dependencies: + "@bcoe/v8-coverage" "^0.2.3" + "@istanbuljs/schema" "^0.1.3" + find-up "^5.0.0" + foreground-child "^3.1.1" + istanbul-lib-coverage "^3.2.0" + istanbul-lib-report "^3.0.1" + istanbul-reports "^3.1.6" + test-exclude "^6.0.0" + v8-to-istanbul "^9.0.0" + yargs "^17.7.2" + yargs-parser "^21.1.1" + +call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== dependencies: - mime-types "^2.1.18" - ylru "^1.2.0" + es-errors "^1.3.0" + function-bind "^1.1.2" call-bind@^1.0.0, call-bind@^1.0.2: version "1.0.2" @@ -1406,29 +2163,45 @@ call-bind@^1.0.0, call-bind@^1.0.2: function-bind "^1.1.1" get-intrinsic "^1.0.2" +call-bind@^1.0.7, call-bind@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c" + integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww== + dependencies: + call-bind-apply-helpers "^1.0.0" + es-define-property "^1.0.0" + get-intrinsic "^1.2.4" + set-function-length "^1.2.2" + +call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== -camelcase@^6.0.0, camelcase@^6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809" - integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg== +camelcase@^6.0.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== -caniuse-lite@^1.0.30001219: - version "1.0.30001228" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001228.tgz#bfdc5942cd3326fa51ee0b42fbef4da9d492a7fa" - integrity sha512-QQmLOGJ3DEgokHbMSA8cj2a+geXqmnpyOFT0lhQV6P3/YOJvGDEwoedcwxEQ30gJIwIIunHIicunJ2rzK5gB2A== +caniuse-lite@^1.0.30001646: + version "1.0.30001653" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001653.tgz#b8af452f8f33b1c77f122780a4aecebea0caca56" + integrity sha512-XGWQVB8wFQ2+9NZwZ10GxTYC5hk0Fa+q8cSkr0tgvMhYhMHP/QC+WTgrePMDBWiWc/pV+1ik82Al20XOK25Gcw== -chainsaw@~0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/chainsaw/-/chainsaw-0.1.0.tgz#5eab50b28afe58074d0d58291388828b5e5fbc98" - integrity sha1-XqtQsor+WAdNDVgpE4iCi15fvJg= - dependencies: - traverse ">=0.3.0 <0.4" +ccount@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" + integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== -chalk@^2.0.0, chalk@^2.0.1, chalk@^2.4.2: +chalk@^2.0.0, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -1437,7 +2210,7 @@ chalk@^2.0.0, chalk@^2.0.1, chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" -chalk@^4.0.0, chalk@^4.1.0: +chalk@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" integrity sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A== @@ -1445,15 +2218,38 @@ chalk@^4.0.0, chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@^4.1.0, chalk@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chalk@^5.3.0: + version "5.4.1" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.4.1.tgz#1b48bf0963ec158dce2aacf69c093ae2dd2092d8" + integrity sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w== + +character-entities-html4@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz#1f1adb940c971a4b22ba39ddca6b618dc6e56b2b" + integrity sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA== + +character-entities-legacy@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz#76bc83a90738901d7bc223a9e93759fdd560125b" + integrity sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ== + charenc@0.0.2: version "0.0.2" resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" integrity sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc= -chokidar@3.5.2: - version "3.5.2" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.2.tgz#dba3976fcadb016f66fd365021d91600d01c1e75" - integrity sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ== +chokidar@3.5.3: + version "3.5.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" + integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== dependencies: anymatch "~3.1.2" braces "~3.0.2" @@ -1465,20 +2261,27 @@ chokidar@3.5.2: optionalDependencies: fsevents "~2.3.2" -chokidar@^3.4.2: - version "3.5.1" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.1.tgz#ee9ce7bbebd2b79f49f304799d5468e31e14e68a" - integrity sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw== +chokidar@^3.5.3: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== dependencies: - anymatch "~3.1.1" + anymatch "~3.1.2" braces "~3.0.2" - glob-parent "~5.1.0" + glob-parent "~5.1.2" is-binary-path "~2.1.0" is-glob "~4.0.1" normalize-path "~3.0.0" - readdirp "~3.5.0" + readdirp "~3.6.0" optionalDependencies: - fsevents "~2.3.1" + fsevents "~2.3.2" + +chokidar@^4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" + integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== + dependencies: + readdirp "^4.0.1" chrome-trace-event@^1.0.2: version "1.0.2" @@ -1488,12 +2291,24 @@ chrome-trace-event@^1.0.2: tslib "^1.9.0" cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" - integrity sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q== + version "1.0.6" + resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.6.tgz#8fe672437d01cd6c4561af5334e0cc50ff1955f7" + integrity sha512-3Ek9H3X6pj5TgenXYtNWdaBon1tgYCaebd+XPg0keyjEbEfkD4KkmAxkQ/i1vYvxdcT5nscLBfq9VJRmCBcFSw== dependencies: - inherits "^2.0.1" - safe-buffer "^5.0.1" + inherits "^2.0.4" + safe-buffer "^5.2.1" + +cli-cursor@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-5.0.0.tgz#24a4831ecf5a6b01ddeb32fb71a4b2088b0dce38" + integrity sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw== + dependencies: + restore-cursor "^5.0.0" + +cli-spinners@^2.9.2: + version "2.9.2" + resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.2.tgz#1773a8f4b9c4d6ac31563df53b3fc1d79462fe41" + integrity sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg== cliui@^7.0.2: version "7.0.4" @@ -1504,6 +2319,15 @@ cliui@^7.0.2: strip-ansi "^6.0.0" wrap-ansi "^7.0.0" +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + clone-buffer@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58" @@ -1528,10 +2352,19 @@ cloneable-readable@^1.0.0: process-nextick-args "^2.0.0" readable-stream "^2.3.5" -co@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" - integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ= +cls-hooked@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/cls-hooked/-/cls-hooked-4.2.2.tgz#ad2e9a4092680cdaffeb2d3551da0e225eae1908" + integrity sha512-J4Xj5f5wq/4jAvcdgoGsL3G103BtWpZrMo8NEinRltN+xpTZdI+M38pyQqhuFU/P792xkMFvnKSf+Lm81U1bxw== + dependencies: + async-hook-jl "^1.7.6" + emitter-listener "^1.0.1" + semver "^5.4.1" + +cockatiel@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/cockatiel/-/cockatiel-3.1.1.tgz#82c95dcad673649c43c0a35c424c5d2ad59d4e6b" + integrity sha512-zHMqBGvkZLfMKkBMD+0U8X1nW8zYwMtymgJ8CTknWOmTDpvjEwygtFN4QR9A1iFQDwCbg8g8+B/zVBoxvj1feQ== color-convert@^1.9.0: version "1.9.3" @@ -1557,7 +2390,7 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -colorette@^1.2.1, colorette@^1.2.2: +colorette@^1.2.1: version "1.2.2" resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94" integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w== @@ -1569,6 +2402,11 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" +comma-separated-tokens@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee" + integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg== + command-line-usage@^6.1.0: version "6.1.1" resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-6.1.1.tgz#c908e28686108917758a49f45efb4f02f76bc03f" @@ -1579,7 +2417,7 @@ command-line-usage@^6.1.0: table-layout "^1.0.1" typical "^5.2.0" -commander@^2.19.0, commander@^2.20.0, commander@^2.8.1: +commander@^2.19.0, commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -1597,29 +2435,32 @@ commandpost@^1.0.0: concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== constants-browserify@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" integrity sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U= -contains-path@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a" - integrity sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo= - -content-disposition@~0.5.2: - version "0.5.3" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd" - integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g== +content-disposition@~0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" + integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== dependencies: - safe-buffer "5.1.2" + safe-buffer "5.2.1" -content-type@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" - integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== +content-type@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + +continuation-local-storage@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/continuation-local-storage/-/continuation-local-storage-3.2.1.tgz#11f613f74e914fe9b34c92ad2d28fe6ae1db7ffb" + integrity sha512-jx44cconVqkCEEyLSKWwkvUXwO561jXMa3LPjTPsm5QR22PA0/mhe33FT4Xb5y74JDvt/Cq+5lm8S8rskLv9ZA== + dependencies: + async-listener "^0.6.0" + emitter-listener "^1.1.1" convert-source-map@^1.5.0: version "1.8.0" @@ -1628,29 +2469,33 @@ convert-source-map@^1.5.0: dependencies: safe-buffer "~5.1.1" -cookies@~0.8.0: - version "0.8.0" - resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.8.0.tgz#1293ce4b391740a8406e3c9870e828c4b54f3f90" - integrity sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow== +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + +cookies@~0.9.1: + version "0.9.1" + resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.9.1.tgz#3ffed6f60bb4fb5f146feeedba50acc418af67e3" + integrity sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw== dependencies: depd "~2.0.0" keygrip "~1.1.0" core-util-is@~1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" - integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== -cosmiconfig@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-6.0.0.tgz#da4fee853c52f6b1e6935f41c1a2fc50bd4a9982" - integrity sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg== +cosmiconfig@^8.2.0: + version "8.3.6" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-8.3.6.tgz#060a2b871d66dba6c8538ea1118ba1ac16f5fae3" + integrity sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA== dependencies: - "@types/parse-json" "^4.0.0" - import-fresh "^3.1.0" - parse-json "^5.0.0" + import-fresh "^3.3.0" + js-yaml "^4.1.0" + parse-json "^5.2.0" path-type "^4.0.0" - yaml "^1.7.2" create-ecdh@^4.0.0: version "4.0.4" @@ -1660,7 +2505,7 @@ create-ecdh@^4.0.0: bn.js "^4.1.0" elliptic "^6.5.3" -create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0: +create-hash@^1.1.0, create-hash@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== @@ -1671,7 +2516,17 @@ create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0: ripemd160 "^2.0.1" sha.js "^2.4.0" -create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7: +create-hash@~1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.1.3.tgz#606042ac8b9262750f483caddab0f5819172d8fd" + integrity sha512-snRpch/kwQhcdlnZKYanNF1m0RDlrCdSKQaH87w1FCFPVPNCQ/Il9QJKAX2jVBZddRdaHBMC+zXa9Gw9tmkNUA== + dependencies: + cipher-base "^1.0.1" + inherits "^2.0.1" + ripemd160 "^2.0.0" + sha.js "^2.4.0" + +create-hmac@^1.1.0, create-hmac@^1.1.7: version "1.1.7" resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff" integrity sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg== @@ -1683,6 +2538,11 @@ create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7: safe-buffer "^5.0.1" sha.js "^2.4.8" +create-require@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== + cross-fetch@3.1.5: version "3.1.5" resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" @@ -1690,10 +2550,10 @@ cross-fetch@3.1.5: dependencies: node-fetch "2.6.7" -cross-spawn@^7.0.0, cross-spawn@^7.0.2: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== +cross-spawn@^7.0.0, cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== dependencies: path-key "^3.1.0" shebang-command "^2.0.0" @@ -1721,23 +2581,19 @@ crypto-browserify@3.12.0: randombytes "^2.0.0" randomfill "^1.0.3" -css-loader@5.1.3: - version "5.1.3" - resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-5.1.3.tgz#87f6fc96816b20debe3cf682f85c7e56a963d0d1" - integrity sha512-CoPZvyh8sLiGARK3gqczpfdedbM74klGWurF2CsNZ2lhNaXdLIUks+3Mfax3WBeRuHoglU+m7KG/+7gY6G4aag== +css-loader@7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-7.1.2.tgz#64671541c6efe06b0e22e750503106bdd86880f8" + integrity sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA== dependencies: - camelcase "^6.2.0" - cssesc "^3.0.0" icss-utils "^5.1.0" - loader-utils "^2.0.0" - postcss "^8.2.8" - postcss-modules-extract-imports "^3.0.0" - postcss-modules-local-by-default "^4.0.0" - postcss-modules-scope "^3.0.0" + postcss "^8.4.33" + postcss-modules-extract-imports "^3.1.0" + postcss-modules-local-by-default "^4.0.5" + postcss-modules-scope "^3.2.0" postcss-modules-values "^4.0.0" - postcss-value-parser "^4.1.0" - schema-utils "^3.0.0" - semver "^7.3.4" + postcss-value-parser "^4.2.0" + semver "^7.5.4" cssesc@^3.0.0: version "3.0.0" @@ -1775,36 +2631,82 @@ data-urls@^3.0.1: whatwg-mimetype "^3.0.0" whatwg-url "^11.0.0" +data-view-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz#211a03ba95ecaf7798a8c7198d79536211f88570" + integrity sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-data-view "^1.0.2" + +data-view-byte-length@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz#9e80f7ca52453ce3e93d25a35318767ea7704735" + integrity sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-data-view "^1.0.2" + +data-view-byte-offset@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz#068307f9b71ab76dbbe10291389e020856606191" + integrity sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-data-view "^1.0.1" + dayjs@1.10.4: version "1.10.4" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.4.tgz#8e544a9b8683f61783f570980a8a80eaf54ab1e2" integrity sha512-RI/Hh4kqRc1UKLOAf/T5zdMMX5DQIlDxwUe3wSyMMnEbGunnpENCdbUgM+dW7kXidZqCttBrmw7BhN4TMddkCw== -debug@2.6.9, debug@^2.2.0, debug@^2.6.8, debug@^2.6.9: +debounce@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5" + integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug== + +debug@2.6.9, debug@^2.2.0: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== dependencies: ms "2.0.0" -debug@4, debug@4.3.1, debug@^4.0.1, debug@^4.1.1: - version "4.3.1" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" - integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== +debug@4, debug@^4.0.1, debug@^4.1.1, debug@^4.4.1: + version "4.4.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" + integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== + dependencies: + ms "^2.1.3" + +debug@4.3.3: + version "4.3.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664" + integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q== dependencies: ms "2.1.2" -debug@^3.1.0: +debug@^3.1.0, debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== dependencies: ms "^2.1.1" -debug@^4.3.2: - version "4.3.3" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664" - integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q== +debug@^4.3.1, debug@^4.3.2, debug@^4.3.5: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +debug@^4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== dependencies: ms "2.1.2" @@ -1818,63 +2720,10 @@ decimal.js@^10.3.1: resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.3.1.tgz#d8c3a444a9c6774ba60ca6ad7261c3a94fd5e783" integrity sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ== -decompress-tar@^4.0.0, decompress-tar@^4.1.0, decompress-tar@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/decompress-tar/-/decompress-tar-4.1.1.tgz#718cbd3fcb16209716e70a26b84e7ba4592e5af1" - integrity sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ== - dependencies: - file-type "^5.2.0" - is-stream "^1.1.0" - tar-stream "^1.5.2" - -decompress-tarbz2@^4.0.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz#3082a5b880ea4043816349f378b56c516be1a39b" - integrity sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A== - dependencies: - decompress-tar "^4.1.0" - file-type "^6.1.0" - is-stream "^1.1.0" - seek-bzip "^1.0.5" - unbzip2-stream "^1.0.9" - -decompress-targz@^4.0.0, decompress-targz@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/decompress-targz/-/decompress-targz-4.1.1.tgz#c09bc35c4d11f3de09f2d2da53e9de23e7ce1eee" - integrity sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w== - dependencies: - decompress-tar "^4.1.1" - file-type "^5.2.0" - is-stream "^1.1.0" - -decompress-unzip@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/decompress-unzip/-/decompress-unzip-4.0.1.tgz#deaaccdfd14aeaf85578f733ae8210f9b4848f69" - integrity sha1-3qrM39FK6vhVePczroIQ+bSEj2k= - dependencies: - file-type "^3.8.0" - get-stream "^2.2.0" - pify "^2.3.0" - yauzl "^2.4.2" - -decompress@^4.2.1: - version "4.2.1" - resolved "https://registry.yarnpkg.com/decompress/-/decompress-4.2.1.tgz#007f55cc6a62c055afa37c07eb6a4ee1b773f118" - integrity sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ== - dependencies: - decompress-tar "^4.0.0" - decompress-tarbz2 "^4.0.0" - decompress-targz "^4.0.0" - decompress-unzip "^4.0.1" - graceful-fs "^4.1.10" - make-dir "^1.0.0" - pify "^2.3.0" - strip-dirs "^2.0.0" - deep-equal@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" - integrity sha1-9dJgKStmDghO/0zbyfCK0yR0SLU= + integrity sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw== deep-extend@~0.6.0: version "0.6.0" @@ -1891,6 +2740,15 @@ deepmerge@^4.2.2: resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== +define-data-property@^1.0.1, define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + define-properties@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" @@ -1898,6 +2756,15 @@ define-properties@^1.1.3: dependencies: object-keys "^1.0.12" +define-properties@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" + integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== + dependencies: + define-data-property "^1.0.1" + has-property-descriptors "^1.0.0" + object-keys "^1.1.1" + delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" @@ -1906,9 +2773,9 @@ delayed-stream@~1.0.0: delegates@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" - integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= + integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ== -depd@^2.0.0, depd@~2.0.0: +depd@2.0.0, depd@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== @@ -1916,12 +2783,12 @@ depd@^2.0.0, depd@~2.0.0: depd@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" - integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= + integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== -deprecation@^2.0.0, deprecation@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" - integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ== +dequal@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== des.js@^1.0.0: version "1.0.1" @@ -1931,21 +2798,45 @@ des.js@^1.0.0: inherits "^2.0.1" minimalistic-assert "^1.0.0" -destroy@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" - integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= +destroy@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + +devlop@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/devlop/-/devlop-1.1.0.tgz#4db7c2ca4dc6e0e834c30be70c94bbc976dc7018" + integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA== + dependencies: + dequal "^2.0.0" + +diagnostic-channel-publishers@1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/diagnostic-channel-publishers/-/diagnostic-channel-publishers-1.0.5.tgz#df8c317086c50f5727fdfb5d2fce214d2e4130ae" + integrity sha512-dJwUS0915pkjjimPJVDnS/QQHsH0aOYhnZsLJdnZIMOrB+csj8RnZhWTuwnm8R5v3Z7OZs+ksv5luC14DGB7eg== + +diagnostic-channel@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/diagnostic-channel/-/diagnostic-channel-1.1.0.tgz#6985e9dfedfbc072d91dc4388477e4087147756e" + integrity sha512-fwujyMe1gj6rk6dYi9hMZm0c8Mz8NDMVl2LB4iaYh3+LIAThZC8RKFGXWG0IML2OxAit/ZFRgZhMkhQ3d/bobQ== + dependencies: + semver "^5.3.0" diff@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== -diff@^4.0.2: +diff@^4.0.1, diff@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== +diff@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-7.0.0.tgz#3fb34d387cd76d803f6eebea67b921dab0182a9a" + integrity sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw== + diffie-hellman@^5.0.0: version "5.0.3" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" @@ -1955,25 +2846,10 @@ diffie-hellman@^5.0.0: miller-rabin "^4.0.0" randombytes "^2.0.0" -dir-glob@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" - integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== - dependencies: - path-type "^4.0.0" - -doctrine@1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa" - integrity sha1-N53Ocw9hZvds76TmcHoVmwLFpvo= - dependencies: - esutils "^2.0.2" - isarray "^1.0.0" - -doctrine@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" - integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== +doctrine@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" + integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw== dependencies: esutils "^2.0.2" @@ -1994,19 +2870,21 @@ domexception@^4.0.0: dependencies: webidl-conversions "^7.0.0" -duplexer2@~0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1" - integrity sha1-ixLauHjA1p4+eJEFFmKjL8a93ME= +dunder-proto@^1.0.0, dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== dependencies: - readable-stream "^2.0.2" + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" duplexer@^0.1.1, duplexer@~0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg== -duplexify@^3.6.0: +duplexify@^3.5.0, duplexify@^3.6.0: version "3.7.1" resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309" integrity sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g== @@ -2016,6 +2894,11 @@ duplexify@^3.6.0: readable-stream "^2.0.0" stream-shift "^1.0.0" +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + editorconfig@^0.15.0: version "0.15.3" resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-0.15.3.tgz#bef84c4e75fb8dcb0ce5cee8efd51c15999befc5" @@ -2029,17 +2912,17 @@ editorconfig@^0.15.0: ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== -electron-to-chromium@^1.3.723: - version "1.3.737" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.737.tgz#196f2e9656f4f3c31930750e1899c091b72d36b5" - integrity sha512-P/B84AgUSQXaum7a8m11HUsYL8tj9h/Pt5f7Hg7Ty6bm5DxlFq+e5+ouHUoNQMsKDJ7u4yGfI8mOErCmSH9wyg== +electron-to-chromium@^1.5.4: + version "1.5.13" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.13.tgz#1abf0410c5344b2b829b7247e031f02810d442e6" + integrity sha512-lbBcvtIJ4J6sS4tb5TLp1b4LyfCdMkwStzXPyAgVgTRAsep4bvrAGaBOP7ZJtQMNJpSQ9SqG4brWOroNaQtm7Q== -elliptic@^6.5.3: - version "6.5.4" - resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb" - integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ== +elliptic@^6.5.3, elliptic@^6.5.4: + version "6.6.1" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.6.1.tgz#3b8ffb02670bf69e382c7f65bf524c97c5405c06" + integrity sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g== dependencies: bn.js "^4.11.9" brorand "^1.1.0" @@ -2049,65 +2932,94 @@ elliptic@^6.5.3: minimalistic-assert "^1.0.1" minimalistic-crypto-utils "^1.0.1" +emitter-listener@^1.0.1, emitter-listener@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/emitter-listener/-/emitter-listener-1.1.2.tgz#56b140e8f6992375b3d7cb2cab1cc7432d9632e8" + integrity sha512-Bt1sBAGFHY9DKY+4/2cV6izcKJUf5T7/gkdmkxzX/qv9CcGH8xSwVRW5mtX03SWJtRTWSOpzCuWN9rBFYZepZQ== + dependencies: + shimmer "^1.2.0" + +emoji-regex@^10.3.0: + version "10.4.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.4.0.tgz#03553afea80b3975749cfcb36f776ca268e413d4" + integrity sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw== + emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + emojis-list@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== -encodeurl@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" - integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= +encodeurl@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" + integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + +end-of-stream@^1.0.0: + version "1.4.5" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.5.tgz#7344d711dea40e0b74abc2ed49778743ccedb08c" + integrity sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg== + dependencies: + once "^1.4.0" -end-of-stream@^1.0.0, end-of-stream@^1.1.0: +end-of-stream@^1.1.0: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== dependencies: once "^1.4.0" -enhanced-resolve@^4.0.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz#2f3cfd84dbe3b487f18f2db2ef1e064a571ca5ec" - integrity sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg== +enhanced-resolve@^5.0.0: + version "5.18.2" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz#7903c5b32ffd4b2143eeb4b92472bd68effd5464" + integrity sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ== dependencies: - graceful-fs "^4.1.2" - memory-fs "^0.5.0" - tapable "^1.0.0" + graceful-fs "^4.2.4" + tapable "^2.2.0" + +enhanced-resolve@^5.15.0: + version "5.18.3" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz#9b5f4c5c076b8787c78fe540392ce76a88855b44" + integrity sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww== + dependencies: + graceful-fs "^4.2.4" + tapable "^2.2.0" -enhanced-resolve@^5.8.3: - version "5.8.3" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.8.3.tgz#6d552d465cce0423f5b3d718511ea53826a7b2f0" - integrity sha512-EGAbGvH7j7Xt2nc0E7D99La1OiEs8LnyimkRgwExpUMScN6O+3x9tIWs7PLQZVNx4YD+00skHXPXi1yQHpAmZA== +enhanced-resolve@^5.17.1: + version "5.17.1" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz#67bfbbcc2f81d511be77d686a90267ef7f898a15" + integrity sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg== dependencies: graceful-fs "^4.2.4" tapable "^2.2.0" -enquirer@^2.3.5, enquirer@^2.3.6: +enquirer@^2.3.6: version "2.3.6" resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d" integrity sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg== dependencies: ansi-colors "^4.1.1" +entities@^4.4.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + envinfo@^7.7.3: version "7.7.4" resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.7.4.tgz#c6311cdd38a0e86808c1c9343f667e4267c4a320" integrity sha512-TQXTYFVVwwluWSFis6K2XKxgrD22jEv0FTuLCQI+OjH7rn93+iY0fSSFM5lrSxFY+H1+B0/cvvlamr3UsBivdQ== -errno@^0.1.3: - version "0.1.8" - resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.8.tgz#8bb3e9c7d463be4976ff888f76b4809ebc2e811f" - integrity sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A== - dependencies: - prr "~1.0.1" - -error-ex@^1.2.0, error-ex@^1.3.1: +error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== @@ -2136,10 +3048,104 @@ es-abstract@^1.18.0-next.1, es-abstract@^1.18.0-next.2: string.prototype.trimstart "^1.0.4" unbox-primitive "^1.0.0" -es-module-lexer@^0.9.0: - version "0.9.3" - resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.9.3.tgz#6f13db00cc38417137daf74366f535c8eb438f19" - integrity sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ== +es-abstract@^1.23.2, es-abstract@^1.23.5, es-abstract@^1.23.9, es-abstract@^1.24.0: + version "1.24.0" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.24.0.tgz#c44732d2beb0acc1ed60df840869e3106e7af328" + integrity sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg== + dependencies: + array-buffer-byte-length "^1.0.2" + arraybuffer.prototype.slice "^1.0.4" + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" + data-view-buffer "^1.0.2" + data-view-byte-length "^1.0.2" + data-view-byte-offset "^1.0.1" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + es-set-tostringtag "^2.1.0" + es-to-primitive "^1.3.0" + function.prototype.name "^1.1.8" + get-intrinsic "^1.3.0" + get-proto "^1.0.1" + get-symbol-description "^1.1.0" + globalthis "^1.0.4" + gopd "^1.2.0" + has-property-descriptors "^1.0.2" + has-proto "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + internal-slot "^1.1.0" + is-array-buffer "^3.0.5" + is-callable "^1.2.7" + is-data-view "^1.0.2" + is-negative-zero "^2.0.3" + is-regex "^1.2.1" + is-set "^2.0.3" + is-shared-array-buffer "^1.0.4" + is-string "^1.1.1" + is-typed-array "^1.1.15" + is-weakref "^1.1.1" + math-intrinsics "^1.1.0" + object-inspect "^1.13.4" + object-keys "^1.1.1" + object.assign "^4.1.7" + own-keys "^1.0.1" + regexp.prototype.flags "^1.5.4" + safe-array-concat "^1.1.3" + safe-push-apply "^1.0.0" + safe-regex-test "^1.1.0" + set-proto "^1.0.0" + stop-iteration-iterator "^1.1.0" + string.prototype.trim "^1.2.10" + string.prototype.trimend "^1.0.9" + string.prototype.trimstart "^1.0.8" + typed-array-buffer "^1.0.3" + typed-array-byte-length "^1.0.3" + typed-array-byte-offset "^1.0.4" + typed-array-length "^1.0.7" + unbox-primitive "^1.1.0" + which-typed-array "^1.1.19" + +es-define-property@^1.0.0, es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-module-lexer@^1.2.1: + version "1.5.4" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.5.4.tgz#a8efec3a3da991e60efa6b633a7cad6ab8d26b78" + integrity sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw== + +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +es-shim-unscopables@^1.0.2, es-shim-unscopables@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz#438df35520dac5d105f3943d927549ea3b00f4b5" + integrity sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw== + dependencies: + hasown "^2.0.2" es-to-primitive@^1.2.1: version "1.2.1" @@ -2150,39 +3156,75 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" +es-to-primitive@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.3.0.tgz#96c89c82cc49fd8794a24835ba3e1ff87f214e18" + integrity sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g== + dependencies: + is-callable "^1.2.7" + is-date-object "^1.0.5" + is-symbol "^1.0.4" + es6-object-assign@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/es6-object-assign/-/es6-object-assign-1.1.0.tgz#c2c3582656247c39ea107cb1e6652b6f9f24523c" integrity sha1-wsNYJlYkfDnqEHyx5mUrb58kUjw= -esbuild-loader@2.10.0: - version "2.10.0" - resolved "https://registry.yarnpkg.com/esbuild-loader/-/esbuild-loader-2.10.0.tgz#35b570187aee0036b2f4b37db66870f7407f3d40" - integrity sha512-BRWmc/7gU6/FmI+MP+E+9Zb/CE0BA1XMOQkdvJ7B/T2gad1Mlim8aMhvvRdS9on5S8JzkC+uNHGQmt/WmbbXbQ== - dependencies: - esbuild "^0.9.2" - joycon "^2.2.5" - json5 "^2.2.0" - loader-utils "^2.0.0" - type-fest "^0.21.3" - webpack-sources "^2.2.0" - -esbuild@^0.9.2: - version "0.9.3" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.9.3.tgz#09293a0b824159c6aa2488d1c6c22f57d8448f74" - integrity sha512-G8k0olucZp3LJ7I/p8y388t+IEyb2Y78nHrLeIxuqZqh6TYqDYP/B/7drAvYKfh83CGwKal9txVP+FTypsPJug== +esbuild-loader@4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/esbuild-loader/-/esbuild-loader-4.2.2.tgz#cce9032097767d325d6aa231edbd7d66a5d7fa1b" + integrity sha512-Mdq/A1L8p37hkibp8jGFwuQTDSWhDmlueAefsrCPRwNWThEOlQmIglV7Gd6GE2mO5bt7ksfxKOMwkuY7jjVTXg== + dependencies: + esbuild "^0.21.0" + get-tsconfig "^4.7.0" + loader-utils "^2.0.4" + webpack-sources "^1.4.3" + +esbuild@^0.21.0: + version "0.21.5" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" + integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.21.5" + "@esbuild/android-arm" "0.21.5" + "@esbuild/android-arm64" "0.21.5" + "@esbuild/android-x64" "0.21.5" + "@esbuild/darwin-arm64" "0.21.5" + "@esbuild/darwin-x64" "0.21.5" + "@esbuild/freebsd-arm64" "0.21.5" + "@esbuild/freebsd-x64" "0.21.5" + "@esbuild/linux-arm" "0.21.5" + "@esbuild/linux-arm64" "0.21.5" + "@esbuild/linux-ia32" "0.21.5" + "@esbuild/linux-loong64" "0.21.5" + "@esbuild/linux-mips64el" "0.21.5" + "@esbuild/linux-ppc64" "0.21.5" + "@esbuild/linux-riscv64" "0.21.5" + "@esbuild/linux-s390x" "0.21.5" + "@esbuild/linux-x64" "0.21.5" + "@esbuild/netbsd-x64" "0.21.5" + "@esbuild/openbsd-x64" "0.21.5" + "@esbuild/sunos-x64" "0.21.5" + "@esbuild/win32-arm64" "0.21.5" + "@esbuild/win32-ia32" "0.21.5" + "@esbuild/win32-x64" "0.21.5" escalade@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" - integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escalade@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.2.tgz#54076e9ab29ea5bf3d8f1ed62acffbb88272df27" + integrity sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA== escape-html@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== -escape-string-regexp@4.0.0: +escape-string-regexp@4.0.0, escape-string-regexp@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== @@ -2204,56 +3246,74 @@ escodegen@^2.0.0: optionalDependencies: source-map "~0.6.1" -eslint-cli@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/eslint-cli/-/eslint-cli-1.1.1.tgz#ae6979edd8ee6e78c6d413b525f4052cb2a94cfd" - integrity sha512-Gu+fYzt7M+jIb5szUHLl5Ex0vFY7zErbi78D7ZaaLunvVTxHRvbOlfzmJlIUWsV5WDM4qyu9TD7WnGgDaDgaMA== - dependencies: - chalk "^2.0.1" - debug "^2.6.8" - resolve "^1.3.3" - -eslint-config-prettier@8.1.0: - version "8.1.0" - resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.1.0.tgz#4ef1eaf97afe5176e6a75ddfb57c335121abc5a6" - integrity sha512-oKMhGv3ihGbCIimCAjqkdzx2Q+jthoqnXSP+d86M9tptwugycmTFdVR4IpLgq2c4SHifbwO90z2fQ8/Aio73yw== - -eslint-import-resolver-node@^0.3.4: - version "0.3.4" - resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.4.tgz#85ffa81942c25012d8231096ddf679c03042c717" - integrity sha512-ogtf+5AB/O+nM6DIeBUNr2fuT7ot9Qg/1harBfBtaP13ekEWFQEEMP94BCB7zaNW3gyY+8SHYF00rnqYwXKWOA== - dependencies: - debug "^2.6.9" - resolve "^1.13.1" - -eslint-module-utils@^2.6.0: - version "2.6.0" - resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.6.0.tgz#579ebd094f56af7797d19c9866c9c9486629bfa6" - integrity sha512-6j9xxegbqe8/kZY8cYpcp0xhbK0EgJlg3g9mib3/miLaExuuwc3n5UEfSnU6hWMbT0FAYVvDbL9RrRgpUeQIvA== +eslint-import-context@^0.1.8: + version "0.1.9" + resolved "https://registry.yarnpkg.com/eslint-import-context/-/eslint-import-context-0.1.9.tgz#967b0b2f0a90ef4b689125e088f790f0b7756dbe" + integrity sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg== dependencies: - debug "^2.6.9" - pkg-dir "^2.0.0" + get-tsconfig "^4.10.1" + stable-hash-x "^0.2.0" -eslint-plugin-import@2.22.1: - version "2.22.1" - resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.22.1.tgz#0896c7e6a0cf44109a2d97b95903c2bb689d7702" - integrity sha512-8K7JjINHOpH64ozkAhpT3sd+FswIZTfMZTjdx052pnWrgRCVfp8op9tbjpAk3DdUeI/Ba4C8OjdC0r90erHEOw== - dependencies: - array-includes "^3.1.1" - array.prototype.flat "^1.2.3" - contains-path "^0.1.0" - debug "^2.6.9" - doctrine "1.5.0" - eslint-import-resolver-node "^0.3.4" - eslint-module-utils "^2.6.0" - has "^1.0.3" - minimatch "^3.0.4" - object.values "^1.1.1" - read-pkg-up "^2.0.0" - resolve "^1.17.0" - tsconfig-paths "^3.9.0" +eslint-import-resolver-node@^0.3.9: + version "0.3.9" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz#d4eaac52b8a2e7c3cd1903eb00f7e053356118ac" + integrity sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g== + dependencies: + debug "^3.2.7" + is-core-module "^2.13.0" + resolve "^1.22.4" + +eslint-import-resolver-typescript@^4.4.4: + version "4.4.4" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.4.tgz#3e83a9c25f4a053fe20e1b07b47e04e8519a8720" + integrity sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw== + dependencies: + debug "^4.4.1" + eslint-import-context "^0.1.8" + get-tsconfig "^4.10.1" + is-bun-module "^2.0.0" + stable-hash-x "^0.2.0" + tinyglobby "^0.2.14" + unrs-resolver "^1.7.11" + +eslint-module-utils@^2.12.0: + version "2.12.1" + resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz#f76d3220bfb83c057651359295ab5854eaad75ff" + integrity sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw== + dependencies: + debug "^3.2.7" + +eslint-plugin-import@2.31.0: + version "2.31.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz#310ce7e720ca1d9c0bb3f69adfd1c6bdd7d9e0e7" + integrity sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A== + dependencies: + "@rtsao/scc" "^1.1.0" + array-includes "^3.1.8" + array.prototype.findlastindex "^1.2.5" + array.prototype.flat "^1.3.2" + array.prototype.flatmap "^1.3.2" + debug "^3.2.7" + doctrine "^2.1.0" + eslint-import-resolver-node "^0.3.9" + eslint-module-utils "^2.12.0" + hasown "^2.0.2" + is-core-module "^2.15.1" + is-glob "^4.0.3" + minimatch "^3.1.2" + object.fromentries "^2.0.8" + object.groupby "^1.0.3" + object.values "^1.2.0" + semver "^6.3.1" + string.prototype.trimend "^1.0.8" + tsconfig-paths "^3.15.0" + +eslint-plugin-rulesdir@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-rulesdir/-/eslint-plugin-rulesdir-0.2.2.tgz#84756ec39cd8503b1fe8af6a02a5da361e2bd076" + integrity sha512-qhBtmrWgehAIQeMDJ+Q+PnOz1DWUZMPeVrI0wE9NZtnpIMFUfh3aPKFYt2saeMSemZRrvUtjWfYwepsC8X+mjQ== -eslint-scope@5.1.1, eslint-scope@^5.0.0, eslint-scope@^5.1.1: +eslint-scope@5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== @@ -2261,84 +3321,83 @@ eslint-scope@5.1.1, eslint-scope@^5.0.0, eslint-scope@^5.1.1: esrecurse "^4.3.0" estraverse "^4.1.1" -eslint-utils@^2.0.0, eslint-utils@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27" - integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg== +eslint-scope@^8.4.0: + version "8.4.0" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-8.4.0.tgz#88e646a207fad61436ffa39eb505147200655c82" + integrity sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg== dependencies: - eslint-visitor-keys "^1.1.0" - -eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e" - integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ== + esrecurse "^4.3.0" + estraverse "^5.2.0" -eslint-visitor-keys@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz#21fdc8fbcd9c795cc0321f0563702095751511a8" - integrity sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ== +eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -eslint@7.22.0: - version "7.22.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.22.0.tgz#07ecc61052fec63661a2cab6bd507127c07adc6f" - integrity sha512-3VawOtjSJUQiiqac8MQc+w457iGLfuNGLFn8JmF051tTKbh5/x/0vlcEj8OgDCaw7Ysa2Jn8paGshV7x2abKXg== - dependencies: - "@babel/code-frame" "7.12.11" - "@eslint/eslintrc" "^0.4.0" - ajv "^6.10.0" +eslint-visitor-keys@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1" + integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== + +eslint@^9.36.0: + version "9.36.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.36.0.tgz#9cc5cbbfb9c01070425d9bfed81b4e79a1c09088" + integrity sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ== + dependencies: + "@eslint-community/eslint-utils" "^4.8.0" + "@eslint-community/regexpp" "^4.12.1" + "@eslint/config-array" "^0.21.0" + "@eslint/config-helpers" "^0.3.1" + "@eslint/core" "^0.15.2" + "@eslint/eslintrc" "^3.3.1" + "@eslint/js" "9.36.0" + "@eslint/plugin-kit" "^0.3.5" + "@humanfs/node" "^0.16.6" + "@humanwhocodes/module-importer" "^1.0.1" + "@humanwhocodes/retry" "^0.4.2" + "@types/estree" "^1.0.6" + "@types/json-schema" "^7.0.15" + ajv "^6.12.4" chalk "^4.0.0" - cross-spawn "^7.0.2" - debug "^4.0.1" - doctrine "^3.0.0" - enquirer "^2.3.5" - eslint-scope "^5.1.1" - eslint-utils "^2.1.0" - eslint-visitor-keys "^2.0.0" - espree "^7.3.1" - esquery "^1.4.0" + cross-spawn "^7.0.6" + debug "^4.3.2" + escape-string-regexp "^4.0.0" + eslint-scope "^8.4.0" + eslint-visitor-keys "^4.2.1" + espree "^10.4.0" + esquery "^1.5.0" esutils "^2.0.2" - file-entry-cache "^6.0.1" - functional-red-black-tree "^1.0.1" - glob-parent "^5.0.0" - globals "^13.6.0" - ignore "^4.0.6" - import-fresh "^3.0.0" + fast-deep-equal "^3.1.3" + file-entry-cache "^8.0.0" + find-up "^5.0.0" + glob-parent "^6.0.2" + ignore "^5.2.0" imurmurhash "^0.1.4" is-glob "^4.0.0" - js-yaml "^3.13.1" json-stable-stringify-without-jsonify "^1.0.1" - levn "^0.4.1" - lodash "^4.17.21" - minimatch "^3.0.4" + lodash.merge "^4.6.2" + minimatch "^3.1.2" natural-compare "^1.4.0" - optionator "^0.9.1" - progress "^2.0.0" - regexpp "^3.1.0" - semver "^7.2.1" - strip-ansi "^6.0.0" - strip-json-comments "^3.1.0" - table "^6.0.4" - text-table "^0.2.0" - v8-compile-cache "^2.0.3" + optionator "^0.9.3" -espree@^7.3.0, espree@^7.3.1: - version "7.3.1" - resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.1.tgz#f2df330b752c6f55019f8bd89b7660039c1bbbb6" - integrity sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g== +espree@^10.0.1, espree@^10.4.0: + version "10.4.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-10.4.0.tgz#d54f4949d4629005a1fa168d937c3ff1f7e2a837" + integrity sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ== dependencies: - acorn "^7.4.0" - acorn-jsx "^5.3.1" - eslint-visitor-keys "^1.3.0" + acorn "^8.15.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^4.2.1" -esprima@^4.0.0, esprima@^4.0.1: +esprima@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== -esquery@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.4.0.tgz#2148ffc38b82e8c7057dfed48425b3e61f0f24a5" - integrity sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w== +esquery@^1.5.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7" + integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== dependencies: estraverse "^5.1.0" @@ -2423,22 +3482,31 @@ extend@^3.0.0: resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== +fast-content-type-parse@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz#5590b6c807cc598be125e6740a9fde589d2b7afb" + integrity sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg== + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== -fast-glob@^3.1.1: - version "3.2.5" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.5.tgz#7939af2a656de79a4f1901903ee8adcaa7cb9661" - integrity sha512-2DtFcgT68wiTTiwZ2hNdJfcHNke9XOfnwmBRWXhmeKM8rF0TGwmC/Qto3S7RoZKp5cilZbxzO5iTNTQsJ+EeDg== +fast-fifo@^1.2.0, fast-fifo@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c" + integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ== + +fast-glob@^3.3.2: + version "3.3.3" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818" + integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== dependencies: "@nodelib/fs.stat" "^2.0.2" "@nodelib/fs.walk" "^1.2.3" - glob-parent "^5.1.0" + glob-parent "^5.1.2" merge2 "^1.3.0" - micromatch "^4.0.2" - picomatch "^2.2.1" + micromatch "^4.0.8" fast-json-stable-stringify@^2.0.0: version "2.1.0" @@ -2457,43 +3525,31 @@ fastq@^1.6.0: dependencies: reusify "^1.0.4" -fd-slicer@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" - integrity sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4= - dependencies: - pend "~1.2.0" - -file-entry-cache@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" - integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== - dependencies: - flat-cache "^3.0.4" +fdir@^6.4.4: + version "6.4.6" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.4.6.tgz#2b268c0232697063111bbf3f64810a2a741ba281" + integrity sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w== -file-type@^3.8.0: - version "3.9.0" - resolved "https://registry.yarnpkg.com/file-type/-/file-type-3.9.0.tgz#257a078384d1db8087bc449d107d52a52672b9e9" - integrity sha1-JXoHg4TR24CHvESdEH1SpSZyuek= - -file-type@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/file-type/-/file-type-5.2.0.tgz#2ddbea7c73ffe36368dfae49dc338c058c2b8ad6" - integrity sha1-LdvqfHP/42No365J3DOMBYwritY= +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== -file-type@^6.1.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/file-type/-/file-type-6.2.0.tgz#e50cd75d356ffed4e306dc4f5bcf52a79903a919" - integrity sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg== +file-entry-cache@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" + integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== + dependencies: + flat-cache "^4.0.0" -fill-range@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" - integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== dependencies: to-regex-range "^5.0.1" -find-up@5.0.0: +find-up@5.0.0, find-up@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== @@ -2501,13 +3557,6 @@ find-up@5.0.0: locate-path "^6.0.0" path-exists "^4.0.0" -find-up@^2.0.0, find-up@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" - integrity sha1-RdG35QbHF93UgndaK3eSCjwMV6c= - dependencies: - locate-path "^2.0.0" - find-up@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" @@ -2516,23 +3565,23 @@ find-up@^4.0.0: locate-path "^5.0.0" path-exists "^4.0.0" -flat-cache@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11" - integrity sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg== +flat-cache@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" + integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== dependencies: - flatted "^3.1.0" - rimraf "^3.0.2" + flatted "^3.2.9" + keyv "^4.5.4" flat@^5.0.2: version "5.0.2" resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== -flatted@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.1.1.tgz#c4b489e80096d9df1dfc97c79871aea7c617c469" - integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA== +flatted@^3.2.9: + version "3.3.3" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" + integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== flush-write-stream@^1.0.2: version "1.1.1" @@ -2542,64 +3591,70 @@ flush-write-stream@^1.0.2: inherits "^2.0.3" readable-stream "^2.3.6" -follow-redirects@^1.14.0: - version "1.14.8" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.8.tgz#016996fb9a11a100566398b1c6839337d7bfa8fc" - integrity sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA== +for-each@^0.3.3, for-each@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" + integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== + dependencies: + is-callable "^1.2.7" foreach@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99" integrity sha1-C+4AUBiusmDQo6865ljdATbsG5k= -fork-ts-checker-webpack-plugin@6.1.1: - version "6.1.1" - resolved "https://registry.yarnpkg.com/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.1.1.tgz#c6c3b6506bfb0c7b645cd5c377e82e670d7d71c9" - integrity sha512-H8cjLmIxbnAUgxhPOqCqx1Ji3mVHnhGDnKxORZIkkkSsZLJF2IIEUc/+bywPXcWfKSR9K2zJtknRlreCWwGv0Q== +foreground-child@^3.1.0, foreground-child@^3.1.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== dependencies: - "@babel/code-frame" "^7.8.3" - "@types/json-schema" "^7.0.5" - chalk "^4.1.0" - chokidar "^3.4.2" - cosmiconfig "^6.0.0" + cross-spawn "^7.0.6" + signal-exit "^4.0.1" + +fork-ts-checker-webpack-plugin@9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz#433481c1c228c56af111172fcad7df79318c915a" + integrity sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q== + dependencies: + "@babel/code-frame" "^7.16.7" + chalk "^4.1.2" + chokidar "^4.0.1" + cosmiconfig "^8.2.0" deepmerge "^4.2.2" - fs-extra "^9.0.0" - memfs "^3.1.2" + fs-extra "^10.0.0" + memfs "^3.4.1" minimatch "^3.0.4" - schema-utils "2.7.0" - semver "^7.3.2" - tapable "^1.0.0" + node-abort-controller "^3.0.1" + schema-utils "^3.1.1" + semver "^7.3.5" + tapable "^2.2.1" form-data@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" - integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + version "4.0.4" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4" + integrity sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow== dependencies: asynckit "^0.4.0" combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.2" mime-types "^2.1.12" fresh@~0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" - integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= + integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== from@^0.1.7: version "0.1.7" resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe" integrity sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g== -fs-constants@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" - integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== - -fs-extra@^9.0.0: - version "9.1.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" - integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== +fs-extra@^10.0.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" + integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ== dependencies: - at-least-node "^1.0.0" graceful-fs "^4.2.0" jsonfile "^6.0.1" universalify "^2.0.0" @@ -2612,46 +3667,63 @@ fs-mkdirp-stream@^1.0.0: graceful-fs "^4.1.11" through2 "^2.0.3" -fs-monkey@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.1.tgz#4a82f36944365e619f4454d9fff106553067b781" - integrity sha512-fcSa+wyTqZa46iWweI7/ZiUfegOZl0SG8+dltIwFXo7+zYU9J9kpS3NB6pZcSlJdhvIwp81Adx2XhZorncxiaA== +fs-monkey@^1.0.4: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.1.0.tgz#632aa15a20e71828ed56b24303363fb1414e5997" + integrity sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw== fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@~2.3.1, fsevents@~2.3.2: +fsevents@2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== -fstream@^1.0.12: - version "1.0.12" - resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045" - integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg== - dependencies: - graceful-fs "^4.1.2" - inherits "~2.0.0" - mkdirp ">=0.5 0" - rimraf "2" +fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== -functional-red-black-tree@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" - integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +function.prototype.name@^1.1.6, function.prototype.name@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz#e68e1df7b259a5c949eeef95cdbde53edffabb78" + integrity sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + functions-have-names "^1.2.3" + hasown "^2.0.2" + is-callable "^1.2.7" + +functions-have-names@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" + integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== get-caller-file@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== +get-east-asian-width@^1.0.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz#21b4071ee58ed04ee0db653371b55b4299875389" + integrity sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ== + get-intrinsic@^1.0.2, get-intrinsic@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6" @@ -2661,18 +3733,29 @@ get-intrinsic@^1.0.2, get-intrinsic@^1.1.1: has "^1.0.3" has-symbols "^1.0.1" -get-stream@6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" - integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== - -get-stream@^2.2.0: - version "2.3.1" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-2.3.1.tgz#5f38f93f346009666ee0150a054167f91bdd95de" - integrity sha1-Xzj5PzRgCWZu4BUKBUFn+Rvdld4= +get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + +get-proto@^1.0.0, get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== dependencies: - object-assign "^4.0.1" - pinkie-promise "^2.0.0" + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" get-stream@^5.0.0: version "5.2.0" @@ -2681,6 +3764,29 @@ get-stream@^5.0.0: dependencies: pump "^3.0.0" +get-symbol-description@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.1.0.tgz#7bdd54e0befe8ffc9f3b4e203220d9f1e881b6ee" + integrity sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + +get-tsconfig@^4.10.1: + version "4.11.0" + resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.11.0.tgz#5a4acc944244a2630c2ed3318b55e6dc051d034b" + integrity sha512-sNsqf7XKQ38IawiVGPOoAlqZo1DMrO7TU+ZcZwi7yLl7/7S0JwmoBMKz/IkUPhSoXM0Ng3vT0yB1iCe5XavDeQ== + dependencies: + resolve-pkg-maps "^1.0.0" + +get-tsconfig@^4.7.0: + version "4.8.1" + resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.8.1.tgz#8995eb391ae6e1638d251118c7b56de7eb425471" + integrity sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg== + dependencies: + resolve-pkg-maps "^1.0.0" + glob-parent@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" @@ -2689,13 +3795,20 @@ glob-parent@^3.1.0: is-glob "^3.1.0" path-dirname "^1.0.0" -glob-parent@^5.0.0, glob-parent@^5.1.0, glob-parent@~5.1.0, glob-parent@~5.1.2: +glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== dependencies: is-glob "^4.0.1" +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + glob-stream@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/glob-stream/-/glob-stream-6.1.0.tgz#7045c99413b3eb94888d83ab46d0b404cc7bdde4" @@ -2729,10 +3842,10 @@ glob@7.1.6, glob@^7.1.3: once "^1.3.0" path-is-absolute "^1.0.0" -glob@7.1.7: - version "7.1.7" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90" - integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ== +glob@7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" + integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== dependencies: fs.realpath "^1.0.0" inflight "^1.0.4" @@ -2741,7 +3854,19 @@ glob@7.1.7: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^7.1.1: +glob@^10.3.10, glob@^10.4.5: + version "10.4.5" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" + integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +glob@^7.1.1, glob@^7.1.4: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -2753,46 +3878,38 @@ glob@^7.1.1: once "^1.3.0" path-is-absolute "^1.0.0" -globals@^12.1.0: - version "12.4.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-12.4.0.tgz#a18813576a41b00a24a97e7f815918c2e19925f8" - integrity sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg== - dependencies: - type-fest "^0.8.1" +globals@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" + integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== -globals@^13.6.0: - version "13.6.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-13.6.0.tgz#d77138e53738567bb96a3916ff6f6b487af20ef7" - integrity sha512-YFKCX0SiPg7l5oKYCJ2zZGxcXprVXHcSnVuvzrT3oSENQonVLqM5pf9fN5dLGZGyCjhw8TN8Btwe/jKnZ0pjvQ== - dependencies: - type-fest "^0.20.2" +globals@^16.4.0: + version "16.4.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-16.4.0.tgz#574bc7e72993d40cf27cf6c241f324ee77808e51" + integrity sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw== -globby@^11.0.1: - version "11.0.2" - resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.2.tgz#1af538b766a3b540ebfb58a32b2e2d5897321d83" - integrity sha512-2ZThXDvvV8fYFRVIxnrMQBipZQDr7MxKAmQK1vujaj9/7eF0efG7BPUKJ7jP7G5SLF37xKDXvO4S/KKLj/Z0og== +globalthis@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" + integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== dependencies: - array-union "^2.1.0" - dir-glob "^3.0.1" - fast-glob "^3.1.1" - ignore "^5.1.4" - merge2 "^1.3.0" - slash "^3.0.0" + define-properties "^1.2.1" + gopd "^1.0.1" -graceful-fs@^4.0.0, graceful-fs@^4.1.11: - version "4.2.10" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" - integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== +gopd@^1.0.1, gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== -graceful-fs@^4.1.10, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.2, graceful-fs@^4.2.4: - version "4.2.6" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.6.tgz#ff040b2b0853b23c3d31027523706f1885d76bee" - integrity sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ== +graceful-fs@^4.0.0, graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11, graceful-fs@^4.2.4: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== -graceful-fs@^4.2.9: - version "4.2.9" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.9.tgz#041b05df45755e587a24942279b9d113146e1c96" - integrity sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ== +graphemer@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" + integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== graphql-tag@2.11.0, graphql-tag@^2.4.2: version "2.11.0" @@ -2819,11 +3936,28 @@ gulp-filter@^7.0.0: streamfilter "^3.0.0" to-absolute-glob "^2.0.2" +gunzip-maybe@^1.4.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz#b913564ae3be0eda6f3de36464837a9cd94b98ac" + integrity sha512-4haO1M4mLO91PW57BMsDFf75UmwoRX0GkdD+Faw+Lr+r/OZrOCS0pIBwOL1xCKQqnQzbNFGgK2V2CpBUPeFNTw== + dependencies: + browserify-zlib "^0.1.4" + is-deflate "^1.0.0" + is-gzip "^1.0.0" + peek-stream "^1.1.0" + pumpify "^1.3.3" + through2 "^2.0.3" + has-bigints@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" integrity sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA== +has-bigints@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.1.0.tgz#28607e965ac967e03cd2a2c70a2636a1edad49fe" + integrity sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg== + has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" @@ -2834,11 +3968,37 @@ has-flag@^4.0.0: resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== +has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-proto@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.2.0.tgz#5de5a6eabd95fdffd9818b43055e8065e39fe9d5" + integrity sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ== + dependencies: + dunder-proto "^1.0.0" + has-symbols@^1.0.0, has-symbols@^1.0.1, has-symbols@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423" integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw== +has-symbols@^1.0.3, has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + has@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" @@ -2846,6 +4006,13 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" +hash-base@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-2.0.2.tgz#66ea1d856db4e8a5470cadf6fce23ae5244ef2e1" + integrity sha512-0TROgQ1/SxE6KmxWSvXHvRj90/Xo1JvZShofnYF+f6ZsGtR4eES7WfrQzPalmyagfKZCXpVnitiRebZulWsbiw== + dependencies: + inherits "^2.0.1" + hash-base@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.1.0.tgz#55c381d9e06e1d2997a883b4a3fddfe7f0d3af33" @@ -2863,7 +4030,38 @@ hash.js@^1.0.0, hash.js@^1.0.3: inherits "^2.0.3" minimalistic-assert "^1.0.1" -he@1.2.0: +hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +hast-util-to-html@^9.0.5: + version "9.0.5" + resolved "https://registry.yarnpkg.com/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz#ccc673a55bb8e85775b08ac28380f72d47167005" + integrity sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw== + dependencies: + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + ccount "^2.0.0" + comma-separated-tokens "^2.0.0" + hast-util-whitespace "^3.0.0" + html-void-elements "^3.0.0" + mdast-util-to-hast "^13.0.0" + property-information "^7.0.0" + space-separated-tokens "^2.0.0" + stringify-entities "^4.0.0" + zwitch "^2.0.4" + +hast-util-whitespace@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz#7778ed9d3c92dd9e8c5c8f648a49c21fc51cb621" + integrity sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw== + dependencies: + "@types/hast" "^3.0.0" + +he@1.2.0, he@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== @@ -2877,11 +4075,6 @@ hmac-drbg@^1.0.1: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hosted-git-info@^2.1.4: - version "2.8.9" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" - integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== - html-encoding-sniffer@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz#2cb1a8cf0db52414776e5b2a7a04d5dd98158de9" @@ -2889,55 +4082,56 @@ html-encoding-sniffer@^3.0.0: dependencies: whatwg-encoding "^2.0.0" -http-assert@^1.3.0: - version "1.4.1" - resolved "https://registry.yarnpkg.com/http-assert/-/http-assert-1.4.1.tgz#c5f725d677aa7e873ef736199b89686cceb37878" - integrity sha512-rdw7q6GTlibqVVbXr0CKelfV5iY8G2HqEUkhSk297BMbSpSL8crXC+9rjKoMcZZEsksX30le6f/4ul4E28gegw== +html-escaper@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" + integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== + +html-void-elements@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-3.0.0.tgz#fc9dbd84af9e747249034d4d62602def6517f1d7" + integrity sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg== + +http-assert@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/http-assert/-/http-assert-1.5.0.tgz#c389ccd87ac16ed2dfa6246fd73b926aa00e6b8f" + integrity sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w== dependencies: deep-equal "~1.0.1" - http-errors "~1.7.2" + http-errors "~1.8.0" -http-errors@^1.6.3, http-errors@^1.7.3: - version "1.8.0" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.0.tgz#75d1bbe497e1044f51e4ee9e704a62f28d336507" - integrity sha512-4I8r0C5JDhT5VkvI47QktDW75rNlGVsUf/8hzjCC/wkWI/jdTRmBb9aI7erSG82r1bjKY3F6k28WnsVxB1C73A== +http-errors@^1.7.3, http-errors@~1.8.0: + version "1.8.1" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c" + integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g== dependencies: depd "~1.1.2" inherits "2.0.4" setprototypeof "1.2.0" statuses ">= 1.5.0 < 2" - toidentifier "1.0.0" + toidentifier "1.0.1" + +http-errors@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" http-errors@~1.6.2: version "1.6.3" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" - integrity sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0= + integrity sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A== dependencies: depd "~1.1.2" inherits "2.0.3" setprototypeof "1.1.0" statuses ">= 1.4.0 < 2" -http-errors@~1.7.2: - version "1.7.3" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06" - integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw== - dependencies: - depd "~1.1.2" - inherits "2.0.4" - setprototypeof "1.1.1" - statuses ">= 1.5.0 < 2" - toidentifier "1.0.0" - -http-proxy-agent@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz#8a8c8ef7f5932ccf953c296ca8291b95aa74aa3a" - integrity sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg== - dependencies: - "@tootallnate/once" "1" - agent-base "6" - debug "4" - http-proxy-agent@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" @@ -2947,6 +4141,14 @@ http-proxy-agent@^5.0.0: agent-base "6" debug "4" +http-proxy-agent@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" + integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig== + dependencies: + agent-base "^7.1.0" + debug "^4.3.4" + https-proxy-agent@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2" @@ -2955,12 +4157,12 @@ https-proxy-agent@^5.0.0: agent-base "6" debug "4" -https-proxy-agent@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" - integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== +https-proxy-agent@^7.0.0, https-proxy-agent@^7.0.5, https-proxy-agent@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" + integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== dependencies: - agent-base "6" + agent-base "^7.1.2" debug "4" human-signals@^1.1.1: @@ -2985,22 +4187,27 @@ icss-utils@^5.0.0, icss-utils@^5.1.0: resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA== -ieee754@^1.1.13, ieee754@^1.2.1: +ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== -ignore@^4.0.6: - version "4.0.6" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" - integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== +ignore@^5.2.0: + version "5.2.4" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324" + integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ== + +ignore@^7.0.0: + version "7.0.5" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9" + integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== -ignore@^5.1.4: - version "5.1.8" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57" - integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw== +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== -import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1: +import-fresh@^3.2.1: version "3.3.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== @@ -3008,6 +4215,14 @@ import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1: parent-module "^1.0.0" resolve-from "^4.0.0" +import-fresh@^3.3.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf" + integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + import-local@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.0.2.tgz#a8cfd0431d1de4a2199703d003e3e62364fa6db6" @@ -3021,20 +4236,15 @@ imurmurhash@^0.1.4: resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= -indexes-of@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607" - integrity sha1-8w9xbI4r00bHtn0985FVZqfAVgc= - inflight@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== dependencies: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.3, inherits@~2.0.4: +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3, inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -3042,7 +4252,16 @@ inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, i inherits@2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" - integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= + integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw== + +internal-slot@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961" + integrity sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw== + dependencies: + es-errors "^1.3.0" + hasown "^2.0.2" + side-channel "^1.1.0" interpret@^2.2.0: version "2.2.0" @@ -3064,16 +4283,43 @@ is-arguments@^1.0.4: dependencies: call-bind "^1.0.0" +is-array-buffer@^3.0.4, is-array-buffer@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz#65742e1e687bd2cc666253068fd8707fe4d44280" + integrity sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= +is-async-function@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-async-function/-/is-async-function-2.1.1.tgz#3e69018c8e04e73b738793d020bfe884b9fd3523" + integrity sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ== + dependencies: + async-function "^1.0.0" + call-bound "^1.0.3" + get-proto "^1.0.1" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.0" + is-bigint@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.1.tgz#6923051dfcbc764278540b9ce0e6b3213aa5ebc2" integrity sha512-J0ELF4yHFxHy0cmSxZuheDOz2luOdVvqjwmEcj8H/L1JHeuEDSDbeRP+Dk9kFVk5RTFzbucJ2Kb9F7ixY2QaCg== +is-bigint@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.1.0.tgz#dda7a3445df57a42583db4228682eba7c4170672" + integrity sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ== + dependencies: + has-bigints "^1.0.2" + is-binary-path@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" @@ -3088,16 +4334,43 @@ is-boolean-object@^1.1.0: dependencies: call-bind "^1.0.0" +is-boolean-object@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz#7067f47709809a393c71ff5bb3e135d8a9215d9e" + integrity sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + is-buffer@^1.1.5, is-buffer@~1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== +is-bun-module@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-bun-module/-/is-bun-module-2.0.0.tgz#4d7859a87c0fcac950c95e666730e745eae8bddd" + integrity sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ== + dependencies: + semver "^7.7.1" + is-callable@^1.1.4, is-callable@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.3.tgz#8b1e0500b73a1d76c70487636f368e519de8db8e" integrity sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ== +is-callable@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" + integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== + +is-core-module@^2.13.0, is-core-module@^2.15.1, is-core-module@^2.16.0: + version "2.16.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== + dependencies: + hasown "^2.0.2" + is-core-module@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.2.0.tgz#97037ef3d52224d85163f5597b2b63d9afed981a" @@ -3105,11 +4378,33 @@ is-core-module@^2.2.0: dependencies: has "^1.0.3" +is-data-view@^1.0.1, is-data-view@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.2.tgz#bae0a41b9688986c2188dda6657e56b8f9e63b8e" + integrity sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw== + dependencies: + call-bound "^1.0.2" + get-intrinsic "^1.2.6" + is-typed-array "^1.1.13" + is-date-object@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.2.tgz#bda736f2cd8fd06d32844e7743bfa7494c3bfd7e" integrity sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g== +is-date-object@^1.0.5, is-date-object@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.1.0.tgz#ad85541996fc7aa8b2729701d27b7319f95d82f7" + integrity sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg== + dependencies: + call-bound "^1.0.2" + has-tostringtag "^1.0.2" + +is-deflate@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-deflate/-/is-deflate-1.0.0.tgz#c862901c3c161fb09dac7cdc7e784f80e98f2f14" + integrity sha512-YDoFpuZWu1VRXlsnlYMzKyVRITXj7Ej/V9gXQ2/pAe7X1J7M/RNOqaIYi6qUn+B7nGyB9pDXrv02dsB58d2ZAQ== + is-extendable@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" @@ -3120,22 +4415,29 @@ is-extendable@^1.0.1: is-extglob@^2.1.0, is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== -is-fullwidth-code-point@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" - integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= +is-finalizationregistry@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz#eefdcdc6c94ddd0674d9c85887bf93f944a97c90" + integrity sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg== + dependencies: + call-bound "^1.0.3" is-fullwidth-code-point@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== -is-generator-function@^1.0.7: - version "1.0.9" - resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.9.tgz#e5f82c2323673e7fcad3d12858c83c4039f6399c" - integrity sha512-ZJ34p1uvIfptHCN7sFTjGibB9/oBg17sHqzDLfuwhvmN/qLVvIQXRQ8licZQ35WJ8KuEQt/etnnzQFI9C9Ue/A== +is-generator-function@^1.0.10, is-generator-function@^1.0.7: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.1.0.tgz#bf3eeda931201394f57b5dba2800f91a238309ca" + integrity sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ== + dependencies: + call-bound "^1.0.3" + get-proto "^1.0.0" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.0" is-glob@^3.1.0: version "3.1.0" @@ -3144,13 +4446,35 @@ is-glob@^3.1.0: dependencies: is-extglob "^2.1.0" -is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: +is-glob@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== dependencies: is-extglob "^2.1.1" +is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-gzip@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-gzip/-/is-gzip-1.0.0.tgz#6ca8b07b99c77998025900e555ced8ed80879a83" + integrity sha512-rcfALRIb1YewtnksfRIHGcIY93QnK8BIQ/2c9yDYcG/Y6+vRoJuTWBmmSEbyLLYtXm7q35pHOHbZFQBaLrhlWQ== + +is-interactive@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-2.0.0.tgz#40c57614593826da1100ade6059778d597f16e90" + integrity sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ== + +is-map@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" + integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== + is-nan@^1.2.1: version "1.3.2" resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.2.tgz#043a54adea31748b55b6cd4e09aadafa69bd9e1d" @@ -3159,11 +4483,6 @@ is-nan@^1.2.1: call-bind "^1.0.0" define-properties "^1.1.3" -is-natural-number@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/is-natural-number/-/is-natural-number-4.0.1.tgz#ab9d76e1db4ced51e35de0c72ebecf09f734cde8" - integrity sha1-q5124dtM7VHjXeDHLr7PCfc0zeg= - is-negated-glob@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-negated-glob/-/is-negated-glob-1.0.0.tgz#6910bca5da8c95e784b5751b976cf5a10fee36d2" @@ -3174,11 +4493,24 @@ is-negative-zero@^2.0.1: resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.1.tgz#3de746c18dda2319241a53675908d8f766f11c24" integrity sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w== +is-negative-zero@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747" + integrity sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw== + is-number-object@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.4.tgz#36ac95e741cf18b283fc1ddf5e83da798e3ec197" integrity sha512-zohwelOAur+5uXtk8O3GPQ1eAcu4ZX3UwxQhUlfFFMNpUd83gXgjbhJh6HmB6LUNV/ieOLQuDwJO3dWJosUeMw== +is-number-object@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.1.1.tgz#144b21e95a1bc148205dcc2814a9134ec41b2541" + integrity sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + is-number@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" @@ -3196,11 +4528,6 @@ is-plain-object@^2.0.4: dependencies: isobject "^3.0.1" -is-plain-object@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" - integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== - is-potential-custom-element-name@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" @@ -3214,6 +4541,16 @@ is-regex@^1.1.2: call-bind "^1.0.2" has-symbols "^1.0.1" +is-regex@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" + integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== + dependencies: + call-bound "^1.0.2" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + is-relative@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-relative/-/is-relative-1.0.0.tgz#a1bb6935ce8c5dba1e8b9754b9b2dcc020e2260d" @@ -3221,10 +4558,17 @@ is-relative@^1.0.0: dependencies: is-unc-path "^1.0.0" -is-stream@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" - integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= +is-set@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" + integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg== + +is-shared-array-buffer@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz#9b67844bd9b7f246ba0708c3a93e34269c774f6f" + integrity sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A== + dependencies: + call-bound "^1.0.3" is-stream@^2.0.0: version "2.0.0" @@ -3236,6 +4580,14 @@ is-string@^1.0.5: resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.5.tgz#40493ed198ef3ff477b8c7f92f644ec82a5cd3a6" integrity sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ== +is-string@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.1.1.tgz#92ea3f3d5c5b6e039ca8677e5ac8d07ea773cbb9" + integrity sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + is-symbol@^1.0.2, is-symbol@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937" @@ -3243,6 +4595,22 @@ is-symbol@^1.0.2, is-symbol@^1.0.3: dependencies: has-symbols "^1.0.1" +is-symbol@^1.0.4, is-symbol@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.1.1.tgz#f47761279f532e2b05a7024a7506dbbedacd0634" + integrity sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w== + dependencies: + call-bound "^1.0.2" + has-symbols "^1.1.0" + safe-regex-test "^1.1.0" + +is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15: + version "1.1.15" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b" + integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== + dependencies: + which-typed-array "^1.1.16" + is-typed-array@^1.1.3: version "1.1.5" resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.5.tgz#f32e6e096455e329eb7b423862456aa213f0eb4e" @@ -3266,6 +4634,16 @@ is-unicode-supported@^0.1.0: resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== +is-unicode-supported@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz#d824984b616c292a2e198207d4a609983842f714" + integrity sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ== + +is-unicode-supported@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz#09f0ab0de6d3744d48d265ebb98f65d11f2a9b3a" + integrity sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ== + is-utf8@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" @@ -3276,6 +4654,26 @@ is-valid-glob@^1.0.0: resolved "https://registry.yarnpkg.com/is-valid-glob/-/is-valid-glob-1.0.0.tgz#29bf3eff701be2d4d315dbacc39bc39fe8f601aa" integrity sha512-AhiROmoEFDSsjx8hW+5sGwgKVIORcXnrlAx/R0ZSeaPw70Vw0CqkGBBhHGL58Uox2eXnU1AnvXJl1XlyedO5bA== +is-weakmap@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd" + integrity sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w== + +is-weakref@^1.0.2, is-weakref@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.1.1.tgz#eea430182be8d64174bd96bffbc46f21bf3f9293" + integrity sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew== + dependencies: + call-bound "^1.0.3" + +is-weakset@^2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.4.tgz#c9f5deb0bc1906c6d6f1027f284ddf459249daca" + integrity sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ== + dependencies: + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + is-windows@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" @@ -3286,21 +4684,57 @@ isarray@0.0.1: resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= -isarray@^1.0.0, isarray@~1.0.0: +isarray@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" + integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== + +isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" - integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" - integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== isobject@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== +istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" + integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== + +istanbul-lib-report@^3.0.0, istanbul-lib-report@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d" + integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw== + dependencies: + istanbul-lib-coverage "^3.0.0" + make-dir "^4.0.0" + supports-color "^7.1.0" + +istanbul-reports@^3.1.6: + version "3.2.0" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz#cb4535162b5784aa623cee21a7252cf2c807ac93" + integrity sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + jest-worker@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.6.2.tgz#7f72cbc4d643c365e27b9fd775f9d0eaa9c7a8ed" @@ -3319,30 +4753,24 @@ jest-worker@^27.4.5: merge-stream "^2.0.0" supports-color "^8.0.0" -joycon@^2.2.5: - version "2.2.5" - resolved "https://registry.yarnpkg.com/joycon/-/joycon-2.2.5.tgz#8d4cf4cbb2544d7b7583c216fcdfec19f6be1615" - integrity sha512-YqvUxoOcVPnCp0VU1/56f+iKSdvIRJYPznH22BdXV3xMk75SFXhWeJkZ8C9XxUWt1b5x2X1SxuFygW1U0FmkEQ== - "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-yaml@4.1.0: +js-yaml@4.1.0, js-yaml@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== dependencies: argparse "^2.0.1" -js-yaml@^3.13.1: - version "3.14.1" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" - integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== +js-yaml@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" + integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== dependencies: - argparse "^1.0.7" - esprima "^4.0.0" + argparse "^2.0.1" jsdom-global@3.0.2: version "3.0.2" @@ -3382,12 +4810,12 @@ jsdom@19.0.0: ws "^8.2.3" xml-name-validator "^4.0.0" -json-parse-better-errors@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" - integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw== +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== -json-parse-even-better-errors@^2.3.0: +json-parse-even-better-errors@^2.3.0, json-parse-even-better-errors@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== @@ -3397,28 +4825,33 @@ json-schema-traverse@^0.4.1: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== -json-schema-traverse@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" - integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== - json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= -json5@2.2.2, json5@^2.1.2, json5@^2.2.0: +json5@2.2.2: version "2.2.2" resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.2.tgz#64471c5bdcc564c18f7c1d4df2e2297f2457c5ab" integrity sha512-46Tk9JiOL2z7ytNQWFLpj99RZkVgeHf87yGQKsIkaPz1qSH9UczKH1rO7K3wgRselo0tYMUNfecYpm/p1vC7tQ== -json5@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe" - integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow== +json5@^1.0.1, json5@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593" + integrity sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA== dependencies: minimist "^1.2.0" +json5@^2.1.2: + version "2.2.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + +jsonc-parser@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.3.1.tgz#f2a524b4f7fd11e3d791e559977ad60b98b798b4" + integrity sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ== + jsonfile@^6.0.1: version "6.1.0" resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" @@ -3428,6 +4861,16 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +jszip@^3.10.1: + version "3.10.1" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2" + integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g== + dependencies: + lie "~3.3.0" + pako "~1.0.2" + readable-stream "~2.3.6" + setimmediate "^1.0.5" + just-extend@^4.0.2: version "4.1.1" resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.1.1.tgz#158f1fdb01f128c411dc8b286a7b4837b3545282" @@ -3440,30 +4883,34 @@ keygrip@~1.1.0: dependencies: tsscmp "1.0.6" +keyv@^4.5.4: + version "4.5.4" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +kleur@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" + integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== + koa-compose@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-4.1.0.tgz#507306b9371901db41121c812e923d0d67d3e877" integrity sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw== -koa-convert@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/koa-convert/-/koa-convert-2.0.0.tgz#86a0c44d81d40551bae22fee6709904573eea4f5" - integrity sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA== - dependencies: - co "^4.6.0" - koa-compose "^4.1.0" - koa-morgan@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/koa-morgan/-/koa-morgan-1.0.1.tgz#08052e0ce0d839d3c43178b90a5bb3424bef1f99" - integrity sha1-CAUuDODYOdPEMXi5CluzQkvvH5k= + integrity sha512-JOUdCNlc21G50afBXfErUrr1RKymbgzlrO5KURY+wmDG1Uvd2jmxUJcHgylb/mYXy2SjiNZyYim/ptUBGsIi3A== dependencies: morgan "^1.6.1" -koa-mount@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/koa-mount/-/koa-mount-4.0.0.tgz#e0265e58198e1a14ef889514c607254ff386329c" - integrity sha512-rm71jaA/P+6HeCpoRhmCv8KVBIi0tfGuO/dMKicbQnQW/YJntJ6MnnspkodoA4QstMVEZArsCphmd0bJEtoMjQ== +koa-mount@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/koa-mount/-/koa-mount-4.2.0.tgz#66f4436cac76af3075ac432d503299f7678d5914" + integrity sha512-2iHQc7vbA9qLeVq5gKAYh3m5DOMMlMfIKjW/REPAS18Mf63daCJHHVXY9nbu7ivrnYn5PiPC4CE523Tf5qvjeQ== dependencies: debug "^4.0.1" koa-compose "^4.1.0" @@ -3485,33 +4932,28 @@ koa-static@^5.0.0: debug "^3.1.0" koa-send "^5.0.0" -koa@^2.13.4: - version "2.13.4" - resolved "https://registry.yarnpkg.com/koa/-/koa-2.13.4.tgz#ee5b0cb39e0b8069c38d115139c774833d32462e" - integrity sha512-43zkIKubNbnrULWlHdN5h1g3SEKXOEzoAlRsHOTFpnlDu8JlAOZSMJBLULusuXRequboiwJcj5vtYXKB3k7+2g== +koa@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/koa/-/koa-3.0.3.tgz#40a97b6da0c0d49a704af82db2b3e5a618ed9042" + integrity sha512-MeuwbCoN1daWS32/Ni5qkzmrOtQO2qrnfdxDHjrm6s4b59yG4nexAJ0pTEFyzjLp0pBVO80CZp0vW8Ze30Ebow== dependencies: - accepts "^1.3.5" - cache-content-type "^1.0.0" - content-disposition "~0.5.2" - content-type "^1.0.4" - cookies "~0.8.0" - debug "^4.3.2" + accepts "^1.3.8" + content-disposition "~0.5.4" + content-type "^1.0.5" + cookies "~0.9.1" delegates "^1.0.0" - depd "^2.0.0" - destroy "^1.0.4" - encodeurl "^1.0.2" + destroy "^1.2.0" + encodeurl "^2.0.0" escape-html "^1.0.3" fresh "~0.5.2" - http-assert "^1.3.0" - http-errors "^1.6.3" - is-generator-function "^1.0.7" + http-assert "^1.5.0" + http-errors "^2.0.0" koa-compose "^4.1.0" - koa-convert "^2.0.0" - on-finished "^2.3.0" - only "~0.0.2" - parseurl "^1.3.2" - statuses "^1.5.0" - type-is "^1.6.16" + mime-types "^3.0.1" + on-finished "^2.4.1" + parseurl "^1.3.3" + statuses "^2.0.1" + type-is "^2.0.1" vary "^1.1.2" lazystream@^1.0.0: @@ -3549,25 +4991,24 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +lie@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" + integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ== + dependencies: + immediate "~3.0.5" + lines-and-columns@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA= -listenercount@~1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/listenercount/-/listenercount-1.0.1.tgz#84c8a72ab59c4725321480c975e6508342e70937" - integrity sha1-hMinKrWcRyUyFIDJdeZQg0LnCTc= - -load-json-file@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-2.0.0.tgz#7947e42149af80d696cbf797bcaabcfe1fe29ca8" - integrity sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg= +linkify-it@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.0.tgz#9ef238bfa6dc70bd8e7f9572b52d369af569b421" + integrity sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ== dependencies: - graceful-fs "^4.1.2" - parse-json "^2.2.0" - pify "^2.0.0" - strip-bom "^3.0.0" + uc.micro "^2.0.0" loader-runner@^4.2.0: version "4.2.0" @@ -3583,23 +5024,15 @@ loader-utils@^1.1.0: emojis-list "^3.0.0" json5 "^1.0.1" -loader-utils@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.0.tgz#e4cace5b816d425a166b5f097e10cd12b36064b0" - integrity sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ== +loader-utils@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c" + integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw== dependencies: big.js "^5.2.2" emojis-list "^3.0.0" json5 "^2.1.2" -locate-path@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" - integrity sha1-K1aLJl7slExtnA3pw9u7ygNUzY4= - dependencies: - p-locate "^2.0.0" - path-exists "^3.0.0" - locate-path@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" @@ -3619,12 +5052,17 @@ lodash.get@^4.4.2: resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= -lodash@^4.16.4, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" - integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lodash@^4.16.4, lodash@^4.17.15: + version "4.17.23" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a" + integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w== -log-symbols@4.1.0: +log-symbols@4.1.0, log-symbols@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== @@ -3632,6 +5070,14 @@ log-symbols@4.1.0: chalk "^4.1.0" is-unicode-supported "^0.1.0" +log-symbols@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-6.0.0.tgz#bb95e5f05322651cac30c0feb6404f9f2a8a9439" + integrity sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw== + dependencies: + chalk "^5.3.0" + is-unicode-supported "^1.3.0" + loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -3646,6 +5092,11 @@ lru-cache@6.0.0, lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + lru-cache@^4.1.5: version "4.1.5" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" @@ -3654,23 +5105,45 @@ lru-cache@^4.1.5: pseudomap "^1.0.2" yallist "^2.1.2" -make-dir@^1.0.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c" - integrity sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ== +make-dir@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" + integrity sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw== dependencies: - pify "^3.0.0" + semver "^7.5.3" + +make-error@^1.1.1: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== map-stream@0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.0.7.tgz#8a1f07896d82b10926bd3744a2420009f88974a8" integrity sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ== +markdown-it@^14.1.0: + version "14.1.0" + resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-14.1.0.tgz#3c3c5992883c633db4714ccb4d7b5935d98b7d45" + integrity sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg== + dependencies: + argparse "^2.0.1" + entities "^4.4.0" + linkify-it "^5.0.0" + mdurl "^2.0.0" + punycode.js "^2.3.1" + uc.micro "^2.1.0" + marked@^4.0.10: version "4.0.10" resolved "https://registry.yarnpkg.com/marked/-/marked-4.0.10.tgz#423e295385cc0c3a70fa495e0df68b007b879423" integrity sha512-+QvuFj0nGgO970fySghXGmuw+Fd0gD2x3+MqCWLIPf5oxdv1Ka6b2q+z9RP01P/IaKPMEramy+7cNy/Lw8c3hw== +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + md5.js@^1.3.4: version "1.3.5" resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" @@ -3689,25 +5162,37 @@ md5@^2.1.0: crypt "0.0.2" is-buffer "~1.1.6" -media-typer@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" - integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= +mdast-util-to-hast@^13.0.0: + version "13.2.1" + resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz#d7ff84ca499a57e2c060ae67548ad950e689a053" + integrity sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + "@ungap/structured-clone" "^1.0.0" + devlop "^1.0.0" + micromark-util-sanitize-uri "^2.0.0" + trim-lines "^3.0.0" + unist-util-position "^5.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" + +mdurl@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0" + integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w== -memfs@^3.1.2: - version "3.2.0" - resolved "https://registry.yarnpkg.com/memfs/-/memfs-3.2.0.tgz#f9438e622b5acd1daa8a4ae160c496fdd1325b26" - integrity sha512-f/xxz2TpdKv6uDn6GtHee8ivFyxwxmPuXatBb1FBwxYNuVpbM3k/Y1Z+vC0mH/dIXXrukYfe3qe5J32Dfjg93A== - dependencies: - fs-monkey "1.0.1" +media-typer@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.0.tgz#6ab74b8f2d3320f2064b2a87a38e7931ff3a5561" + integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== -memory-fs@^0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.5.0.tgz#324c01288b88652966d161db77838720845a8e3c" - integrity sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA== +memfs@^3.4.1: + version "3.6.0" + resolved "https://registry.yarnpkg.com/memfs/-/memfs-3.6.0.tgz#d7a2110f86f79dd950a8b6df6d57bc984aa185f6" + integrity sha512-EGowvkkgbMcIChjMTMkESFDbZeSh8xZ7kNSF0hAiAN4Jh6jgHCRS0Ga/+C8y6Au+oqpezRHCfPsmJ2+DwAgiwQ== dependencies: - errno "^0.1.3" - readable-stream "^2.0.1" + fs-monkey "^1.0.4" merge-options@3.0.4: version "3.0.4" @@ -3726,18 +5211,45 @@ merge2@^1.3.0: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -methods@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" - integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= +micromark-util-character@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz#2f987831a40d4c510ac261e89852c4e9703ccda6" + integrity sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q== + dependencies: + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" -micromatch@^4.0.0, micromatch@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259" - integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q== +micromark-util-encode@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz#0d51d1c095551cfaac368326963cf55f15f540b8" + integrity sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw== + +micromark-util-sanitize-uri@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz#ab89789b818a58752b73d6b55238621b7faa8fd7" + integrity sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-symbol "^2.0.0" + +micromark-util-symbol@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz#e5da494e8eb2b071a0d08fb34f6cefec6c0a19b8" + integrity sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q== + +micromark-util-types@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz#f00225f5f5a0ebc3254f96c36b6605c4b393908e" + integrity sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA== + +micromatch@^4.0.0, micromatch@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== dependencies: - braces "^3.0.1" - picomatch "^2.0.5" + braces "^3.0.3" + picomatch "^2.3.1" miller-rabin@^4.0.0: version "4.0.1" @@ -3747,35 +5259,52 @@ miller-rabin@^4.0.0: bn.js "^4.0.0" brorand "^1.0.1" -mime-db@1.46.0: - version "1.46.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.46.0.tgz#6267748a7f799594de3cbc8cde91def349661cee" - integrity sha512-svXaP8UQRZ5K7or+ZmfNhg2xX3yKDMUzqadsSqi4NCH/KomcH75MAMYAGVlvXn4+b/xOPhS3I2uHKRUzvjY7BQ== - mime-db@1.48.0: version "1.48.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.48.0.tgz#e35b31045dd7eada3aaad537ed88a33afbef2d1d" integrity sha512-FM3QwxV+TnZYQ2aRqhlKBMHxk10lTbMt3bBkMAp54ddrNeVSfcQYOOKuGuy3Ddrm38I04If834fOUSq1yzslJQ== -mime-types@^2.1.12, mime-types@^2.1.27: - version "2.1.29" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.29.tgz#1d4ab77da64b91f5f72489df29236563754bb1b2" - integrity sha512-Y/jMt/S5sR9OaqteJtslsFZKWOIIqMACsJSiHghlCAyhf7jfVYjKBmLiX8OgpWeW+fjJ2b+Az69aPFPkUOY6xQ== - dependencies: - mime-db "1.46.0" +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-db@^1.54.0: + version "1.54.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5" + integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== -mime-types@^2.1.18, mime-types@~2.1.24: +mime-types@^2.1.12, mime-types@^2.1.27: version "2.1.31" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.31.tgz#a00d76b74317c61f9c2db2218b8e9f8e9c5c9e6b" integrity sha512-XGZnNzm3QvgKxa8dpzyhFTHmpP3l5YNusmne07VUOXxou9CqUqYa/HBy124RqtVh/O2pECas/MOcsDgpilPOPg== dependencies: mime-db "1.48.0" +mime-types@^3.0.0, mime-types@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.1.tgz#b1d94d6997a9b32fd69ebaed0db73de8acb519ce" + integrity sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA== + dependencies: + mime-db "^1.54.0" + +mime-types@~2.1.34: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== +mimic-function@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/mimic-function/-/mimic-function-5.0.1.tgz#acbe2b3349f99b9deaca7fb70e48b83e94e67076" + integrity sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA== + minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" @@ -3786,31 +5315,48 @@ minimalistic-crypto-utils@^1.0.1: resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= -minimatch@3.0.4, minimatch@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" - integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== +minimatch@4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-4.2.1.tgz#40d9d511a46bdc4e563c22c3080cde9c0d8299b4" + integrity sha512-9Uq1ChtSZO+Mxa/CL1eGizn2vRn3MlLgzhT0Iz8zaY8NdvxvB0d5QdPFmCKf7JKA9Lerx5vRrnwO03jsSfGG9g== dependencies: brace-expansion "^1.1.7" -minimatch@^3.1.1: +minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== dependencies: brace-expansion "^1.1.7" +minimatch@^9.0.3, minimatch@^9.0.4, minimatch@^9.0.5: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: version "1.2.7" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== +minimist@^1.2.8: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + mkdirp@1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -"mkdirp@>=0.5 0", mkdirp@^0.5.1, mkdirp@~0.5.1: +mkdirp@^0.5.1, mkdirp@~0.5.1: version "0.5.5" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== @@ -3836,33 +5382,58 @@ mocha-multi-reporters@1.1.7: debug "^3.1.0" lodash "^4.16.4" +mocha@^11.1.0: + version "11.7.2" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.2.tgz#3c0079fe5cc2f8ea86d99124debcc42bb1ab22b5" + integrity sha512-lkqVJPmqqG/w5jmmFtiRvtA2jkDyNVUcefFJKb2uyX4dekk8Okgqop3cgbFiaIvj8uCRJVTP5x9dfxGyXm2jvQ== + dependencies: + browser-stdout "^1.3.1" + chokidar "^4.0.1" + debug "^4.3.5" + diff "^7.0.0" + escape-string-regexp "^4.0.0" + find-up "^5.0.0" + glob "^10.4.5" + he "^1.2.0" + js-yaml "^4.1.0" + log-symbols "^4.1.0" + minimatch "^9.0.5" + ms "^2.1.3" + picocolors "^1.1.1" + serialize-javascript "^6.0.2" + strip-json-comments "^3.1.1" + supports-color "^8.1.1" + workerpool "^9.2.0" + yargs "^17.7.2" + yargs-parser "^21.1.1" + yargs-unparser "^2.0.0" + mocha@^9.0.1: - version "9.0.2" - resolved "https://registry.yarnpkg.com/mocha/-/mocha-9.0.2.tgz#e84849b61f406a680ced85af76425f6f3108d1a0" - integrity sha512-FpspiWU+UT9Sixx/wKimvnpkeW0mh6ROAKkIaPokj3xZgxeRhcna/k5X57jJghEr8X+Cgu/Vegf8zCX5ugSuTA== + version "9.2.2" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-9.2.2.tgz#d70db46bdb93ca57402c809333e5a84977a88fb9" + integrity sha512-L6XC3EdwT6YrIk0yXpavvLkn8h+EU+Y5UcCHKECyMbdUIxyMuZj4bX4U9e1nvnvUUvQVsV2VHQr5zLdcUkhW/g== dependencies: "@ungap/promise-all-settled" "1.1.2" ansi-colors "4.1.1" browser-stdout "1.3.1" - chokidar "3.5.2" - debug "4.3.1" + chokidar "3.5.3" + debug "4.3.3" diff "5.0.0" escape-string-regexp "4.0.0" find-up "5.0.0" - glob "7.1.7" + glob "7.2.0" growl "1.10.5" he "1.2.0" js-yaml "4.1.0" log-symbols "4.1.0" - minimatch "3.0.4" + minimatch "4.2.1" ms "2.1.3" - nanoid "3.1.23" + nanoid "3.3.1" serialize-javascript "6.0.0" strip-json-comments "3.1.1" supports-color "8.1.1" which "2.0.2" - wide-align "1.1.3" - workerpool "6.1.5" + workerpool "6.2.0" yargs "16.2.0" yargs-parser "20.2.4" yargs-unparser "2.0.0" @@ -3881,14 +5452,14 @@ morgan@^1.6.1: ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@2.1.3, ms@^2.1.1: +ms@2.1.3, ms@^2.1.1, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -3904,20 +5475,30 @@ multimatch@^5.0.0: arrify "^2.0.1" minimatch "^3.0.4" -nanoid@3.1.23, nanoid@^3.1.23: - version "3.1.23" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.23.tgz#f744086ce7c2bc47ee0a8472574d5c78e4183a81" - integrity sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw== +nanoid@3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.1.tgz#6347a18cac88af88f58af0b3594b723d5e99bb35" + integrity sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw== + +nanoid@^3.3.11: + version "3.3.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + +napi-postinstall@^0.3.0: + version "0.3.4" + resolved "https://registry.yarnpkg.com/napi-postinstall/-/napi-postinstall-0.3.4.tgz#7af256d6588b5f8e952b9190965d6b019653bbb9" + integrity sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ== natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= -negotiator@0.6.2: - version "0.6.2" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" - integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== neo-async@^2.6.2: version "2.6.2" @@ -3935,27 +5516,22 @@ nise@^4.0.1: just-extend "^4.0.2" path-to-regexp "^1.7.0" -node-fetch@2.6.7, node-fetch@^2.6.1: +node-abort-controller@^3.0.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-3.1.1.tgz#a94377e964a9a37ac3976d848cb5c765833b8548" + integrity sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ== + +node-fetch@2.6.7: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== dependencies: whatwg-url "^5.0.0" -node-releases@^1.1.71: - version "1.1.72" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.72.tgz#14802ab6b1039a79a0c7d662b610a5bbd76eacbe" - integrity sha512-LLUo+PpH3dU6XizX3iVoubUNheF/owjXCZZ5yACDxNnPtgFuludV1ZL3ayK1kVep42Rmm0+R9/Y60NQbZ2bifw== - -normalize-package-data@^2.3.2: - version "2.5.0" - resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" - integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== - dependencies: - hosted-git-info "^2.1.4" - resolve "^1.10.0" - semver "2 || 3 || 4 || 5" - validate-npm-package-license "^3.0.1" +node-releases@^2.0.18: + version "2.0.18" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.18.tgz#f010e8d35e2fe8d6b2944f03f70213ecedc4ca3f" + integrity sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g== normalize-path@^2.1.1: version "2.1.1" @@ -3993,6 +5569,11 @@ object-assign@^4.0.1, object-assign@^4.1.1: resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= +object-inspect@^1.13.3, object-inspect@^1.13.4: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + object-inspect@^1.9.0: version "1.9.0" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.9.0.tgz#c90521d74e1127b67266ded3394ad6116986533a" @@ -4021,20 +5602,58 @@ object.assign@^4.0.4, object.assign@^4.1.2: has-symbols "^1.0.1" object-keys "^1.1.1" -object.values@^1.1.1: - version "1.1.3" - resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.3.tgz#eaa8b1e17589f02f698db093f7c62ee1699742ee" - integrity sha512-nkF6PfDB9alkOUxpf1HNm/QlkeW3SReqL5WXeBLpEJJnlPSvRaDQpW3gQTksTN3fgJX4hL42RzKyOin6ff3tyw== +object.assign@^4.1.7: + version "4.1.7" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.7.tgz#8c14ca1a424c6a561b0bb2a22f66f5049a945d3d" + integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw== dependencies: - call-bind "^1.0.2" - define-properties "^1.1.3" - es-abstract "^1.18.0-next.2" - has "^1.0.3" + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + has-symbols "^1.1.0" + object-keys "^1.1.1" + +object.fromentries@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.8.tgz#f7195d8a9b97bd95cbc1999ea939ecd1a2b00c65" + integrity sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-object-atoms "^1.0.0" + +object.groupby@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/object.groupby/-/object.groupby-1.0.3.tgz#9b125c36238129f6f7b61954a1e7176148d5002e" + integrity sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" -on-finished@^2.3.0, on-finished@~2.3.0: +object.values@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.2.1.tgz#deed520a50809ff7f75a7cfd4bc64c7a038c6216" + integrity sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +on-finished@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + +on-finished@~2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" - integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= + integrity sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww== dependencies: ee-first "1.1.1" @@ -4046,7 +5665,7 @@ on-headers@~1.0.2: once@^1.3.0, once@^1.3.1, once@^1.3.2, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== dependencies: wrappy "1" @@ -4057,10 +5676,12 @@ onetime@^5.1.0: dependencies: mimic-fn "^2.1.0" -only@~0.0.2: - version "0.0.2" - resolved "https://registry.yarnpkg.com/only/-/only-0.0.2.tgz#2afde84d03e50b9a8edc444e30610a70295edfb4" - integrity sha1-Kv3oTQPlC5qO3EROMGEKcCle37Q= +onetime@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-7.0.0.tgz#9f16c92d8c9ef5120e3acd9dd9957cceecc1ab60" + integrity sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ== + dependencies: + mimic-function "^5.0.0" optimism@^0.10.0: version "0.10.3" @@ -4081,17 +5702,32 @@ optionator@^0.8.1: type-check "~0.3.2" word-wrap "~1.2.3" -optionator@^0.9.1: - version "0.9.1" - resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499" - integrity sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw== +optionator@^0.9.3: + version "0.9.4" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== dependencies: deep-is "^0.1.3" fast-levenshtein "^2.0.6" levn "^0.4.1" prelude-ls "^1.2.1" type-check "^0.4.0" - word-wrap "^1.2.3" + word-wrap "^1.2.5" + +ora@^8.1.0: + version "8.2.0" + resolved "https://registry.yarnpkg.com/ora/-/ora-8.2.0.tgz#8fbbb7151afe33b540dd153f171ffa8bd38e9861" + integrity sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw== + dependencies: + chalk "^5.3.0" + cli-cursor "^5.0.0" + cli-spinners "^2.9.2" + is-interactive "^2.0.0" + is-unicode-supported "^2.0.0" + log-symbols "^6.0.0" + stdin-discarder "^0.2.2" + string-width "^7.2.0" + strip-ansi "^7.1.0" ordered-read-streams@^1.0.0: version "1.0.1" @@ -4105,6 +5741,15 @@ os-browserify@^0.3.0: resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc= +own-keys@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358" + integrity sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg== + dependencies: + get-intrinsic "^1.2.6" + object-keys "^1.1.1" + safe-push-apply "^1.0.0" + p-all@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-all/-/p-all-1.0.0.tgz#93bdf53a55a23821fdfa98b4174a99bf7f31df8d" @@ -4112,13 +5757,6 @@ p-all@^1.0.0: dependencies: p-map "^1.0.0" -p-limit@^1.1.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" - integrity sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q== - dependencies: - p-try "^1.0.0" - p-limit@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" @@ -4133,13 +5771,6 @@ p-limit@^3.0.2, p-limit@^3.1.0: dependencies: yocto-queue "^0.1.0" -p-locate@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" - integrity sha1-IKAQOyIqcMj9OcwuWAaA893l7EM= - dependencies: - p-limit "^1.1.0" - p-locate@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" @@ -4159,16 +5790,26 @@ p-map@^1.0.0: resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b" integrity sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA== -p-try@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" - integrity sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M= - p-try@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== + +pako@~0.2.0: + version "0.2.9" + resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" + integrity sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA== + +pako@~1.0.2: + version "1.0.11" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" + integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + parent-module@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" @@ -4176,7 +5817,7 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" -parse-asn1@^5.0.0, parse-asn1@^5.1.5: +parse-asn1@^5.0.0, parse-asn1@^5.1.6: version "5.1.6" resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.6.tgz#385080a3ec13cb62a62d39409cb3e88844cdaed4" integrity sha512-RnZRo1EPU6JBnra2vGHj0yhp6ebyjBZpmUCLHWiFhxlzvBCCpAuZ7elsBp1PVAbQN0/04VD/19rfzlBSwLstMw== @@ -4187,14 +5828,7 @@ parse-asn1@^5.0.0, parse-asn1@^5.1.5: pbkdf2 "^3.0.3" safe-buffer "^5.1.1" -parse-json@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9" - integrity sha1-9ID0BDTvgHQfhGkJn43qGPVaTck= - dependencies: - error-ex "^1.2.0" - -parse-json@^5.0.0: +parse-json@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== @@ -4209,7 +5843,7 @@ parse5@6.0.1: resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== -parseurl@^1.3.2: +parseurl@^1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== @@ -4224,11 +5858,6 @@ path-dirname@^1.0.0: resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" integrity sha512-ALzNPpyNq9AqXMBjeymIjFDAkAFH06mHJH/cSBHAgU0s4vfpBn6b2nf8tiRLvagKD8RbTpq2FKTBg7cl9l3c7Q== -path-exists@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" - integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= - path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -4244,11 +5873,19 @@ path-key@^3.0.0, path-key@^3.1.0: resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== -path-parse@^1.0.6: +path-parse@^1.0.6, path-parse@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + path-to-regexp@^1.7.0: version "1.8.0" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" @@ -4256,17 +5893,10 @@ path-to-regexp@^1.7.0: dependencies: isarray "0.0.1" -path-to-regexp@^6.1.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.0.tgz#f7b3803336104c346889adece614669230645f38" - integrity sha512-f66KywYG6+43afgE/8j/GoiNyygk/bnoCbps++3ErRKsIYkGGupyv07R2Ok5m9i67Iqc+T2g1eAUGUPzWhYTyg== - -path-type@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73" - integrity sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM= - dependencies: - pify "^2.0.0" +path-to-regexp@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.3.0.tgz#2b6a26a337737a8e1416f9272ed0766b1c0389f4" + integrity sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ== path-type@^4.0.0: version "4.0.0" @@ -4281,54 +5911,50 @@ pause-stream@^0.0.11: through "~2.3" pbkdf2@^3.0.3: - version "3.1.2" - resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.1.2.tgz#dd822aa0887580e52f1a039dc3eda108efae3075" - integrity sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA== + version "3.1.3" + resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.1.3.tgz#8be674d591d65658113424592a95d1517318dd4b" + integrity sha512-wfRLBZ0feWRhCIkoMB6ete7czJcnNnqRpcoWQBLqatqXXmelSRqfdDK4F3u9T2s2cXas/hQJcryI/4lAL+XTlA== dependencies: - create-hash "^1.1.2" - create-hmac "^1.1.4" - ripemd160 "^2.0.1" - safe-buffer "^5.0.1" - sha.js "^2.4.8" - -pend@~1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" - integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA= + create-hash "~1.1.3" + create-hmac "^1.1.7" + ripemd160 "=2.0.1" + safe-buffer "^5.2.1" + sha.js "^2.4.11" + to-buffer "^1.2.0" -picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1: - version "2.2.2" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" - integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg== +peek-stream@^1.1.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/peek-stream/-/peek-stream-1.1.3.tgz#3b35d84b7ccbbd262fff31dc10da56856ead6d67" + integrity sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA== + dependencies: + buffer-from "^1.0.0" + duplexify "^3.5.0" + through2 "^2.0.3" -pify@^2.0.0, pify@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" - integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw= +picocolors@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.1.tgz#a8ad579b571952f0e5d25892de5445bcfe25aaa1" + integrity sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew== -pify@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" - integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY= +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== -pinkie-promise@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" - integrity sha1-ITXW36ejWMBprJsXh3YogihFD/o= - dependencies: - pinkie "^2.0.0" +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== -pinkie@^2.0.0: - version "2.0.4" - resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" - integrity sha1-clVrgM+g1IqXToDnckjoDtT3+HA= +picomatch@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab" + integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== -pkg-dir@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b" - integrity sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s= - dependencies: - find-up "^2.1.0" +picomatch@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" + integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== pkg-dir@^4.2.0: version "4.2.0" @@ -4337,17 +5963,24 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -playwright-core@1.24.2: - version "1.24.2" - resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.24.2.tgz#47bc5adf3dcfcc297a5a7a332449c9009987db26" - integrity sha512-zfAoDoPY/0sDLsgSgLZwWmSCevIg1ym7CppBwllguVBNiHeixZkc1AdMuYUPZC6AdEYc4CxWEyLMBTw2YcmRrA== +playwright-core@1.54.1: + version "1.54.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.54.1.tgz#d32edcce048c9d83ceac31e294a7b60ef586960b" + integrity sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA== -playwright@^1.23.1: - version "1.24.2" - resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.24.2.tgz#51e60f128b386023e5ee83deca23453aaf73ba6d" - integrity sha512-iMWDLgaFRT+7dXsNeYwgl8nhLHsUrzFyaRVC+ftr++P1dVs70mPrFKBZrGp1fOKigHV9d1syC03IpPbqLKlPsg== +playwright-core@1.56.1: + version "1.56.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.56.1.tgz#24a66481e5cd33a045632230aa2c4f0cb6b1db3d" + integrity sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ== + +playwright@^1.53.1: + version "1.56.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.56.1.tgz#62e3b99ddebed0d475e5936a152c88e68be55fbf" + integrity sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw== dependencies: - playwright-core "1.24.2" + playwright-core "1.56.1" + optionalDependencies: + fsevents "2.3.2" plugin-error@^1.0.1: version "1.0.1" @@ -4359,26 +5992,31 @@ plugin-error@^1.0.1: arr-union "^3.1.0" extend-shallow "^3.0.2" -postcss-modules-extract-imports@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz#cda1f047c0ae80c97dbe28c3e76a43b88025741d" - integrity sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw== +possible-typed-array-names@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae" + integrity sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg== -postcss-modules-local-by-default@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz#ebbb54fae1598eecfdf691a02b3ff3b390a5a51c" - integrity sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ== +postcss-modules-extract-imports@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz#b4497cb85a9c0c4b5aabeb759bb25e8d89f15002" + integrity sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q== + +postcss-modules-local-by-default@^4.0.5: + version "4.2.0" + resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz#d150f43837831dae25e4085596e84f6f5d6ec368" + integrity sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw== dependencies: icss-utils "^5.0.0" - postcss-selector-parser "^6.0.2" + postcss-selector-parser "^7.0.0" postcss-value-parser "^4.1.0" -postcss-modules-scope@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz#9ef3151456d3bbfa120ca44898dfca6f2fa01f06" - integrity sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg== +postcss-modules-scope@^3.2.0: + version "3.2.1" + resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz#1bbccddcb398f1d7a511e0a2d1d047718af4078c" + integrity sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA== dependencies: - postcss-selector-parser "^6.0.4" + postcss-selector-parser "^7.0.0" postcss-modules-values@^4.0.0: version "4.0.0" @@ -4387,14 +6025,12 @@ postcss-modules-values@^4.0.0: dependencies: icss-utils "^5.0.0" -postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4: - version "6.0.4" - resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.4.tgz#56075a1380a04604c38b063ea7767a129af5c2b3" - integrity sha512-gjMeXBempyInaBqpp8gODmwZ52WaYsVOsfr4L4lDQ7n3ncD6mEyySiDtgzCT+NYC0mmeOLvtsF8iaEf0YT6dBw== +postcss-selector-parser@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz#4d6af97eba65d73bc4d84bcb343e865d7dd16262" + integrity sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA== dependencies: cssesc "^3.0.0" - indexes-of "^1.0.1" - uniq "^1.0.1" util-deprecate "^1.0.2" postcss-value-parser@^4.1.0: @@ -4402,14 +6038,19 @@ postcss-value-parser@^4.1.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz#443f6a20ced6481a2bda4fa8532a6e55d789a2cb" integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ== -postcss@^8.2.8: - version "8.2.15" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.2.15.tgz#9e66ccf07292817d226fc315cbbf9bc148fbca65" - integrity sha512-2zO3b26eJD/8rb106Qu2o7Qgg52ND5HPjcyQiK2B98O388h43A448LCslC0dI2P97wCAQRJsFvwTRcXxTKds+Q== +postcss-value-parser@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" + integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== + +postcss@^8.4.33: + version "8.5.6" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" + integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== dependencies: - colorette "^1.2.2" - nanoid "^3.1.23" - source-map "^0.6.1" + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" prelude-ls@^1.2.1: version "1.2.1" @@ -4421,11 +6062,6 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= -prettier@2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.2.1.tgz#795a1a78dd52f073da0cd42b21f9c91381923ff5" - integrity sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q== - pretty-format@^24.7.0: version "24.9.0" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-24.9.0.tgz#12fac31b37019a4eea3c11aa9a959eb7628aa7c9" @@ -4446,10 +6082,13 @@ process@^0.11.10: resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= -progress@^2.0.0: - version "2.0.3" - resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" - integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== +prompts@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" + integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== + dependencies: + kleur "^3.0.3" + sisteransi "^1.0.5" prop-types@^15.6.2: version "15.7.2" @@ -4460,10 +6099,10 @@ prop-types@^15.6.2: object-assign "^4.1.1" react-is "^16.8.1" -prr@~1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476" - integrity sha1-0/wRS6BplaRexok/SEzrHXj19HY= +property-information@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.1.0.tgz#b622e8646e02b580205415586b40804d3e8bfd5d" + integrity sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ== pseudomap@^1.0.2: version "1.0.2" @@ -4503,7 +6142,7 @@ pump@^3.0.0: end-of-stream "^1.1.0" once "^1.3.1" -pumpify@^1.3.5: +pumpify@^1.3.3, pumpify@^1.3.5: version "1.5.1" resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.5.1.tgz#36513be246ab27570b1a374a5ce278bfd74370ce" integrity sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ== @@ -4512,11 +6151,21 @@ pumpify@^1.3.5: inherits "^2.0.3" pump "^2.0.0" +punycode.js@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7" + integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA== + punycode@^2.1.0, punycode@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + queue-microtask@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.2.tgz#abf64491e6ecf0f38a6502403d4cda04f372dfd3" @@ -4537,14 +6186,6 @@ randomfill@^1.0.3: randombytes "^2.0.5" safe-buffer "^5.1.0" -raw-loader@4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/raw-loader/-/raw-loader-4.0.2.tgz#1aac6b7d1ad1501e66efdac1522c73e59a584eb6" - integrity sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA== - dependencies: - loader-utils "^2.0.0" - schema-utils "^3.0.0" - react-dom@^16.12.0: version "16.14.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89" @@ -4577,24 +6218,7 @@ react@^16.12.0: object-assign "^4.1.1" prop-types "^15.6.2" -read-pkg-up@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-2.0.0.tgz#6b72a8048984e0c41e79510fd5e9fa99b3b549be" - integrity sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4= - dependencies: - find-up "^2.0.0" - read-pkg "^2.0.0" - -read-pkg@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-2.0.0.tgz#8ef1c0623c6a6db0dc6713c4bfac46332b2368f8" - integrity sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg= - dependencies: - load-json-file "^2.0.0" - normalize-package-data "^2.3.2" - path-type "^2.0.0" - -readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.1.5, readable-stream@^2.3.0, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: +readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.5, readable-stream@^2.1.5, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.7" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== @@ -4607,6 +6231,19 @@ readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable string_decoder "~1.1.1" util-deprecate "~1.0.1" +readable-stream@^2.3.5: + version "2.3.8" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + readable-stream@^3.0.6, readable-stream@^3.5.0, readable-stream@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" @@ -4616,12 +6253,19 @@ readable-stream@^3.0.6, readable-stream@^3.5.0, readable-stream@^3.6.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" -readdirp@~3.5.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.5.0.tgz#9ba74c019b15d365278d2e91bb8c48d7b4d42c9e" - integrity sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ== +readable-stream@^3.6.2: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== dependencies: - picomatch "^2.2.1" + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +readdirp@^4.0.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" + integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== readdirp@~3.6.0: version "3.6.0" @@ -4642,15 +6286,36 @@ reduce-flatten@^2.0.0: resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-2.0.0.tgz#734fd84e65f375d7ca4465c69798c25c9d10ae27" integrity sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w== +reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: + version "1.0.10" + resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz#c629219e78a3316d8b604c765ef68996964e7bf9" + integrity sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.9" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + get-intrinsic "^1.2.7" + get-proto "^1.0.1" + which-builtin-type "^1.2.1" + regenerator-runtime@^0.13.4: version "0.13.7" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55" integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew== -regexpp@^3.0.0, regexpp@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.1.0.tgz#206d0ad0a5648cffbdb8ae46438f3dc51c9f78e2" - integrity sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q== +regexp.prototype.flags@^1.5.4: + version "1.5.4" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz#1ad6c62d44a259007e55b3970e00f746efbcaa19" + integrity sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-errors "^1.3.0" + get-proto "^1.0.1" + gopd "^1.2.0" + set-function-name "^2.0.2" remove-bom-buffer@^3.0.0: version "3.0.0" @@ -4682,12 +6347,12 @@ replace-ext@^1.0.0: require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" - integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== -require-from-string@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" - integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== resolve-cwd@^3.0.0: version "3.0.0" @@ -4716,12 +6381,26 @@ resolve-options@^1.1.0: resolve-path@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/resolve-path/-/resolve-path-1.4.0.tgz#c4bda9f5efb2fce65247873ab36bb4d834fe16f7" - integrity sha1-xL2p9e+y/OZSR4c6s2u02DT+Fvc= + integrity sha512-i1xevIst/Qa+nA9olDxLWnLk8YZbi8R/7JPbCMcgyWaFR6bKWaexgJgEB5oc2PKMjYdrHynyz0NY+if+H98t1w== dependencies: http-errors "~1.6.2" path-is-absolute "1.0.1" -resolve@^1.10.0, resolve@^1.13.1, resolve@^1.17.0, resolve@^1.3.3, resolve@^1.9.0: +resolve-pkg-maps@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" + integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== + +resolve@^1.22.4: + version "1.22.10" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" + integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== + dependencies: + is-core-module "^2.16.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +resolve@^1.9.0: version "1.20.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975" integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A== @@ -4729,25 +6408,19 @@ resolve@^1.10.0, resolve@^1.13.1, resolve@^1.17.0, resolve@^1.3.3, resolve@^1.9. is-core-module "^2.2.0" path-parse "^1.0.6" +restore-cursor@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-5.1.0.tgz#0766d95699efacb14150993f55baf0953ea1ebe7" + integrity sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA== + dependencies: + onetime "^7.0.0" + signal-exit "^4.1.0" + reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== -rimraf@2: - version "2.7.1" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" - integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== - dependencies: - glob "^7.1.3" - -rimraf@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" - integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== - dependencies: - glob "^7.1.3" - rimraf@~2.6.2: version "2.6.3" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" @@ -4755,6 +6428,14 @@ rimraf@~2.6.2: dependencies: glob "^7.1.3" +ripemd160@=2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.1.tgz#0f4584295c53a3628af7e6d79aca21ce57d1c6e7" + integrity sha512-J7f4wutN8mdbV08MJnXibYpCOPHR+yzy+iQ/AsjMv2j8cLavQ8VGagDFUwwTAdF8FmRKVeNpbTTEwNHCW1g94w== + dependencies: + hash-base "^2.0.0" + inherits "^2.0.1" + ripemd160@^2.0.0, ripemd160@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.2.tgz#a1c1a6f624751577ba5d07914cbc92850585890c" @@ -4770,16 +6451,44 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: +safe-array-concat@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz#c9e54ec4f603b0bbb8e7e5007a5ee7aecd1538c3" + integrity sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + get-intrinsic "^1.2.6" + has-symbols "^1.1.0" + isarray "^2.0.5" + +safe-buffer@5.1.2, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@^5.2.1, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== +safe-push-apply@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz#01850e981c1602d398c85081f360e4e6d03d27f5" + integrity sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA== + dependencies: + es-errors "^1.3.0" + isarray "^2.0.5" + +safe-regex-test@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz#7f87dfb67a3150782eaaf18583ff5d1711ac10c1" + integrity sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-regex "^1.2.1" + "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" @@ -4800,53 +6509,38 @@ scheduler@^0.19.1: loose-envify "^1.1.0" object-assign "^4.1.1" -schema-utils@2.7.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.0.tgz#17151f76d8eae67fbbf77960c33c676ad9f4efc7" - integrity sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A== - dependencies: - "@types/json-schema" "^7.0.4" - ajv "^6.12.2" - ajv-keywords "^3.4.1" - -schema-utils@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.0.0.tgz#67502f6aa2b66a2d4032b4279a2944978a0913ef" - integrity sha512-6D82/xSzO094ajanoOSbe4YvXWMfn2A//8Y1+MUqFAJul5Bs+yn36xbK9OtNDcRVSBJ9jjeoXftM6CfztsjOAA== - dependencies: - "@types/json-schema" "^7.0.6" - ajv "^6.12.5" - ajv-keywords "^3.5.2" - -schema-utils@^3.1.0, schema-utils@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.1.1.tgz#bc74c4b6b6995c1d88f76a8b77bea7219e0c8281" - integrity sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw== +schema-utils@^3.0.0, schema-utils@^3.1.1, schema-utils@^3.2.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.3.0.tgz#f50a88877c3c01652a15b622ae9e9795df7a60fe" + integrity sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg== dependencies: "@types/json-schema" "^7.0.8" ajv "^6.12.5" ajv-keywords "^3.5.2" -seek-bzip@^1.0.5: - version "1.0.6" - resolved "https://registry.yarnpkg.com/seek-bzip/-/seek-bzip-1.0.6.tgz#35c4171f55a680916b52a07859ecf3b5857f21c4" - integrity sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ== - dependencies: - commander "^2.8.1" +semver@^5.3.0, semver@^5.4.1, semver@^5.6.0: + version "5.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" + integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== -"semver@2 || 3 || 4 || 5", semver@^5.6.0: - version "5.7.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" - integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== +semver@^6.3.1: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.2.1, semver@^7.3.2, semver@^7.3.4: - version "7.3.4" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.4.tgz#27aaa7d2e4ca76452f98d3add093a72c943edc97" - integrity sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw== +semver@^7.3.4, semver@^7.5.4: + version "7.5.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" + integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== dependencies: lru-cache "^6.0.0" -serialize-javascript@6.0.0, serialize-javascript@^6.0.0: +semver@^7.3.5, semver@^7.5.3, semver@^7.6.0, semver@^7.6.2, semver@^7.7.1: + version "7.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" + integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== + +serialize-javascript@6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8" integrity sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag== @@ -4858,35 +6552,69 @@ serialize-javascript@^5.0.1: resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-5.0.1.tgz#7886ec848049a462467a97d3d918ebb2aaf934f4" integrity sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA== dependencies: - randombytes "^2.1.0" + randombytes "^2.1.0" + +serialize-javascript@^6.0.1, serialize-javascript@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" + integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== + dependencies: + randombytes "^2.1.0" + +set-function-length@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + +set-function-name@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" + integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + functions-have-names "^1.2.3" + has-property-descriptors "^1.0.2" + +set-proto@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/set-proto/-/set-proto-1.0.0.tgz#0760dbcff30b2d7e801fd6e19983e56da337565e" + integrity sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw== + dependencies: + dunder-proto "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" -setimmediate@^1.0.4, setimmediate@~1.0.4: +setimmediate@^1.0.4, setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" - integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= + integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== setprototypeof@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656" integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ== -setprototypeof@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683" - integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw== - setprototypeof@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== -sha.js@^2.4.0, sha.js@^2.4.8: - version "2.4.11" - resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" - integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== +sha.js@^2.4.0, sha.js@^2.4.11, sha.js@^2.4.8: + version "2.4.12" + resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.12.tgz#eb8b568bf383dfd1867a32c3f2b74eb52bdbf23f" + integrity sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w== dependencies: - inherits "^2.0.1" - safe-buffer "^5.0.1" + inherits "^2.0.4" + safe-buffer "^5.2.1" + to-buffer "^1.2.0" shebang-command@^2.0.0: version "2.0.0" @@ -4900,6 +6628,51 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== +shimmer@^1.1.0, shimmer@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/shimmer/-/shimmer-1.2.1.tgz#610859f7de327b587efebf501fb43117f9aff337" + integrity sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw== + +side-channel-list@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" + integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + +side-channel@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + sigmund@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590" @@ -4910,6 +6683,11 @@ signal-exit@^3.0.2: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== +signal-exit@^4.0.1, signal-exit@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + simple-html-tokenizer@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/simple-html-tokenizer/-/simple-html-tokenizer-0.1.1.tgz#05c2eec579ffffe145a030ac26cfea61b980fabe" @@ -4928,25 +6706,21 @@ sinon@9.0.0: nise "^4.0.1" supports-color "^7.1.0" -slash@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" - integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== - -slice-ansi@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b" - integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ== - dependencies: - ansi-styles "^4.0.0" - astral-regex "^2.0.0" - is-fullwidth-code-point "^3.0.0" +sisteransi@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" + integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== -source-list-map@^2.0.1: +source-list-map@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw== +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + source-map-support@0.5.19: version "0.5.19" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61" @@ -4968,31 +6742,15 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -spdx-correct@^3.0.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9" - integrity sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w== - dependencies: - spdx-expression-parse "^3.0.0" - spdx-license-ids "^3.0.0" - -spdx-exceptions@^2.1.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d" - integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A== - -spdx-expression-parse@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679" - integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q== - dependencies: - spdx-exceptions "^2.1.0" - spdx-license-ids "^3.0.0" +source-map@^0.7.4: + version "0.7.4" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656" + integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA== -spdx-license-ids@^3.0.0: - version "3.0.7" - resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.7.tgz#e9c18a410e5ed7e12442a549fbd8afa767038d65" - integrity sha512-U+MTEOO0AiDzxwFvoa4JVnMV6mZlJKk2sBLt90s7G0Gd0Mlknc7kxEn3nuDPNZRta7O2uy8oLcZLVT+4sqNZHQ== +space-separated-tokens@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz#1ecd9d2350a3844572c3f4a312bceb018348859f" + integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q== split@^1.0.1: version "1.0.1" @@ -5001,20 +6759,48 @@ split@^1.0.1: dependencies: through "2" -sprintf-js@~1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" - integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= - ssh-config@4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/ssh-config/-/ssh-config-4.1.1.tgz#de8ab080c97234873291488b36090cdc2d075b06" integrity sha512-p9t6ZX2yg3vzrUh81arLOOh73arUdm8aZ8YG1Rxve0T+1PVv6v7DhXQ/uPQ0lJaubEvGXJz7eGj7Z850443Ohg== -"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@^1.5.0: +stable-hash-x@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/stable-hash-x/-/stable-hash-x-0.2.0.tgz#dfd76bfa5d839a7470125c6a6b3c8b22061793e9" + integrity sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ== + +stack-chain@^1.3.7: + version "1.3.7" + resolved "https://registry.yarnpkg.com/stack-chain/-/stack-chain-1.3.7.tgz#d192c9ff4ea6a22c94c4dd459171e3f00cea1285" + integrity sha512-D8cWtWVdIe/jBA7v5p5Hwl5yOSOrmZPWDPe2KxQ5UAGD+nxbxU0lKXA4h85Ta6+qgdKVL3vUxsbIZjc1kBG7ug== + +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + +"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2": version "1.5.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" - integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= + integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== + +statuses@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" + integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== + +stdin-discarder@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz#390037f44c4ae1a1ae535c5fe38dc3aba8d997be" + integrity sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ== + +stop-iteration-iterator@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz#f481ff70a548f6124d0312c3aa14cbfa7aa542ad" + integrity sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ== + dependencies: + es-errors "^1.3.0" + internal-slot "^1.1.0" stream-browserify@^3.0.0: version "3.0.0" @@ -5032,6 +6818,16 @@ stream-combiner@^0.2.2: duplexer "~0.1.1" through "~2.3.4" +stream-http@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-3.2.0.tgz#1872dfcf24cb15752677e40e5c3f9cc1926028b5" + integrity sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A== + dependencies: + builtin-status-codes "^3.0.0" + inherits "^2.0.4" + readable-stream "^3.6.0" + xtend "^4.0.2" + stream-shift@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d" @@ -5044,22 +6840,64 @@ streamfilter@^3.0.0: dependencies: readable-stream "^3.0.6" -"string-width@^1.0.2 || 2": - version "2.1.1" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" - integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== +streamx@^2.15.0, streamx@^2.21.0: + version "2.22.1" + resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.22.1.tgz#c97cbb0ce18da4f4db5a971dc9ab68ff5dc7f5a5" + integrity sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA== dependencies: - is-fullwidth-code-point "^2.0.0" - strip-ansi "^4.0.0" + fast-fifo "^1.3.2" + text-decoder "^1.1.0" + optionalDependencies: + bare-events "^2.2.0" -string-width@^4.1.0, string-width@^4.2.0: - version "4.2.2" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5" - integrity sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA== +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== dependencies: emoji-regex "^8.0.0" is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +string-width@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-7.2.0.tgz#b5bb8e2165ce275d4d43476dd2700ad9091db6dc" + integrity sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ== + dependencies: + emoji-regex "^10.3.0" + get-east-asian-width "^1.0.0" + strip-ansi "^7.1.0" + +string.prototype.trim@^1.2.10: + version "1.2.10" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz#40b2dd5ee94c959b4dcfb1d65ce72e90da480c81" + integrity sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + define-data-property "^1.1.4" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-object-atoms "^1.0.0" + has-property-descriptors "^1.0.2" string.prototype.trimend@^1.0.4: version "1.0.4" @@ -5069,6 +6907,16 @@ string.prototype.trimend@^1.0.4: call-bind "^1.0.2" define-properties "^1.1.3" +string.prototype.trimend@^1.0.8, string.prototype.trimend@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz#62e2731272cd285041b36596054e9f66569b6942" + integrity sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + string.prototype.trimstart@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz#b36399af4ab2999b4c9c648bd7a3fb2bb26feeed" @@ -5077,65 +6925,86 @@ string.prototype.trimstart@^1.0.4: call-bind "^1.0.2" define-properties "^1.1.3" -string_decoder@^1.1.1: +string.prototype.trimstart@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz#7ee834dda8c7c17eff3118472bb35bfedaa34dde" + integrity sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +string_decoder@^1.1.1, string_decoder@^1.3.0, string_decoder@~1.1.1: version "1.3.0" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== dependencies: safe-buffer "~5.2.0" -string_decoder@~1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" - integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== +stringify-entities@^4.0.0: + version "4.0.4" + resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-4.0.4.tgz#b3b79ef5f277cc4ac73caeb0236c5ba939b3a4f3" + integrity sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg== + dependencies: + character-entities-html4 "^2.0.0" + character-entities-legacy "^3.0.0" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== dependencies: - safe-buffer "~5.1.0" + ansi-regex "^5.0.1" strip-ansi@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" - integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8= + integrity sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow== dependencies: ansi-regex "^3.0.0" -strip-ansi@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532" - integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w== +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1: + version "7.1.2" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" + integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== + dependencies: + ansi-regex "^6.0.1" + +strip-ansi@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" + integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== dependencies: - ansi-regex "^5.0.0" + ansi-regex "^6.0.1" strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM= -strip-dirs@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/strip-dirs/-/strip-dirs-2.1.0.tgz#4987736264fc344cf20f6c34aca9d13d1d4ed6c5" - integrity sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g== - dependencies: - is-natural-number "^4.0.1" - strip-final-newline@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== -strip-json-comments@3.1.1, strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: +strip-json-comments@3.1.1, strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== -style-loader@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-2.0.0.tgz#9669602fd4690740eaaec137799a03addbbc393c" - integrity sha512-Z0gYUJmzZ6ZdRUqpg1r8GsaFKypE+3xAzuFeMuoHgjc9KZv3wMyCRjQIWEbhoFSq7+7yoHXySDJyyWQaPajeiQ== - dependencies: - loader-utils "^2.0.0" - schema-utils "^3.0.0" +style-loader@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-4.0.0.tgz#0ea96e468f43c69600011e0589cb05c44f3b17a5" + integrity sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA== -supports-color@8.1.1, supports-color@^8.0.0: +supports-color@8.1.1, supports-color@^8.0.0, supports-color@^8.1.1: version "8.1.1" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== @@ -5156,6 +7025,16 @@ supports-color@^7.0.0, supports-color@^7.1.0: dependencies: has-flag "^4.0.0" +supports-color@^9.4.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-9.4.0.tgz#17bfcf686288f531db3dea3215510621ccb55954" + integrity sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw== + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + svg-inline-loader@^0.8.2: version "0.8.2" resolved "https://registry.yarnpkg.com/svg-inline-loader/-/svg-inline-loader-0.8.2.tgz#9872414f9e4141601e04eb80cda748c9a50dae71" @@ -5185,45 +7064,40 @@ table-layout@^1.0.1: typical "^5.2.0" wordwrapjs "^4.0.0" -table@^6.0.4: - version "6.0.7" - resolved "https://registry.yarnpkg.com/table/-/table-6.0.7.tgz#e45897ffbcc1bcf9e8a87bf420f2c9e5a7a52a34" - integrity sha512-rxZevLGTUzWna/qBLObOe16kB2RTnnbhciwgPbMMlazz1yZGVEgnZK762xyVdVznhqxrfCeBMmMkgOOaPwjH7g== - dependencies: - ajv "^7.0.2" - lodash "^4.17.20" - slice-ansi "^4.0.0" - string-width "^4.2.0" - -tapable@^1.0.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" - integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== - tapable@^2.1.1, tapable@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.0.tgz#5c373d281d9c672848213d0e037d1c4165ab426b" integrity sha512-FBk4IesMV1rBxX2tfiK8RAmogtWn53puLOQlvO8XuwlgxcYbP4mVPS9Ph4aeamSyyVjOl24aYWAuc8U5kCVwMw== -tar-stream@^1.5.2: - version "1.6.2" - resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.6.2.tgz#8ea55dab37972253d9a9af90fdcd559ae435c555" - integrity sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A== +tapable@^2.2.1: + version "2.2.3" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.3.tgz#4b67b635b2d97578a06a2713d2f04800c237e99b" + integrity sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg== + +tar-fs@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-3.1.0.tgz#4675e2254d81410e609d91581a762608de999d25" + integrity sha512-5Mty5y/sOF1YWj1J6GiBodjlDc05CUR8PKXrsnFAiSG0xA+GHeWLovaZPYUDXkH/1iKRf2+M5+OrRgzC7O9b7w== dependencies: - bl "^1.0.0" - buffer-alloc "^1.2.0" - end-of-stream "^1.0.0" - fs-constants "^1.0.0" - readable-stream "^2.3.0" - to-buffer "^1.1.1" - xtend "^4.0.0" + pump "^3.0.0" + tar-stream "^3.1.5" + optionalDependencies: + bare-fs "^4.0.1" + bare-path "^3.0.0" -tas-client@0.1.16: - version "0.1.16" - resolved "https://registry.yarnpkg.com/tas-client/-/tas-client-0.1.16.tgz#9ef07229f9f65e593bba226100e8ecff6d970ad2" - integrity sha512-ZMGg7dGXiYVJHYusDpUb/Ilg+iPNYZdKJSIA2ADn0f2RovHWM0TpNVe2YHPEc0hdFMsUwWKS5pCRzLnlUqcqGg== +tar-stream@^3.1.5: + version "3.1.7" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.1.7.tgz#24b3fb5eabada19fe7338ed6d26e5f7c482e792b" + integrity sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ== dependencies: - axios "^0.21.1" + b4a "^1.6.4" + fast-fifo "^1.2.0" + streamx "^2.15.0" + +tas-client@0.2.33: + version "0.2.33" + resolved "https://registry.yarnpkg.com/tas-client/-/tas-client-0.2.33.tgz#451bf114a8a64748030ce4068ab7d079958402e6" + integrity sha512-V+uqV66BOQnWxvI6HjDnE4VkInmYZUQ4dgB7gzaDyFyFSK1i1nF/j7DpS9UbQAgV9NaF1XpcyuavnM1qOeiEIg== temp@0.9.4: version "0.9.4" @@ -5233,6 +7107,18 @@ temp@0.9.4: mkdirp "^0.5.1" rimraf "~2.6.2" +temporal-polyfill@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/temporal-polyfill/-/temporal-polyfill-0.3.0.tgz#7fe90e913ac5ec8e0d508fb50d04dd7a74cec23e" + integrity sha512-qNsTkX9K8hi+FHDfHmf22e/OGuXmfBm9RqNismxBrnSmZVJKegQ+HYYXT+R7Ha8F/YSm2Y34vmzD4cxMu2u95g== + dependencies: + temporal-spec "0.3.0" + +temporal-spec@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/temporal-spec/-/temporal-spec-0.3.0.tgz#8c4210c575fb28ba0a1c2e02ad68d1be5956a11f" + integrity sha512-n+noVpIqz4hYgFSMOSiINNOUOMFtV5cZQNCmmszA6GiVFVRt3G7AqVyhXjhCSmowvQn+NsGn+jMDMKJYHd3bSQ== + terser-webpack-plugin@5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.1.1.tgz#7effadee06f7ecfa093dbbd3e9ab23f5f3ed8673" @@ -5245,18 +7131,28 @@ terser-webpack-plugin@5.1.1: source-map "^0.6.1" terser "^5.5.1" -terser-webpack-plugin@^5.1.3: - version "5.3.1" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.1.tgz#0320dcc270ad5372c1e8993fabbd927929773e54" - integrity sha512-GvlZdT6wPQKbDNW/GDQzZFg/j4vKU96yl2q6mcUkzKOgW4gwf1Z8cZToUCrz31XHlPWH8MVb1r2tFtdDtTGJ7g== +terser-webpack-plugin@^5.3.10: + version "5.3.10" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz#904f4c9193c6fd2a03f693a2150c62a92f40d199" + integrity sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w== dependencies: + "@jridgewell/trace-mapping" "^0.3.20" jest-worker "^27.4.5" schema-utils "^3.1.1" - serialize-javascript "^6.0.0" - source-map "^0.6.1" - terser "^5.7.2" + serialize-javascript "^6.0.1" + terser "^5.26.0" + +terser@^5.26.0: + version "5.31.6" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.31.6.tgz#c63858a0f0703988d0266a82fcbf2d7ba76422b1" + integrity sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg== + dependencies: + "@jridgewell/source-map" "^0.3.3" + acorn "^8.8.2" + commander "^2.20.0" + source-map-support "~0.5.20" -terser@^5.5.1, terser@^5.7.2: +terser@^5.5.1: version "5.14.2" resolved "https://registry.yarnpkg.com/terser/-/terser-5.14.2.tgz#9ac9f22b06994d736174f4091aa368db896f1c10" integrity sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA== @@ -5266,10 +7162,21 @@ terser@^5.5.1, terser@^5.7.2: commander "^2.20.0" source-map-support "~0.5.20" -text-table@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" - integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= +test-exclude@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" + integrity sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w== + dependencies: + "@istanbuljs/schema" "^0.1.2" + glob "^7.1.4" + minimatch "^3.0.4" + +text-decoder@^1.1.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/text-decoder/-/text-decoder-1.2.3.tgz#b19da364d981b2326d5f43099c310cc80d770c65" + integrity sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA== + dependencies: + b4a "^1.6.4" through2-filter@^3.0.0: version "3.0.0" @@ -5299,6 +7206,22 @@ timers-browserify@^2.0.12: dependencies: setimmediate "^1.0.4" +tinyglobby@0.2.14: + version "0.2.14" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.14.tgz#5280b0cf3f972b050e74ae88406c0a6a58f4079d" + integrity sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ== + dependencies: + fdir "^6.4.4" + picomatch "^4.0.2" + +tinyglobby@^0.2.14: + version "0.2.15" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" + integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.3" + to-absolute-glob@^2.0.0, to-absolute-glob@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz#1865f43d9e74b0822db9f145b78cff7d0f7c849b" @@ -5307,10 +7230,14 @@ to-absolute-glob@^2.0.0, to-absolute-glob@^2.0.2: is-absolute "^1.0.0" is-negated-glob "^1.0.0" -to-buffer@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.1.1.tgz#493bd48f62d7c43fcded313a03dcadb2e1213a80" - integrity sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg== +to-buffer@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.2.1.tgz#2ce650cdb262e9112a18e65dc29dcb513c8155e0" + integrity sha512-tB82LpAIWjhLYbqjx3X4zEeHN6M8CiuOEy2JY8SEQVdYRe3CCHOFaqrBW1doLDrfpWhplcW7BL+bO3/6S3pcDQ== + dependencies: + isarray "^2.0.5" + safe-buffer "^5.2.1" + typed-array-buffer "^1.0.3" to-regex-range@^5.0.1: version "5.0.1" @@ -5326,19 +7253,20 @@ to-through@^2.0.0: dependencies: through2 "^2.0.3" -toidentifier@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" - integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== tough-cookie@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" - integrity sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg== + version "4.1.3" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.3.tgz#97b9adb0728b42280aa3d814b6b999b2ff0318bf" + integrity sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw== dependencies: psl "^1.1.33" punycode "^2.1.1" - universalify "^0.1.2" + universalify "^0.2.0" + url-parse "^1.5.3" tr46@^3.0.0: version "3.0.0" @@ -5352,10 +7280,15 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o= -"traverse@>=0.3.0 <0.4": - version "0.3.9" - resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9" - integrity sha1-cXuPIgzAu3tE5AUUwisui7xw2Lk= +trim-lines@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338" + integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg== + +ts-api-utils@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.1.0.tgz#595f7094e46eed364c13fd23e75f9513d29baf91" + integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ== ts-invariant@^0.4.0: version "0.4.4" @@ -5364,44 +7297,66 @@ ts-invariant@^0.4.0: dependencies: tslib "^1.9.3" -ts-loader@8.0.18: - version "8.0.18" - resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-8.0.18.tgz#b2385cbe81c34ad9f997915129cdde3ad92a61ea" - integrity sha512-hRZzkydPX30XkLaQwJTDcWDoxZHK6IrEMDQpNd7tgcakFruFkeUp/aY+9hBb7BUGb+ZWKI0jiOGMo0MckwzdDQ== +ts-loader@9.5.2: + version "9.5.2" + resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-9.5.2.tgz#1f3d7f4bb709b487aaa260e8f19b301635d08020" + integrity sha512-Qo4piXvOTWcMGIgRiuFa6nHNm+54HbYaZCKqc9eeZCLRy3XqafQgwX2F7mofrbJG3g7EEb+lkiR+z2Lic2s3Zw== dependencies: chalk "^4.1.0" - enhanced-resolve "^4.0.0" - loader-utils "^2.0.0" + enhanced-resolve "^5.0.0" micromatch "^4.0.0" semver "^7.3.4" - -tsconfig-paths@^3.9.0: - version "3.9.0" - resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz#098547a6c4448807e8fcb8eae081064ee9a3c90b" - integrity sha512-dRcuzokWhajtZWkQsDVKbWyY+jgcLC5sqJhg2PSgf4ZkH2aHPvaOY8YWGhmjb68b5qqTfasSsDO9k7RUiEmZAw== + source-map "^0.7.4" + +ts-node@^10.9.2: + version "10.9.2" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" + integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== + dependencies: + "@cspotcode/source-map-support" "^0.8.0" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + v8-compile-cache-lib "^3.0.1" + yn "3.1.1" + +tsconfig-paths@^3.15.0: + version "3.15.0" + resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz#5299ec605e55b1abb23ec939ef15edaf483070d4" + integrity sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg== dependencies: "@types/json5" "^0.0.29" - json5 "^1.0.1" - minimist "^1.2.0" + json5 "^1.0.2" + minimist "^1.2.6" strip-bom "^3.0.0" -tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: +tslib@^1.10.0, tslib@^1.9.0, tslib@^1.9.3: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.2.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf" + integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg== + +tslib@^2.4.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + tsscmp@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb" integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA== -tsutils@^3.17.1: - version "3.21.0" - resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" - integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA== - dependencies: - tslib "^1.8.1" - tty@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/tty/-/tty-1.0.1.tgz#e4409ac98b0dd1c50b59ff38e86eac3f0764ee45" @@ -5431,28 +7386,69 @@ type-detect@4.0.8, type-detect@^4.0.8: resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== -type-fest@^0.20.2: - version "0.20.2" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" - integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== - -type-fest@^0.21.3: - version "0.21.3" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" - integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== +type-is@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.0.1.tgz#64f6cf03f92fce4015c2b224793f6bdd4b068c97" + integrity sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw== + dependencies: + content-type "^1.0.5" + media-typer "^1.1.0" + mime-types "^3.0.0" -type-fest@^0.8.1: - version "0.8.1" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" - integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== +typed-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz#a72395450a4869ec033fd549371b47af3a2ee536" + integrity sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-typed-array "^1.1.14" -type-is@^1.6.16: - version "1.6.18" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" - integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== +typed-array-byte-length@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz#8407a04f7d78684f3d252aa1a143d2b77b4160ce" + integrity sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg== dependencies: - media-typer "0.3.0" - mime-types "~2.1.24" + call-bind "^1.0.8" + for-each "^0.3.3" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.14" + +typed-array-byte-offset@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz#ae3698b8ec91a8ab945016108aef00d5bff12355" + integrity sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + for-each "^0.3.3" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.15" + reflect.getprototypeof "^1.0.9" + +typed-array-length@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.7.tgz#ee4deff984b64be1e118b0de8c9c877d5ce73d3d" + integrity sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + is-typed-array "^1.1.13" + possible-typed-array-names "^1.0.0" + reflect.getprototypeof "^1.0.6" + +typescript-eslint@^8.44.0: + version "8.44.1" + resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.44.1.tgz#00506d12db48112cbb43030c5b810e6117670010" + integrity sha512-0ws8uWGrUVTjEeN2OM4K1pLKHK/4NiNP/vz6ns+LjT/6sqpaYzIVFajZb1fj/IDwpsrrHb3Jy0Qm5u9CPcKaeg== + dependencies: + "@typescript-eslint/eslint-plugin" "8.44.1" + "@typescript-eslint/parser" "8.44.1" + "@typescript-eslint/typescript-estree" "8.44.1" + "@typescript-eslint/utils" "8.44.1" typescript-formatter@^7.2.2: version "7.2.2" @@ -5462,16 +7458,21 @@ typescript-formatter@^7.2.2: commandpost "^1.0.0" editorconfig "^0.15.0" -typescript@4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.3.tgz#39062d8019912d43726298f09493d598048c1ce3" - integrity sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw== +typescript@^5.9.2: + version "5.9.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.2.tgz#d93450cddec5154a2d5cabe3b8102b83316fb2a6" + integrity sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A== typical@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/typical/-/typical-5.2.0.tgz#4daaac4f2b5315460804f0acf6cb69c52bb93066" integrity sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg== +uc.micro@^2.0.0, uc.micro@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee" + integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A== + unbox-primitive@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.0.tgz#eeacbc4affa28e9b3d36b5eaeccc50b3251b1d3f" @@ -5482,23 +7483,25 @@ unbox-primitive@^1.0.0: has-symbols "^1.0.0" which-boxed-primitive "^1.0.1" -unbzip2-stream@^1.0.9: - version "1.4.3" - resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7" - integrity sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg== +unbox-primitive@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz#8d9d2c9edeea8460c7f35033a88867944934d1e2" + integrity sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw== dependencies: - buffer "^5.2.1" - through "^2.3.8" + call-bound "^1.0.3" + has-bigints "^1.0.2" + has-symbols "^1.1.0" + which-boxed-primitive "^1.1.1" unc-path-regex@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" integrity sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg== -uniq@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" - integrity sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8= +undici-types@~6.21.0: + version "6.21.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" + integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== unique-stream@^2.0.2: version "2.3.1" @@ -5508,36 +7511,93 @@ unique-stream@^2.0.2: json-stable-stringify-without-jsonify "^1.0.1" through2-filter "^3.0.0" -universal-user-agent@^6.0.0: +unist-util-is@^6.0.0: version "6.0.0" - resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-6.0.0.tgz#3381f8503b251c0d9cd21bc1de939ec9df5480ee" - integrity sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w== + resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-6.0.0.tgz#b775956486aff107a9ded971d996c173374be424" + integrity sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw== + dependencies: + "@types/unist" "^3.0.0" -universalify@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" - integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== +unist-util-position@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-5.0.0.tgz#678f20ab5ca1207a97d7ea8a388373c9cf896be4" + integrity sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-stringify-position@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz#449c6e21a880e0855bf5aabadeb3a740314abac2" + integrity sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-visit-parents@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz#4d5f85755c3b8f0dc69e21eca5d6d82d22162815" + integrity sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + +unist-util-visit@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-5.0.0.tgz#a7de1f31f72ffd3519ea71814cccf5fd6a9217d6" + integrity sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + unist-util-visit-parents "^6.0.0" + +universal-user-agent@^7.0.0, universal-user-agent@^7.0.2: + version "7.0.3" + resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-7.0.3.tgz#c05870a58125a2dc00431f2df815a77fe69736be" + integrity sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A== + +universalify@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" + integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== universalify@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== -unzipper@^0.10.11: - version "0.10.11" - resolved "https://registry.yarnpkg.com/unzipper/-/unzipper-0.10.11.tgz#0b4991446472cbdb92ee7403909f26c2419c782e" - integrity sha512-+BrAq2oFqWod5IESRjL3S8baohbevGcVA+teAIOYWM3pDVdseogqbzhhvvmiyQrUNKFUnDMtELW3X8ykbyDCJw== - dependencies: - big-integer "^1.6.17" - binary "~0.3.0" - bluebird "~3.4.1" - buffer-indexof-polyfill "~1.0.0" - duplexer2 "~0.1.4" - fstream "^1.0.12" - graceful-fs "^4.2.2" - listenercount "~1.0.1" - readable-stream "~2.3.6" - setimmediate "~1.0.4" +unrs-resolver@^1.7.11: + version "1.11.1" + resolved "https://registry.yarnpkg.com/unrs-resolver/-/unrs-resolver-1.11.1.tgz#be9cd8686c99ef53ecb96df2a473c64d304048a9" + integrity sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg== + dependencies: + napi-postinstall "^0.3.0" + optionalDependencies: + "@unrs/resolver-binding-android-arm-eabi" "1.11.1" + "@unrs/resolver-binding-android-arm64" "1.11.1" + "@unrs/resolver-binding-darwin-arm64" "1.11.1" + "@unrs/resolver-binding-darwin-x64" "1.11.1" + "@unrs/resolver-binding-freebsd-x64" "1.11.1" + "@unrs/resolver-binding-linux-arm-gnueabihf" "1.11.1" + "@unrs/resolver-binding-linux-arm-musleabihf" "1.11.1" + "@unrs/resolver-binding-linux-arm64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-arm64-musl" "1.11.1" + "@unrs/resolver-binding-linux-ppc64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-riscv64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-riscv64-musl" "1.11.1" + "@unrs/resolver-binding-linux-s390x-gnu" "1.11.1" + "@unrs/resolver-binding-linux-x64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-x64-musl" "1.11.1" + "@unrs/resolver-binding-wasm32-wasi" "1.11.1" + "@unrs/resolver-binding-win32-arm64-msvc" "1.11.1" + "@unrs/resolver-binding-win32-ia32-msvc" "1.11.1" + "@unrs/resolver-binding-win32-x64-msvc" "1.11.1" + +update-browserslist-db@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz#7ca61c0d8650766090728046e416a8cde682859e" + integrity sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ== + dependencies: + escalade "^3.1.2" + picocolors "^1.0.1" uri-js@^4.2.2: version "4.4.1" @@ -5546,6 +7606,14 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +url-parse@^1.5.3: + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + url-search-params-polyfill@^8.1.1: version "8.1.1" resolved "https://registry.yarnpkg.com/url-search-params-polyfill/-/url-search-params-polyfill-8.1.1.tgz#9e69e4dba300a71ae7ad3cead62c7717fd99329f" @@ -5554,7 +7622,7 @@ url-search-params-polyfill@^8.1.1: util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" - integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== util@^0.12.0: version "0.12.4" @@ -5568,23 +7636,29 @@ util@^0.12.0: safe-buffer "^5.1.2" which-typed-array "^1.1.2" -uuid@8.3.2: +uuid@8.3.2, uuid@^8.3.0: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== -v8-compile-cache@^2.0.3, v8-compile-cache@^2.2.0: +v8-compile-cache-lib@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" + integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== + +v8-compile-cache@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== -validate-npm-package-license@^3.0.1: - version "3.0.4" - resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" - integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== +v8-to-istanbul@^9.0.0: + version "9.3.0" + resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz#b9572abfa62bd556c16d75fdebc1a411d5ff3175" + integrity sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA== dependencies: - spdx-correct "^3.0.0" - spdx-expression-parse "^3.0.0" + "@jridgewell/trace-mapping" "^0.3.12" + "@types/istanbul-lib-coverage" "^2.0.1" + convert-source-map "^2.0.0" value-or-function@^3.0.0: version "3.0.0" @@ -5594,7 +7668,23 @@ value-or-function@^3.0.0: vary@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" - integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + +vfile-message@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-4.0.2.tgz#c883c9f677c72c166362fd635f21fc165a7d1181" + integrity sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw== + dependencies: + "@types/unist" "^3.0.0" + unist-util-stringify-position "^4.0.0" + +vfile@^6.0.0: + version "6.0.3" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-6.0.3.tgz#3652ab1c496531852bf55a6bac57af981ebc38ab" + integrity sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q== + dependencies: + "@types/unist" "^3.0.0" + vfile-message "^4.0.0" vinyl-fs@^3.0.3: version "3.0.3" @@ -5644,17 +7734,17 @@ vinyl@^2.0.0: remove-trailing-separator "^1.0.1" replace-ext "^1.0.0" -vscode-tas-client@^0.1.17: - version "0.1.17" - resolved "https://registry.yarnpkg.com/vscode-tas-client/-/vscode-tas-client-0.1.17.tgz#3a8613776149f4c571b6eb4a61def307a32997d9" - integrity sha512-5uqMeg7sjsu1/QkmuRtBOXtZnnrCXAMEihbOSxan3bk2NdA/nZvhfhfLh8gd9FlBBL56QH69I8Zn25B2yGPRng== +vscode-tas-client@^0.1.84: + version "0.1.84" + resolved "https://registry.yarnpkg.com/vscode-tas-client/-/vscode-tas-client-0.1.84.tgz#906bdcfd8c9e1dc04321d6bc0335184f9119968e" + integrity sha512-rUTrUopV+70hvx1hW5ebdw1nd6djxubkLvVxjGdyD/r5v/wcVF41LIfiAtbm5qLZDtQdsMH1IaCuDoluoIa88w== dependencies: - tas-client "0.1.16" + tas-client "0.2.33" -vscode-uri@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.3.tgz#a95c1ce2e6f41b7549f86279d19f47951e4f4d84" - integrity sha512-EcswR2S8bpR7fD0YPeS7r2xXExrScVMxg4MedACaWHEtx9ftCF/qHG1xGkolzTPcEmjTavCQgbVzHUIdTMzFGA== +vscode-uri@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.1.0.tgz#dd09ec5a66a38b5c3fffc774015713496d14e09c" + integrity sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ== vsls@^0.3.967: version "0.3.1291" @@ -5680,10 +7770,10 @@ wait-for-expect@^1.1.1: resolved "https://registry.yarnpkg.com/wait-for-expect/-/wait-for-expect-1.3.0.tgz#65241ce355425f907f5d127bdb5e72c412ff830c" integrity sha512-8fJU7jiA96HfGPt+P/UilelSAZfhMBJ52YhKzlmZQvKEZU2EcD1GQ0yqGB6liLdHjYtYAoGVigYwdxr5rktvzA== -watchpack@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.3.1.tgz#4200d9447b401156eeca7767ee610f8809bc9d25" - integrity sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA== +watchpack@^2.4.1: + version "2.4.2" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.2.tgz#2feeaed67412e7c33184e5a79ca738fbd38564da" + integrity sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw== dependencies: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" @@ -5724,47 +7814,46 @@ webpack-merge@^4.2.2: dependencies: lodash "^4.17.15" -webpack-sources@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-2.2.0.tgz#058926f39e3d443193b6c31547229806ffd02bac" - integrity sha512-bQsA24JLwcnWGArOKUxYKhX3Mz/nK1Xf6hxullKERyktjNMC4x8koOeaDNTA2fEJ09BdWLbM/iTW0ithREUP0w== +webpack-sources@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" + integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ== dependencies: - source-list-map "^2.0.1" - source-map "^0.6.1" + source-list-map "^2.0.0" + source-map "~0.6.1" webpack-sources@^3.2.3: version "3.2.3" resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== -webpack@5.68.0: - version "5.68.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.68.0.tgz#a653a58ed44280062e47257f260117e4be90d560" - integrity sha512-zUcqaUO0772UuuW2bzaES2Zjlm/y3kRBQDVFVCge+s2Y8mwuUTdperGaAv65/NtRL/1zanpSJOq/MD8u61vo6g== - dependencies: - "@types/eslint-scope" "^3.7.0" - "@types/estree" "^0.0.50" - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/wasm-edit" "1.11.1" - "@webassemblyjs/wasm-parser" "1.11.1" - acorn "^8.4.1" - acorn-import-assertions "^1.7.6" - browserslist "^4.14.5" +webpack@5.94.0: + version "5.94.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.94.0.tgz#77a6089c716e7ab90c1c67574a28da518a20970f" + integrity sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg== + dependencies: + "@types/estree" "^1.0.5" + "@webassemblyjs/ast" "^1.12.1" + "@webassemblyjs/wasm-edit" "^1.12.1" + "@webassemblyjs/wasm-parser" "^1.12.1" + acorn "^8.7.1" + acorn-import-attributes "^1.9.5" + browserslist "^4.21.10" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.8.3" - es-module-lexer "^0.9.0" + enhanced-resolve "^5.17.1" + es-module-lexer "^1.2.1" eslint-scope "5.1.1" events "^3.2.0" glob-to-regexp "^0.4.1" - graceful-fs "^4.2.9" - json-parse-better-errors "^1.0.2" + graceful-fs "^4.2.11" + json-parse-even-better-errors "^2.3.1" loader-runner "^4.2.0" mime-types "^2.1.27" neo-async "^2.6.2" - schema-utils "^3.1.0" + schema-utils "^3.2.0" tapable "^2.1.1" - terser-webpack-plugin "^5.1.3" - watchpack "^2.3.1" + terser-webpack-plugin "^5.3.10" + watchpack "^2.4.1" webpack-sources "^3.2.3" whatwg-encoding@^2.0.0: @@ -5814,6 +7903,59 @@ which-boxed-primitive@^1.0.1: is-string "^1.0.5" is-symbol "^1.0.3" +which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz#d76ec27df7fa165f18d5808374a5fe23c29b176e" + integrity sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA== + dependencies: + is-bigint "^1.1.0" + is-boolean-object "^1.2.1" + is-number-object "^1.1.1" + is-string "^1.1.1" + is-symbol "^1.1.1" + +which-builtin-type@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/which-builtin-type/-/which-builtin-type-1.2.1.tgz#89183da1b4907ab089a6b02029cc5d8d6574270e" + integrity sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q== + dependencies: + call-bound "^1.0.2" + function.prototype.name "^1.1.6" + has-tostringtag "^1.0.2" + is-async-function "^2.0.0" + is-date-object "^1.1.0" + is-finalizationregistry "^1.1.0" + is-generator-function "^1.0.10" + is-regex "^1.2.1" + is-weakref "^1.0.2" + isarray "^2.0.5" + which-boxed-primitive "^1.1.0" + which-collection "^1.0.2" + which-typed-array "^1.1.16" + +which-collection@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.2.tgz#627ef76243920a107e7ce8e96191debe4b16c2a0" + integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw== + dependencies: + is-map "^2.0.3" + is-set "^2.0.3" + is-weakmap "^2.0.2" + is-weakset "^2.0.3" + +which-typed-array@^1.1.16, which-typed-array@^1.1.19: + version "1.1.19" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.19.tgz#df03842e870b6b88e117524a4b364b6fc689f956" + integrity sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" + for-each "^0.3.5" + get-proto "^1.0.1" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + which-typed-array@^1.1.2: version "1.1.4" resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.4.tgz#8fcb7d3ee5adf2d771066fba7cf37e32fe8711ff" @@ -5834,17 +7976,15 @@ which@2.0.2, which@^2.0.1: dependencies: isexe "^2.0.0" -wide-align@1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" - integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== - dependencies: - string-width "^1.0.2 || 2" +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -word-wrap@^1.2.3, word-wrap@~1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" - integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== +word-wrap@~1.2.3: + version "1.2.4" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.4.tgz#cb4b50ec9aca570abd1f52f33cd45b6c61739a9f" + integrity sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA== wordwrapjs@^4.0.0: version "4.0.1" @@ -5854,10 +7994,24 @@ wordwrapjs@^4.0.0: reduce-flatten "^2.0.0" typical "^5.2.0" -workerpool@6.1.5: - version "6.1.5" - resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.1.5.tgz#0f7cf076b6215fd7e1da903ff6f22ddd1886b581" - integrity sha512-XdKkCK0Zqc6w3iTxLckiuJ81tiD/o5rBE/m+nXpRCB+/Sq4DqkfXZ/x0jW02DG1tGsfUGXbTJyZDP+eu67haSw== +workerpool@6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.0.tgz#827d93c9ba23ee2019c3ffaff5c27fccea289e8b" + integrity sha512-Rsk5qQHJ9eowMH28Jwhe8HEbmdYDX4lwoMWshiCXugjtHqMD9ZbiqSDLxcsfdqsETPzVUtX5s1Z5kStiIM6l4A== + +workerpool@^9.2.0: + version "9.3.4" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-9.3.4.tgz#f6c92395b2141afd78e2a889e80cb338fe9fca41" + integrity sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg== + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" wrap-ansi@^7.0.0: version "7.0.0" @@ -5868,15 +8022,24 @@ wrap-ansi@^7.0.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== ws@^8.2.3: - version "8.7.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.7.0.tgz#eaf9d874b433aa00c0e0d8752532444875db3957" - integrity sha512-c2gsP0PRwcLFzUiA8Mkr37/MI7ilIlHQxaEAtd0uNMbVMoy8puJyafRlm0bV9MbGSabUPeLrRRaqIBcFcA2Pqg== + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== xml-name-validator@^4.0.0: version "4.0.0" @@ -5893,7 +8056,7 @@ xmlchars@^2.2.0: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== -xtend@^4.0.0, xtend@~4.0.0, xtend@~4.0.1: +xtend@^4.0.2, xtend@~4.0.0, xtend@~4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== @@ -5913,11 +8076,6 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== -yaml@^1.7.2: - version "1.10.2" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" - integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== - yargs-parser@20.2.4: version "20.2.4" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54" @@ -5928,7 +8086,12 @@ yargs-parser@^20.2.2: resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== -yargs-unparser@2.0.0: +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs-unparser@2.0.0, yargs-unparser@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb" integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== @@ -5951,18 +8114,23 @@ yargs@16.2.0: y18n "^5.0.5" yargs-parser "^20.2.2" -yauzl@^2.4.2: - version "2.10.0" - resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" - integrity sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk= +yargs@^17.7.2: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== dependencies: - buffer-crc32 "~0.2.3" - fd-slicer "~1.1.0" + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" -ylru@^1.2.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.2.1.tgz#f576b63341547989c1de7ba288760923b27fe84f" - integrity sha512-faQrqNMzcPCHGVC2aaOINk13K+aaBDUPjGWl0teOXywElLjyVAB6Oe2jj62jHYtwsU49jXhScYbvPENK+6zAvQ== +yn@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" + integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== yocto-queue@^0.1.0: version "0.1.0" @@ -5981,3 +8149,8 @@ zen-observable@^0.8.0: version "0.8.15" resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15" integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ== + +zwitch@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7" + integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==