diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 647e2b3c37..d9d5ca7ad5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -5,12 +5,15 @@ 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: 1. -2. \ No newline at end of file +2. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index a820760fc4..9104fcd584 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,7 +1,6 @@ --- name: Feature request about: Suggest an idea for this project - --- 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/skills/write-changelog/SKILL.md b/.github/skills/write-changelog/SKILL.md new file mode 100644 index 0000000000..86b597da2d --- /dev/null +++ b/.github/skills/write-changelog/SKILL.md @@ -0,0 +1,74 @@ +--- +name: write-changelog +description: 'Write a CHANGELOG.md entry for a new extension release. Use when: writing changelog, updating changelog, adding release notes, preparing a release, documenting milestone changes. Requires a milestone name and extension version number.' +argument-hint: 'Provide the extension version (e.g. 0.134.0) and the GitHub milestone name (e.g. 1.112.0)' +--- + +# Write Changelog Entry + +Generate a new entry in `CHANGELOG.md` for an extension release by gathering closed issues and merged PRs from a GitHub milestone. + +## Inputs + +The user must provide: +1. **Extension version number** - the version for the changelog heading (e.g. `0.134.0`) +2. **GitHub milestone name** - the milestone to query (e.g. `1.112.0`) + +## Procedure + +### 1. Gather Milestone Items + +Search for all closed items in the milestone scoped to `microsoft/vscode-pull-request-github`: + +- **Issues (bugs/features):** Search for closed issues with `milestone:"" repo:microsoft/vscode-pull-request-github is:closed` +- **Merged PRs:** Search for merged PRs with `milestone:"" repo:microsoft/vscode-pull-request-github is:pr is:merged` + +### 2. Classify Items + +Sort every item into one of these buckets: + +| Bucket | Criteria | +|--------|----------| +| **Changes** | Feature requests, enhancements, new settings, new commands, infrastructure improvements (e.g. dependency upgrades, build system changes) | +| **Fixes** | Items labeled `bug`, or PRs that fix a specific issue | +| **Thank You** | Merged PRs authored by external contributors (user type is not `Bot`, and user is not a GitHub staff / `site_admin`) | +| **Skip** | Version-bump PRs (title is just a version like "0.132.0"), test plan items, items from other repos | + +### 3. Write the Entry + +Insert the new section **at the top** of `CHANGELOG.md`, directly after the `# Changelog` heading and before the previous release section. + +Follow this format exactly: + +```markdown +## + +### Changes + +- Description of change one. +- Description of change two with setting `"settingName"`. + +### Fixes + +- Short description of bug. https://github.com/microsoft/vscode-pull-request-github/issues/ +- Another bug fix. https://github.com/microsoft/vscode-pull-request-github/issues/ + +**_Thank You_** + +* [@username (Display Name)](https://github.com/username): Short description of contribution [PR #1234](https://github.com/microsoft/vscode-pull-request-github/pull/1234) +``` + +### Format Rules + +- **Changes:** Write a concise, user-facing description. Do NOT link to issues. Use backticks for setting names and commands. Each entry is a single `- ` bullet. +- **Fixes:** Use the issue title (cleaned up for readability) followed by a space and the full issue URL. Each entry is a single `- ` bullet. If a fix came from a PR without a linked issue, describe it without a URL. +- **Thank You:** Use `* ` bullets (not `- `). Format: `[@login (Name)](profile-url): Description [PR #number](pr-url)`. Only include for external community contributors. +- **Sections:** Omit any section (`### Changes`, `### Fixes`, `**_Thank You_**`) if there are no items for it. +- **No blank lines** between bullets within a section. +- **One blank line** between sections. + +### 4. Validate + +- Confirm the new section is positioned correctly (after `# Changelog`, before the previous version). +- Verify all issue/PR links are correct and point to `microsoft/vscode-pull-request-github`. +- Ensure no duplicate entries. diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 0000000000..74dc8fb9a7 --- /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: "npm" + + - name: Install dependencies + run: npm ci + 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: npm run 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/.gitignore b/.gitignore index 9fff26c67f..3520467a42 100644 --- a/.gitignore +++ b/.gitignore @@ -331,17 +331,21 @@ ASALocalRun/ out -preview-src/dist node_modules +dist +./test/ media .vscode-test +.vscode-test-web .DS_Store *.vsix -package-lock.json -src/typings/vscode.d.ts -src/typings/vscode.proposed.d.ts +src/@types/vscode.d.ts +src/@types/vscode.proposed.d.ts # CI package.insiders.json README.insiders.md src/constants.insiders.ts + +.eslintcache +*.tsbuildinfo diff --git a/.gitignore-revs b/.gitignore-revs new file mode 100644 index 0000000000..727a5b69c8 --- /dev/null +++ b/.gitignore-revs @@ -0,0 +1,2 @@ +4c1ed39f2fa6d36b378eefd9427fc0a65a835024 +5bc7d184e784e74f343294680017262501305974 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000000..17c0786299 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,11 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npm run hygiene + +# Lint only staged TS/TSX files instead of the whole repo (much faster). +# Stream a NUL-delimited list directly to xargs -0 so paths with spaces/newlines +# are handled safely (command substitution would strip NUL bytes). +if git diff --cached --name-only --diff-filter=ACMR -- '*.ts' '*.tsx' | grep -q .; then + git diff --cached --name-only --diff-filter=ACMR -z -- '*.ts' '*.tsx' | xargs -0 npx eslint --fix --cache +fi diff --git a/.readme/demo.gif b/.readme/demo.gif index 1dc06e411e..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 ac53f6e6e4..cd6d08e737 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,6 +1,7 @@ { "recommendations": [ - "ms-vscode.vscode-typescript-tslint-plugin", - "eamodio.tsl-problem-matcher" + "dbaeumer.vscode-eslint", + "amodio.tsl-problem-matcher", + "ms-vscode.extension-test-runner" ] -} \ No newline at end of file +} diff --git a/.vscode/launch.json b/.vscode/launch.json index b4f89faea2..07d36ff613 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,9 +2,9 @@ "version": "0.2.0", "configurations": [ { + "name": "Launch Extension", "type": "extensionHost", "request": "launch", - "name": "Launch Extension", "runtimeExecutable": "${execPath}", "args": [ "--extensionDevelopmentPath=${workspaceFolder}", @@ -17,21 +17,127 @@ "smartStep": true, "sourceMaps": true, "outFiles": [ - "${workspaceFolder}/media/*.js" + "${workspaceFolder}/dist/*.js", + "${workspaceFolder}" + ], + "debugWebviews": true + }, + { + "name": "Launch Extension in Webworker", + "type": "extensionHost", + "request": "launch", + "debugWebWorkerHost": true, + "runtimeExecutable": "${execPath}", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}", + "--disable-extension=GitHub.vscode-pull-request-github-insiders" + ], + "skipFiles": [ + "/**/*.js", + "**/node_modules/**/*.js" + ], + "smartStep": true, + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/dist/browser/*.js" ] }, { - "name": "Extension Tests", + "name": "Watch & Launch Extension", "type": "extensionHost", "request": "launch", "runtimeExecutable": "${execPath}", "args": [ "--extensionDevelopmentPath=${workspaceFolder}", - "--extensionTestsPath=${workspaceFolder}/out/test" + "--disable-extension=GitHub.vscode-pull-request-github-insiders" ], - "preLaunchTask": "npm: preprocess", + "skipFiles": [ + "/**/*.js", + "**/node_modules/**/*.js" + ], + "preLaunchTask": "npm: watch", + "smartStep": true, + "sourceMaps": true, "outFiles": [ - "${workspaceFolder}/out/test/**/*.js" + "${workspaceFolder}/dist/*.js" + ] + }, + { + "name": "Watch & Launch Extension in Webworker", + "type": "extensionHost", + "request": "launch", + "debugWebWorkerHost": true, + "runtimeExecutable": "${execPath}", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}", + "--disable-extension=GitHub.vscode-pull-request-github-insiders" + ], + "skipFiles": [ + "/**/*.js", + "**/node_modules/**/*.js" + ], + "preLaunchTask": "npm: watch", + "smartStep": true, + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/dist/browser/*.js" + ] + }, + { + "name": "Extension Tests", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}", + "--extensionTestsPath=${workspaceFolder}/out/src/test", + "--disable-extensions", + "--user-data-dir=${workspaceFolder}/.vscode-test" + ], + "preLaunchTask": "npm: test:preprocess", + "smartStep": true, + "sourceMaps": true + }, + { + "type": "node", + "request": "launch", + "name": "Attach Web Test", + "program": "${workspaceFolder}/node_modules/@vscode/test-web/out/server/index.js", + "args": [ + "--extensionTestsPath=dist/browser/test/index.js", + "--extensionDevelopmentPath=.", + "--browserType=chromium", + ], + "cascadeTerminateToConfigurations": [ + "Launch Web Test" + ], + "presentation": { + "hidden": true, + } + }, + { + "type": "chrome", + "request": "launch", + "name": "Launch Web Test", + "skipFiles": [ + "/**" + ], + "port": 9229, + "resolveSourceMapLocations": [ + "!**/vs/**", // exclude core vscode sources + "!**/static/build/extensions/**", // exclude built-in extensions + ], + "presentation": { + "hidden": true, + } + }, + ], + "compounds": [ + { + "name": "Debug Web Test", + "configurations": [ + "Attach Web Test", + "Launch Web Test" ] } ] diff --git a/.vscode/settings.json b/.vscode/settings.json index 4943de95f9..71538296dc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,17 @@ { + "editor.insertSpaces": false, + "files.associations": { + ".eslintrc.json": "jsonc", + ".eslintrc.*.json": "jsonc" + }, + "json.schemas": [ + { + "fileMatch": [ + "**/.eslintrc.*.json" + ], + "url": "https://json.schemastore.org/eslintrc" + } + ], "files.trimTrailingWhitespace": true, - "editor.insertSpaces": false -} \ No newline at end of file + "gitlens.advanced.blame.customArguments": ["--ignore-revs-file", ".gitignore-revs"] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 6a04fbdd77..1eb8296257 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,17 +4,49 @@ "version": "2.0.0", "tasks": [ { - "label": "npm", + "type": "npm", + "script": "build", + "group": "build", + "problemMatcher": ["$ts-checker-webpack", "$ts-checker-eslint-webpack"] + }, + { + "type": "npm", + "script": "lint", + "group": "build", + "problemMatcher": ["$eslint-stylish"] + }, + { "type": "npm", "script": "watch", - "isBackground": true, - "problemMatcher": [ - "$ts-webpack-watch" - ], "group": { "kind": "build", "isDefault": true - } + }, + "isBackground": true, + "problemMatcher": ["$ts-checker-webpack-watch", "$ts-checker-eslint-webpack-watch"] + }, + { + "label": "Serve local web extension", + "dependsOn": ["serve web", "tunnel web"], + "isBackground": true, + "problemMatcher": [], + "presentation": { "group": "vscode-web" } + }, + { + "label": "serve web", + "type": "shell", + "command": "npx serve --cors -l 5000", + "isBackground": true, + "presentation": { "group": "vscode-web" }, + "problemMatcher": [] + }, + { + "label": "tunnel web", + "type": "shell", + "command": "npx localtunnel -p 5000", + "isBackground": true, + "presentation": { "group": "vscode-web" }, + "problemMatcher": [] } ] -} \ No newline at end of file +} diff --git a/.vscodeignore b/.vscodeignore index dead6c518a..e64d84d935 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,16 +1,39 @@ +.github +.husky +.readme/** .vscode/** +.vscode-test/** +.vscode-test-web/** +build/** out/** -src/** -preview-src/** +dist/browser/test/** +documentation/** node_modules/** +scripts/** +src/** +!src/lm/skills/** +webviews/** +**/test/** +.eslintcache* +.eslintignore +.eslintrc* +.gitattributes .gitignore -.github -tsconfig.json -tslint.json -yarn.lock +.gitignore-revs +.prettierignore +.prettierrc +package-lock.json +azure-pipeline.* +**/tsconfig*.json +*.tsbuildinfo +**/webpack.config*.js +npm-debug.log +**/*.map +**/*.svg +!**/*_webview.svg +**/*.ts *.vsix -media/*.map -.readme/** -documentation/** -.vscode-test/** -webpack.config.js +**/*.bak +package.insiders.json +README.insiders.md +tsfmt.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fd53f9318..51926665ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,2072 @@ # Changelog +## 0.156.0 + +### Fixes + +- Attestation commit shows poorly on web. https://github.com/microsoft/vscode-pull-request-github/issues/8816 +- PR shows green checkmark for ready to commit even though branch protection policy is not met yet (review pending). https://github.com/microsoft/vscode-pull-request-github/issues/8813 +- Link to an issue doesn't work. https://github.com/microsoft/vscode-pull-request-github/issues/8774 + +**_Thank You_** + +* [@kamil-gwozdz (kamil-gwozdz)](https://github.com/kamil-gwozdz): Remove the `rateLimit` selection from the MaxPullRequest GraphQL query. [PR #8812](https://github.com/microsoft/vscode-pull-request-github/pull/8812) + +## 0.154.0 + +### Changes + +- Back off polling for pull request updates when no changes are found, reducing unnecessary requests. + +## 0.152.0 + +### Fixes + +- Upload file renders on top of text in the input. https://github.com/microsoft/vscode-pull-request-github/issues/8776 + +## 0.150.0 + +### Fixes + +- New PR title doesn't match behavior on github.com. https://github.com/microsoft/vscode-pull-request-github/issues/8771 +- PRs not shown in panel when workspace folder is a manually-created git worktree. https://github.com/microsoft/vscode-pull-request-github/issues/8756 + +## 0.148.0 + +### Changes + +- Contribute YAML schema validation for GitHub issue template files under `.github/ISSUE_TEMPLATE/`. + +### Fixes + +- Comments panel discards `` alt attribute, renders "Image: image". https://github.com/microsoft/vscode-pull-request-github/issues/8760 +- Merge button shows a separator even when there are no extra options. https://github.com/microsoft/vscode-pull-request-github/issues/8759 +- URI handler rejects PR URLs with underscores in the owner name. https://github.com/microsoft/vscode-pull-request-github/issues/8758 +- PR description is reset if the branch push fails. https://github.com/microsoft/vscode-pull-request-github/issues/8678 + +**_Thank You_** + +* [@Malix-Labs](https://github.com/Malix-Labs): Contribute YAML schema validation for GitHub issue templates [PR #8761](https://github.com/microsoft/vscode-pull-request-github/pull/8761) + +## 0.146.0 + +### Changes + +- Trim `Co-authored-by` lines from the default PR description. + +### Fixes + +- "Bad credentials" error persists even after re-authenticating via browser. https://github.com/microsoft/vscode-pull-request-github/issues/8738 +- Comment shows twice in diff editor. https://github.com/microsoft/vscode-pull-request-github/issues/8736 +- Upload button overlays cursor / text in comment editor. https://github.com/microsoft/vscode-pull-request-github/issues/8735 +- Branches tracking a shared upstream incorrectly associated with stale closed PRs. https://github.com/microsoft/vscode-pull-request-github/issues/8676 + +## 0.144.0 + +### Changes + +- Support uploading images to pull requests with a button and with paste. + +![Use paste to upload an image](./documentation/changelog/0.144.0/upload-an-image.gif) + +- Use a more descriptive folder name when checking out pull requests in a worktree. +- `"githubIssues.issueBranchTitle"` now supports the `${issueType}` template variable. + +### Fixes + +- VS Code hangs with "the upstream ref origin/master for branch master is not a github repo". https://github.com/microsoft/vscode-pull-request-github/issues/8729 +- `getAssignableUsers` and `getMentionableUsers` bypass `globalState` cache when fetch promise already exists. https://github.com/microsoft/vscode-pull-request-github/issues/8669 +- Bad Credentials error. https://github.com/microsoft/vscode-pull-request-github/issues/8603 + +**_Thank You_** + +* [@MaxDNG](https://github.com/MaxDNG): Re-parent pulled-up directory children to ensure proper checkbox refresh [PR #8679](https://github.com/microsoft/vscode-pull-request-github/pull/8679) + +## 0.142.0 + +### Changes + +- Display linked issue(s) in the Pull Request Overview. +- Clean up GitHub API usage to reduce unnecessary calls, errors, and generally reduce load on GitHub. + +### Fixes + +- Issue sorting has different behavior for CLI created issues. https://github.com/microsoft/vscode-pull-request-github/issues/8703 +- Checked checkboxes (- [x]) are visually indistinguishable from unchecked ones. https://github.com/microsoft/vscode-pull-request-github/issues/8671 + +**_Thank You_** + +* [@mohamedamara1](https://github.com/mohamedamara1): Display linked issue(s) from the PR Overview [PR #6835](https://github.com/microsoft/vscode-pull-request-github/pull/6835) + +## 0.140.0 + +### Changes + +- New "Checkout Pull Request in Worktree" option added to the Pull Requests view context menu and the PR description view. + +### Fixes + +- `hasBranch` called with repository name (`pr.base.name`) instead of branch name (`pr.base.ref`) in `reviewManager.ts`. https://github.com/microsoft/vscode-pull-request-github/issues/8668 + +**_Thank You_** + +* [@Will-hxw](https://github.com/Will-hxw): Use `pr.base.ref` instead of `pr.base.name` in `hasBranch` call [PR #8698](https://github.com/microsoft/vscode-pull-request-github/pull/8698) + +## 0.138.0 + +### Fixes + +- Extension does not show all PR checks. https://github.com/microsoft/vscode-pull-request-github/issues/8640 +- File-scoped comment fails with "Error: File has been deleted". https://github.com/microsoft/vscode-pull-request-github/issues/8641 +- PR made without the UI is not linked to the existing branch. https://github.com/microsoft/vscode-pull-request-github/issues/8643 + +## 0.136.0 + +### Changes + +- Add tool to create pull requests. +- Add tool to resolve review comments. +- Offer to delete worktrees from the clean up command. +- Include line numbers in "Apply suggestion using AI" context for code review comments. + +### Fixes + +- Resolving comments doesn't update state on server. https://github.com/microsoft/vscode-pull-request-github/issues/8649 +- PR description filled differently than on github.com. https://github.com/microsoft/vscode-pull-request-github/issues/8630 +- Can't checkout PR locally when already on the same branch. https://github.com/microsoft/vscode-pull-request-github/issues/8624 +- Parent folder checkbox not auto-checked when all children are marked as viewed. https://github.com/microsoft/vscode-pull-request-github/issues/8584 +- Escape file names in processed links. + +## 0.134.0 + +### Changes + +- Branch names in the create PR view are now cached for faster target branch loading. +- GitHub permalink links in PR and issue overview webviews now open the corresponding local file when the file exists in the workspace. + +### Fixes + +- Selecting a target branch when creating a PR is sometimes broken. https://github.com/microsoft/vscode-pull-request-github/issues/8627 +- "Apply suggestion using AI" for code comment reviews results in a redundant disclaimer suffix. https://github.com/microsoft/vscode-pull-request-github/issues/8605 + +**_Thank You_** + +* [@Daniel-Aaron-Bloom](https://github.com/Daniel-Aaron-Bloom): Link to local file for permalinks in webview [PR #8583](https://github.com/microsoft/vscode-pull-request-github/pull/8583) + +## 0.132.2 + +### Fixes + +- Button missing rounded corners on one side. https://github.com/microsoft/vscode-pull-request-github/issues/8609 + +## 0.132.1 + +### Fixes + +- Can't add multiline GitHub PR Comment because Inline Chat button covers Comment button. https://github.com/microsoft/vscode-pull-request-github/issues/8504 +- Deleting a worktree after merging a PR mixes the main branch with the worktree. https://github.com/microsoft/vscode-pull-request-github/issues/8519 +- Missing issue webview icon. + +## 0.132.0 + +### Changes + +- When `"githubPullRequests.pullRequestDescription"` is set to `"branchName"`, the branch name is now "title-ized" (dashes and underscores replaced with spaces, words capitalized) to match the behavior on github.com. +- Pull requests in the sidebar views can now show icons representing their status (open, closed, merged, draft) with the setting `"githubPullRequests.pullRequestAvatarDisplay"`. + +### Fixes + +- Update with merge commit feature not working. https://github.com/microsoft/vscode-pull-request-github/issues/8553 +- Avatar doesn't show in comment widget with GitHub Enterprise. https://github.com/microsoft/vscode-pull-request-github/issues/8550 +- Can't easily open PR to the right. https://github.com/microsoft/vscode-pull-request-github/issues/8537 +- Stale worktree repo entry persists in PR sidebar after worktree removal. https://github.com/microsoft/vscode-pull-request-github/issues/8525 +- Wrong list of files when creating PR. https://github.com/microsoft/vscode-pull-request-github/issues/8457 +- Element with id All Open (URL) is already registered. https://github.com/microsoft/vscode-pull-request-github/issues/8073 + +## 0.130.0 + +### Changes + +- Multiple PR and issue descriptions can be open at once. +- Repositories without matching issues are now hidden in the Issues view. +- The setting `githubPullRequests.autoRepositoryDetection` can be set to `true` to include repositories that are outside of the workspace. + +### Fixes + +- GitHub Pull Requests: Refresh Chat Sessions command remains available with disableAIFeature set to true. https://github.com/microsoft/vscode-pull-request-github/issues/8376 +- Share permalink as markdown on tab should use filename for link text. https://github.com/microsoft/vscode-pull-request-github/issues/4663 +- "Branch does not exist locally" while rebasing. https://github.com/microsoft/vscode-pull-request-github/issues/8487 +- Coding Agent UI: Closed pull requests are not detected as closed. https://github.com/microsoft/vscode-pull-request-github/issues/8482 +- The "Refresh Pull Request Comments" command does not work. https://github.com/microsoft/vscode-pull-request-github/issues/8445 +- Popup "delegate to agent" unremovable. https://github.com/microsoft/vscode-pull-request-github/issues/8496 +- There is no indicator of what repository the PR relates to. https://github.com/microsoft/vscode-pull-request-github/issues/8174 +- PR titles cropped unnecessarily. https://github.com/microsoft/vscode-pull-request-github/issues/8453 +- PR tool descriptions are not user-oriented. https://github.com/microsoft/vscode-pull-request-github/issues/8510 +- New "check out PR by number" is confusing. https://github.com/microsoft/vscode-pull-request-github/issues/8455 +- Refreshing PR does not refresh rerun status checks. https://github.com/microsoft/vscode-pull-request-github/issues/8449 +- Improve commit line break unwrapping. https://github.com/microsoft/vscode-pull-request-github/issues/8345 + +**_Thank You_** + +* [@gvilums (Georgijs)](https://github.com/gvilums): Fix PR tree reveal errors for flat file layout [PR #8522](https://github.com/microsoft/vscode-pull-request-github/pull/8522) + +## 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 + +- Changes can be suggested and accepted from within editor comments + +![Suggest a Change](documentation/changelog/0.58.0/suggest-a-change.gif) + +- The setting `githubPullRequests.defaultCommentType` controls whether the default comment type is a single comment or a review comment. +- `"githubPullRequests.postCreate": "checkoutDefaultBranch"` will cause the default branch to be checked out after creating a PR. +- Section headings (assignees, reviewers, lables, and milestones) are clickable in the PR overview. +- The commands pr.openModifiedFile pr.openDiffView can be executed with a keyboard shortcut on the active file. +- GitHub handles in comments are now linkified. +- Setting `"githubPullRequests.createDraft": true` will make created PRs default to drafts. +- Permalinks can be created for non-text, rendered, files. +- Labels can be added to PRs at creation time + +![Create a PR with labels](documentation/changelog/0.58.0/create-with-labels.png) + +- A progress notification shows during PR creation. +- Branches and remotes for PRs that are made from a fork and are checked out from the "Pull Requests" view will be automatically cleaned up when the default branch is checked out using the "Checkout default branch" button. +- An experimental setting `githubPullRequests.experimental.quickDiff` will show the quick diff widget in the editor gutter for changed lines in a checked out PR. + +### Fixes + +- Using the enter key while renaming a PR should save the title. https://github.com/microsoft/vscode-pull-request-github/issues/4402 +- JSDoc hover for @return shows GHPRI username hover. https://github.com/microsoft/vscode-pull-request-github/issues/4344 +- Some text is not visible in high contrast mode. https://github.com/microsoft/vscode-pull-request-github/issues/4287 +- Empty diff view after reloading. https://github.com/microsoft/vscode-pull-request-github/issues/4293 +- Error signing in to Github. Try Again doesn't try again. https://github.com/microsoft/vscode-pull-request-github/issues/4148 +- Other accessibility fixes. https://github.com/microsoft/vscode-pull-request-github/issues/4237 + +**_Thank You_** + +* [@eamodio (Eric Amodio)](https://github.com/eamodio): Updates TypeScript (released 4.2) and Octokit (to get fixed types), and a couple minor others [PR #2525](https://github.com/microsoft/vscode-pull-request-github/pull/2525) +* [@sravan1946 (sravan)](https://github.com/sravan1946): Remove unavailable badge from readme [PR #4393](https://github.com/microsoft/vscode-pull-request-github/pull/4393) +* [@Thomas1664](https://github.com/Thomas1664) + * Fix comment layout & use bin as delete icon [PR #4285](https://github.com/microsoft/vscode-pull-request-github/pull/4285) + * Colorize status badge [PR #4286](https://github.com/microsoft/vscode-pull-request-github/pull/4286) + * UI fixes for PR view [PR #4368](https://github.com/microsoft/vscode-pull-request-github/pull/4368) + * Use correct permission to show 'assign yourself' in PR view sidebar [PR #4369](https://github.com/microsoft/vscode-pull-request-github/pull/4369) + * Fix UI for PR draft status check entry [PR #4370](https://github.com/microsoft/vscode-pull-request-github/pull/4370) + + +## 0.56.0 + +### Changes + +- Most recent PR is selected when a branch has multiple PRs. +- Notebooks support for the permalink commands. +- Review status is shown in the PRs view. + +![Pull Requests view with status](documentation/changelog/0.56.0/pr-status-in-list.png) + +- PR links to vscode.dev can be copied from the Pull Request description page. + +![Copy vscode.dev link button](documentation/changelog/0.56.0/copy-vscode-dev-link.png) + +- The new "Go To Next Diff in Pull Request" command will navigate to the next diff in the pull request across files. +- The "Resolve" and "Unresolve" buttons are now always visible on comments, instead of only showing when the reply is expanded. + +![Always visible resolve button](documentation/changelog/0.56.0/visible-resolve-button.png) + +### Fixes + +- Still getting auto-fetching behavior when setting is off. https://github.com/microsoft/vscode-pull-request-github/issues/4202 + +**_Thank You_** + +* [@joshuaobrien (Joshua O'Brien)](https://github.com/joshuaobrien): Narrow types in TimelineEvent so that it may be treated as a tagged union [PR #4160](https://github.com/microsoft/vscode-pull-request-github/pull/4160) + +## 0.54.1 + +### Fixes + +- No Longer Prompted To Create PR after Pushing Feature Branch. https://github.com/microsoft/vscode-pull-request-github/issues/4171 + +## 0.54.0 + +### Changes + +- Pull Requests can be submitted from the "Create" view by doing `ctrl/cmd+enter` while your cursor is in the description input box. +- Keybindings are supported for "Mark File as Viewed" (`pr.markFileAsViewed`). When "Mark File as Viewed" is run from a command or from the editor toolbar the file will also be closed. Tip: Use with "Open All Diffs" for quickly going through a PR review. +- Checked-out pull requests with less than 20 files will have all the diffs pre-fetched for faster diff-opening times. +- Strings in VS Code UI have been configured for localization. Strings in webviews (such as the "Create" view and the PR description/overview) are still not localized. + +### Fixes + +- User completion in commit box is wrong when manually triggered. https://github.com/microsoft/vscode-pull-request-github/issues/4026 +- Extension periodically refreshes the file under review, resetting the view position. https://github.com/microsoft/vscode-pull-request-github/issues/4031 +- Does the GHPRI extension need to be * activated? https://github.com/microsoft/vscode-pull-request-github/issues/4046 +- "viewed" checkboxes don't always propagate. https://github.com/microsoft/vscode-pull-request-github/issues/3959 +- Block comments not rendering correctly. https://github.com/microsoft/vscode-pull-request-github/issues/4013 +- Can't Create an Issue without body. https://github.com/microsoft/vscode-pull-request-github/issues/4027 + +**_Thank You_** + +* [@hoontae24](https://github.com/hoontae24): feat: Add origin of upstream for github enterprise on copy head link [PR #4028](https://github.com/microsoft/vscode-pull-request-github/pull/4028) +* [@Thomas1664](https://github.com/Thomas1664): UI fixes for checks section [PR #4059](https://github.com/microsoft/vscode-pull-request-github/pull/4059) +* [@yin1999 (A1lo)](https://github.com/yin1999): fix: use ssh url for ssh protocol upstream [PR #3853](https://github.com/microsoft/vscode-pull-request-github/pull/3853) + +## 0.52.0 + +### Changes + +- Improved support for GitHub Enterprise starting with GitHub Enterprise version 3.1. This includes: + - PAT-less authentication courtesy of the VS Code built in GitHub Enterprise authentication provider. + - Automatic detection when you open a folder with an Enterprise repo and an on-ramp to get set up. + - Fixes for GitHub Enterprise bugs. _Note:_ If you find any issues with GitHub Enterprise please do file an issue! +- Checkboxes to mark files as viewed. This means you can mark whole folders as viewed now. +![Checkboxes to mark as viewed](documentation/changelog/0.52.0/tree-item-checkbox-state.png) +- When you use the "Checkout 'default branch'" button, the pull request overview and all associated diffs will close. +- Issues referenced by `#` in pull request titles are linked to the pull request. + +### Fixes + +- Multi-root workspaces with two projects checked out to branches with open PRs either shows errors or misleading information. https://github.com/microsoft/vscode-pull-request-github/issues/3490 +- Draft PR checkbox reverts to unchecked after typing description. https://github.com/microsoft/vscode-pull-request-github/issues/3977 + +**_Thank you_** + +* [@Thomas1664](https://github.com/Thomas1664): Add button to always pull on incoming changes [PR #3896](https://github.com/microsoft/vscode-pull-request-github/pull/3896) + +## 0.50.0 + +### Changes + +- By setting the `githubPullRequests.notifications` setting to `pullRequests` Pull Requests which have unread notifications will be highlighted. + + ![GitHub Notifications](documentation/changelog/0.50.0/githubNotifications.gif) + +- GitHub labels will render with the GitHub colors + + ![GitHub Label Colors](documentation/changelog/0.50.0/labelColors.png) + +- Review Comments can now be directly resolved/unresolved in the Pull Request Overview +- Creating an issue should never lose data. If the "new issue" editor is closed but the issue is not created, the data will be stored until VS Code is reloaded. +- When the local branch is out of date, a prompt to pull the branch will show when the "Refresh" button on the PR overview is clicked. The setting `githubPullRequests.pullBranch` also has a new `always` option. +- Renamed files have a tooltip that makes the rename clearer. + + ![Renamed file tooltip](documentation/changelog/0.50.0/renamed-tooltip.png) + +- The command "Reset Viewed Files" will reset all files to be unviewed. + +### Fixes + +- Fails to load Pull Requests on older GitHub Enterprise verisons https://github.com/microsoft/vscode-pull-request-github/issues/3829 +- Copy GitHub Permalink in LHS of a PR diff generates a link to the RHS. https://github.com/microsoft/vscode-pull-request-github/issues/3801 +- User and issue suggestions don't always show for some languages. https://github.com/microsoft/vscode-pull-request-github/issues/3874 +- Comment rendering distorted / missing linebreaks. https://github.com/microsoft/vscode-pull-request-github/issues/3776 + +## 0.48.0 + +### Changes + +- The changes since last review button will appear on PRs to which a commit has been pushed since the viewers review. Pressing it will only show the diffs for the commits since the review. + + ![Changes since last review](documentation/changelog/0.48.0/changesSinceReview.gif) + +- Milestones can be created directly from the add milestone dropdown. +- The setting `githubPullRequests.pullRequestDescription` has been brought back from being deprecated. +- `githubPullRequests.pullBranch` can be used to configure whether to be prompted to pull changes when a change in a PR is detected. +- The new **Commit & Create Pull Request** action in the git SCM view let's you commit and go to the "Create PR" view in one click. + + ![Commit and Create Pull Request action](documentation/changelog/0.48.0/commit-and-create-pr.png) + +### Fixes + +- Improve performance of expanding a PR in the "Pull Requests" view. https://github.com/microsoft/vscode-pull-request-github/issues/3684 +- Performance: Delay in showing Assignee quick open. https://github.com/microsoft/vscode-pull-request-github/issues/3728 +- Apply patch feature bugs. https://github.com/microsoft/vscode-pull-request-github/issues/3722 +- Show a notification when there are 2 signed in GitHub accounts. https://github.com/microsoft/vscode-pull-request-github/issues/3693 +- Only first 30 files per PR show in the "Pull Requests" view on vscode.dev. https://github.com/microsoft/vscode-pull-request-github/issues/3682 +- "Upgrade" Pull request diffs opened from the "Pull Requests" view after the PR is checked out. https://github.com/microsoft/vscode-pull-request-github/issues/3631 + +## 0.46.0 + +### Changes + +- Use the setting `"githubPullRequests.ignoredPullRequestBranches"` to ignore branches for pull requests. +- The setting `"githubPullRequests.overrideDefaultBranch"` lets you override the default branch from github.com locally. +- The "Publish branch?" dialog can be skipped when creating a PR using the setting `"githubPullRequests.pushBranch"`. +- The auto-merge checkbox is availabe in the "Overview" editor. + + ![Auto-merge in the overview](documentation/changelog/0.46.0/automerge-overview.png) + +### Fixes + +- "Exit Review Mode" changed to "Checkout default Branch". https://github.com/microsoft/vscode-pull-request-github/issues/3637 +- Comments showed when opening a PR despite having "comments.openView": "never" set. https://github.com/microsoft/vscode-pull-request-github/issues/3652 +- Can't comment on a new file (via github.dev web editor). https://github.com/microsoft/vscode-pull-request-github/issues/3646 +- Cannot view more than 100 and few files in PR. https://github.com/microsoft/vscode-pull-request-github/issues/3623 +- The Copy GitHub Permalink command copies wrong commit hash. https://github.com/microsoft/vscode-pull-request-github/issues/3566 + +**_Thank You_** + +* [@blindpirate (Bo Zhang)](https://github.com/blindpirate): Show pull request's close button for author [PR #3507](https://github.com/microsoft/vscode-pull-request-github/pull/3507) +* [@leopoldsedev (Christian Leopoldseder)](https://github.com/leopoldsedev): Implement quick self assign link as available on .com (#3382) [PR #3601](https://github.com/microsoft/vscode-pull-request-github/pull/3601) + + +## 0.44.0 + +### Changes + +- Auto-merge support from the "Create" view. + + ![Auto-merge from the create view](documentation/changelog/0.44.0/auto-merge-create-view.png) + +### Fixes + +- Creating a pull request doesn't use commit message for PR description when the base branch has more commits. https://github.com/microsoft/vscode-pull-request-github/issues/3350 +- Fails to activate with "Timed out waiting for authentication provider to register". https://github.com/microsoft/vscode-pull-request-github/issues/3469 +- Prompted about updates to PR after pushing to the PR branch. https://github.com/microsoft/vscode-pull-request-github/issues/3479 +- Pull requests created from a fork on a topic branch aren't discovered. https://github.com/microsoft/vscode-pull-request-github/issues/3511 +- Unable to create PR in web. https://github.com/microsoft/vscode-pull-request-github/issues/3528 + +**_Thank You_** + +* [@jpspringall](https://github.com/jpspringall): Issue #3371 | Updated getAuthSessionOptions in case of GitHub Enterprise AuthProvider [PR #3565](https://github.com/microsoft/vscode-pull-request-github/pull/3565) + +## 0.42.0 + +### Changes + +- New actions to go to github.com/owner/repo/issues and github.com/owner/repo/pulls + + ![Open on GitHub](documentation/changelog/0.42.0/open-on-github.png) +- Support for multiline comments + + ![Multiline comments](documentation/changelog/0.42.0/multiline-comments.gif) +- Commit changes can now show in a tree or list depending on the value of `githubPullRequests.fileListLayout` +- "Create Permalink" works on github.dev and vscode.dev from a tag or commit number. +- "Mark as Viewed" and "Mark as Unviewed" are available in the editor toolbar + + ![Mark file as viewed from the editor toolbar](documentation/changelog/0.42.0/mark-as-viewed-toolbar.png) +- Better defaults for `githubIssues.ignoreCompletionTrigger`. + +### Fixes + +- Invisible Button/Input Text in High Contrast Themes. https://github.com/microsoft/vscode-pull-request-github/issues/3342 +- Create PR icon dissapears from Source Control view. https://github.com/microsoft/vscode-pull-request-github/issues/3410 +- Don't make potentially expensive getObjectDetails and detectObjectType calls for known file extensions. https://github.com/microsoft/vscode-pull-request-github/issues/3446 +- Web: Broken state for deleting branch in repo that deletes on merge. https://github.com/microsoft/vscode-pull-request-github/issues/3453 +- Can't leave a comment in PR webviewview without a pending review. https://github.com/microsoft/vscode-pull-request-github/issues/3480 +- Text suggestions load forever on first column. https://github.com/microsoft/vscode-pull-request-github/issues/3485 + +**_Thank You_** + +* [@hoontae24](https://github.com/hoontae24): Support the copied permalink for Github Enterprise URL [PR #3460](https://github.com/microsoft/vscode-pull-request-github/pull/3460) + +## 0.40.0 + +### Changes + +- @user completions works in CODEOWNERS files +- There is a new setting for "Don't show again" on the notification shown when a branch is published: `githubPullRequests.createOnPublishBranch`. + +### Fixes + +- Sorting order of issues in SCM commit box is off. https://github.com/microsoft/vscode-pull-request-github/issues/3365 +- Comments panel - comment appears twice. https://github.com/microsoft/vscode-pull-request-github/issues/3349 + +## 0.38.0 + +### Changes + +- The **Mark File as Viewed** and **Mark File as Unviewed** actions have new icons, and we've generally tried to improve the UX around marking as viewed/unviewed. + + ![Mark file as viewed UX](documentation/changelog/0.38.0/mark-file-viewed.gif) + +### Fixes + +- "We couldn't find an open repository for...". https://github.com/microsoft/vscode-pull-request-github/issues/3242 +- Placeholder title when creating a PR does not behave like GitHub website. https://github.com/microsoft/vscode-pull-request-github/issues/2988 +- Github.dev: Weird Default Description When Creating PR. https://github.com/microsoft/vscode-pull-request-github/issues/3316 +- New pull requests on branches off master try to merge into a wrong branch. https://github.com/microsoft/vscode-pull-request-github/issues/3303 + +## 0.36.2 + +### Fixes + +- "The current repository does not have a push remote for..." when trying to create a PR from github.dev. https://github.com/microsoft/vscode-pull-request-github/issues/3309 + +## 0.36.1 + +### Fixes + +- After making a change to a PR that was made on a fork the "Files" tree is empty. https://github.com/microsoft/vscode-pull-request-github/issues/3294 +- Commenting ranges often don't show when using github.dev or vscode.dev. https://github.com/microsoft/vscode-pull-request-github/issues/3306 + +## 0.36.0 + +### Changes + +- Pull request loading performance in vscode.dev and github.dev (and to a lesser extension, desktop VS Code) is significantly improved. As part of this: + - The "changes in pull request" tree shows faster. + - Individual file diffs are not loaded until the diff is opened. + - Comments load in with a delay. +- There is a new setting that can disable review mode for merged and/or closed pull requests. + +```json +"githubPullRequests.useReviewMode" { + "closed": false, + "merged": true +} +``` + +- Added a "Don't show again" button to the "Do you want to create a pull request" notification to better surface the associated setting. +![Notification with don't show again button](documentation/changelog/0.36.0/dont-show-button.png) +- There's a new command "Open All Diffs". +- Comments now show a timestamp using the new VS Code comment `timestamp` API. + +### Fixes + +- Markdown content escaped when editing comments. https://github.com/microsoft/vscode-pull-request-github/issues/3254 +- Many spurious user mentions inserted in text. https://github.com/microsoft/vscode-pull-request-github/issues/3257 +- Issues loading forever in folder with git not initialized. https://github.com/microsoft/vscode-pull-request-github/issues/3160 +- Not able to mention people in my code. https://github.com/microsoft/vscode-pull-request-github/issues/3251 +- Losing focus/switching tabs while a comment entered (but not submitted) loses comment. https://github.com/microsoft/vscode-pull-request-github/issues/3200 +- Comments appear being pending instead of submitted. https://github.com/microsoft/vscode-pull-request-github/issues/3232 + +**_Thank You_** + +* [@blindpirate (Bo Zhang)](https://github.com/blindpirate) + * Fix react warnings in timeline [PR #3212](https://github.com/microsoft/vscode-pull-request-github/pull/3212) + * Fix more React warnings of incorrect `props.key` [PR #3214](https://github.com/microsoft/vscode-pull-request-github/pull/3214) +* [@joshuaobrien (Joshua O'Brien)](https://github.com/joshuaobrien): Add command to open all diffs [PR #3238](https://github.com/microsoft/vscode-pull-request-github/pull/3238) + + +## 0.34.3 + +### Fixes + +- Extension causes high cpu load. https://github.com/microsoft/vscode-pull-request-github/issues/841 + +## 0.34.2 + +### Fixes + +- Being on a branch with a PR replaces my active editor. https://github.com/microsoft/vscode-pull-request-github/issues/3194 + +## 0.34.1 + +### Fixes + +- Every time I open a new instance of vscode I'm being prompted to sign in to GitHub. https://github.com/microsoft/vscode-pull-request-github/issues/3165 +- Comments disappearing after exiting review mode. https://github.com/microsoft/vscode-pull-request-github/issues/3173 + +## 0.34.0 + +### Changes + +- This extension now supports pre-releases using VS Code's built in pre-release feature. Version 0.33.0 was skipped to support this. + +![Extension pre-release support](documentation/changelog/0.34.0/pre-release-extension.png) + +- Support for opening a pull request by number. +- The default issue query now includes recent issues to help with issue completions. +- Create PR button no longer shows if there is already a PR. +- Git file status colors are now used for PR files. + +![Git change colors for PR files](documentation/changelog/0.34.0/git-change-colors.png) + +### Fixes + +- Fails to load pull requests when using ssh.github.com for ssh over https. https://github.com/microsoft/vscode-pull-request-github/issues/3052 +- vscode.dev/github.dev: Support .github/pull_request_template.md when creating a PR within vscode. https://github.com/microsoft/vscode-pull-request-github/issues/3055 +- quote reply doesn't handle nested replies. https://github.com/microsoft/vscode-pull-request-github/issues/3059 +- Don't show hover cards for @-mentioned users when related to JSDoc. https://github.com/microsoft/vscode-pull-request-github/issues/2498 +- Edit PR title, does not (auto) update title of PR in treeview (PR in list on left side). https://github.com/microsoft/vscode-pull-request-github/issues/2299 + +**_Thank You_** + +* [@38elements (38elements)](https://github.com/38elements) + * Fix typo [PR #3049](https://github.com/microsoft/vscode-pull-request-github/pull/3049) + * justSwitchedToRevieMode -> justSwitchedToReviewMode [PR #3060](https://github.com/microsoft/vscode-pull-request-github/pull/3060) +* [@burkeholland (Burke Holland)](https://github.com/burkeholland): Opening a gist repository gives a fetch error [PR #2933](https://github.com/microsoft/vscode-pull-request-github/pull/2933) +* [@emtei (Mateusz Stopka)](https://github.com/emtei): Do not activate review mode for closed or merged PR [PR #3021](https://github.com/microsoft/vscode-pull-request-github/pull/3021) +* [@taisph (Tais P. Hansen)](https://github.com/taisph): Fix generated branch name requiring quotes in bash [PR #3084](https://github.com/microsoft/vscode-pull-request-github/pull/3084) + +## 0.33.* + +- **This is a pre-release version** that uses VS Code's built-in pre-release extension support. The old nightly build (GitHub Pull Requests Nightly) is being deprecated in favor of the this built-in support. Users of the the old nightly build will be automatically upgraded to the new pre-release version; however, extension state will not be migrated. Thank you for helping make GitHub Pull Requests and Issues better! + +## 0.32.0 + +### Changes + +- Action to navigate directly to pull request query in settings.json + +![Navigate to pull request query](documentation/changelog/0.32.0/navigate-pr-query.gif) + +### Fixes + +- Cannot set branch tile when starting working on an issue despite useBranchForIssues=prompt. https://github.com/microsoft/vscode-pull-request-github/issues/3032 +- No issue hovers in code editor. https://github.com/microsoft/vscode-pull-request-github/issues/3013 +- Pending checks section is empty. https://github.com/microsoft/vscode-pull-request-github/issues/2949 + +**_Thank You_** + +* [@burkeholland (Burke Holland)](https://github.com/burkeholland) + * Fixes Commit hash should use a monospace font #2957 [PR #2968](https://github.com/microsoft/vscode-pull-request-github/pull/2968) + * Burkeholland-issue1635 [PR #2993](https://github.com/microsoft/vscode-pull-request-github/pull/2993) + * Title doesn't have enough horizontal space in small tabs [PR #3029](https://github.com/microsoft/vscode-pull-request-github/pull/3029) + * Fix 2959 - Merge title box needs top margin [PR #3039](https://github.com/microsoft/vscode-pull-request-github/pull/3039) +* [@nicholas-l (Nicholas Latham)](https://github.com/nicholas-l): Allow authentication for github enterprise [PR #3064](https://github.com/microsoft/vscode-pull-request-github/pull/3064) + +## 0.31.1 + +### Fixes + +- Don't ask for auth upon activation. https://github.com/microsoft/vscode-pull-request-github/issues/3044 + +## 0.31.0 + +### Changes + +- Variable in settings for lowercase branch names when working on and issue +```json +{ + "githubIssues.issueBranchTitle": "${sanitizedLowercaseIssueTitle}" +} +``` + +### Fixes +- issueBranchTitle not Working as Expected. https://github.com/microsoft/vscode-pull-request-github/issues/2259 +- Skipped status check is shown as pending. https://github.com/microsoft/vscode-pull-request-github/issues/2580 + +**_Thank You_** + +- fix: make pending checks "show" button / link accessible. [2972](https://github.com/microsoft/vscode-pull-request-github/pull/2972) by @moshfeu +- Fixes Tick SVG doesn't look right #2950. [2971](https://github.com/microsoft/vscode-pull-request-github/pull/2971) by @burkeholland +- Create commit select horizontal padding is too high. [2970](https://github.com/microsoft/vscode-pull-request-github/pull/2970) by @burkeholland +- Focus border is used on assignees when it's not focused. [2967](https://github.com/microsoft/vscode-pull-request-github/pull/2967) by @burkeholland +- Button sizes don't look right when PR title is wrapped. [2966](https://github.com/microsoft/vscode-pull-request-github/pull/2966) by @burkeholland +- Respect remote link type for upstream. [2929](https://github.com/microsoft/vscode-pull-request-github/pull/2929) by @burkeholland +- Fixes #2259. [2909](https://github.com/microsoft/vscode-pull-request-github/pull/2909) by @burkeholland +- Lowercase branch names. [2986](https://github.com/microsoft/vscode-pull-request-github/pull/2986) by @burkeholland +- GitHub permalinks: Deduplicate the line number if the range is one line. [2980](https://github.com/microsoft/vscode-pull-request-github/pull/2980) by @lgarron +- Skipped status check is shown as pending. [3005](https://github.com/microsoft/vscode-pull-request-github/pull/3005) by @burkeholland +- PR created x minutes ago is cut off in small tabs. [2985](https://github.com/microsoft/vscode-pull-request-github/pull/2985) by @burkeholland + +## 0.30.0 + +### Changes + +- All comments can be collapsed using the UI. + +![Collapse all comments UI button](documentation/changelog/0.30.0/comments-collapse-all.png) +- All comments can be expanded and collapsed via the commands **GitHub Pull Requests: Expand All Comments** and **GitHub Pull Requests: Collapse All Comments**. +- The expand/collapse state of pull request comments can be set with the setting `githubPullRequests.commentExpandState`. +- Adopted the new multiling string support for settings. + +![Multiline setting](documentation/changelog/0.30.0/multiline-setting.png) +- Improved the language on the sidebar view to better indicate that you can submit a review. + +### Fixes + +- Copy GitHub Permalink is missing line info. https://github.com/microsoft/vscode-pull-request-github/issues/2924 +- Old image version is not shown when viewing a checked-out pull request change for an image. https://github.com/microsoft/vscode-pull-request-github/issues/2914 +- Extension asks to create pull request when remote is not github. https://github.com/microsoft/vscode-pull-request-github/issues/2879 +- Edits to comments on PRs not being applied. https://github.com/microsoft/vscode-pull-request-github/issues/2869 +- Could not resolve conversation on old PR. https://github.com/microsoft/vscode-pull-request-github/issues/2857 +- Issue queries have the wrong owner. https://github.com/microsoft/vscode-pull-request-github/issues/2836 +- PR description should auto fill when the PR is a single commit. https://github.com/microsoft/vscode-pull-request-github/issues/2540 + +**_Thank You_** + +- Always show action bar in PR overview header. [2849](https://github.com/microsoft/vscode-pull-request-github/pull/2849) by @joshuaobrien +- When using a branch name as a PR title, ensure the first letter is capitalized. [2848](https://github.com/microsoft/vscode-pull-request-github/pull/2848) by @joshuaobrien +- Include PR template in PR description if there is just one commit. [2846](https://github.com/microsoft/vscode-pull-request-github/pull/2846) by @joshuaobrien +- Let the view know about the review status of the PR. [1833](https://github.com/microsoft/vscode-pull-request-github/pull/1833) by @RishabhKothaari + +## 0.29.2 + +### Fixes + +- Fix **Copy Permalink** commands for github.dev. https://github.com/microsoft/vscode-pull-request-github/issues/2903 + +## 0.29.1 + +### Fixes + +- Do not preload mentionable users when a PR isn't checked out. https://github.com/microsoft/vscode-pull-request-github/issues/2892 + +## 0.29.0 + +### Changes + +- "Start working" on an issue has been expanded to let you work on issues outside of the repo you currently have open. +- Improved UX for merged PRs in the sidebar view. + +![Merged PR sidebar view](documentation/changelog/0.29.0/merged-pr-sidebar.png) + +### Fixes + +- Comments can be deleted from the overview path. https://github.com/microsoft/vscode-pull-request-github/issues/2855 +- Comments no longer get stuck "pending". https://github.com/microsoft/vscode-pull-request-github/issues/2822 +- Comments are visible in diffs opened from the commits file view. https://github.com/microsoft/vscode-pull-request-github/issues/2688 +- "Webview is disposed" error is fixed. https://github.com/microsoft/vscode-pull-request-github/issues/2444 + +**_Thank You_** + +- Fix check fork details [#2806](https://github.com/microsoft/vscode-pull-request-github/pull/2806) by @binsee +- chore: replace deprecated vscode-test with @vscode/test-electron [#2842](https://github.com/microsoft/vscode-pull-request-github/pull/2842) by @iChenLei + +## 0.28.0 + +### Changes + +- Status bar entries use the new `createStatusBarItem` API to show better names. + +![Status bar name](documentation/changelog/0.28.0/status-bar-name.png) + + +### Fixes + +- Placeholder avatar is shown correctly with GitHub Enterprise. https://github.com/microsoft/vscode-pull-request-github/issues/2771 +- Error messages no longer shown when there's a non-GitHub remote. https://github.com/microsoft/vscode-pull-request-github/issues/2768 +- Copying a permalink doesn't require sign in. https://github.com/microsoft/vscode-pull-request-github/issues/2761 +- There's a new timeout for listing branches so that repositories with many branches don't hang. https://github.com/microsoft/vscode-pull-request-github/issues/2753 +- "There are updates" notification is shown less often and can be permanently hidden. https://github.com/microsoft/vscode-pull-request-github/issues/2628 +- [And more!](https://github.com/microsoft/vscode-pull-request-github/issues?q=is%3Aissue+label%3Abug+milestone%3A%22June+2021%22+is%3Aclosed) + +**_Thank You_** + +- Fix broken pull request files pagination [#2766](https://github.com/microsoft/vscode-pull-request-github/pull/2766) by @habibm + +## 0.27.1 + +- Fix links in the readme for the marketplace: https://github.com/microsoft/vscode-pull-request-github/issues/2769 + +## 0.27.0 + +- Thanks to a community PR from @kabel, there is support for GitHub Enterprise. See [the discussion in the PR](https://github.com/microsoft/vscode/pull/115940) for how to get set up. + +## 0.26.0 +- Files that have been marked as viewed on GitHub show a checkmark decoration in the pull request tree. Each file in the tree has an inline action for marking it as viewed or not viewed. + +![Hovering over a file in the pull request tree, an inline action to mark the file as viewed appears. When marked as viewed, the file is updated with a checkmark decoration and the action changes to mark as not viewed ](documentation/changelog/0.26.0/mark-as-viewed.gif) + +- Conversations can be marked as resolved within the editor. Resolved conversations collapse by default. + +-![The comment editor widget in a file. The comment thread has actions to "Resolve Conversation" and "Add Comment".](documentation/changelog/0.26.0/resolve-conversation.png) + +- The commented decoration in tree views has been updated to a speech bubble for clarity. + +-![The pull request tree, showing a file decorated with a speech bubble indicating it has been commented on.](documentation/changelog/0.26.0/file-decoration.png) + +- The "Delete Review" and "Finish Review" buttons have been removed from the comment widget. Instead there is a "Go to Review" button which shows all comments in the pending review and allows choosing how to submit the review, as an approval or requesting changes. +- The "All" category of the pull request tree is expanded by default. +- Thanks to @icatalina, there is a new `githubPullRequests.terminalLinksHandler` setting to choose how create pull request links should be handled from the terminal. + +## 0.25.1 + +### Fixes + +- Fixed vulnerability described in [CVE-2021-28470](https://msrc.microsoft.com/update-guide/vulnerability/CVE-2021-28470) + +## 0.25.0 + +### Changes +- For forked repositories, an upstream remote is automatically added if it doesn't exist. +- The `githubPullRequests.focusedMode` setting is enabled by default, causing the "Focused Mode" view for a checked out pull request to be visible in the Pull Request view container. + +- Thanks to @TanushN and @rohanshiva, the pull request description page now supports adding and removing assignees and milestones. +![A button next to the milestone marker is pressed and a list of milestone options is shown. Choosing a milestone updates the page.](documentation/changelog/0.25.0/update_milestone.gif) + +- The pull request create view now allows selecting both the source and target branch instead of always using the current branch as the source. + +![The create pull request view, with two sections at the top labeled "Merge changes from" and "Into".](documentation/changelog/0.25.0/create_pull_request.png) + +### Fixes + +- Using ${user} twice in the `githubPullRequests.queries` no longer results in an error. +- Local branches are pruned when deleting the remote branch after merging a pull request. + +## 0.24.0 + +- The views of the extension have been split into two viewlets: one with the GitHub logo which contains the overview of pull requests, and issues, and one containing views for the active pull request. + + ![Split activity bar views](documentation/changelog/0.24.0/activity_bar_viewlets.png) + +- The focused mode view has actions to refresh the view and open the description page. + ![Focused mode acions](documentation/changelog/0.24.0/focused_mode_actions.png) + +- Thanks to @TanushN and @rohanshiva, assignees and the milestone are displayed on the description page. + ![View assignees and milestone](documentation/changelog/0.24.0/assignees_and_milestone.png) + +- Resolved conversations are rendered as collapsed on the description page. + ![Resolved conserversations](documentation/changelog/0.24.0/resolved_conversations.png) + +## 0.23.1 + +### Fixes + +- Update the new "Create Pull Request" icon to be a sharper image. + +## 0.23.0 + +### Changes + +- Creating a pull request now uses views in the activity bar. You can enter the details of the pull request and view the diff between the current branch and the base branch. + ![Create a pull request](documentation/changelog/0.23.0/new-create-experience.gif) + +- When you publish a branch using the terminal, clicking on the resulting link can also be used to start the create pull request flow. + ![Create from terminal link](documentation/changelog/0.23.0/create-from-terminal.gif) + +- The command to create a pull request now also appears in the SCM view. + +- Add a command to copy a GitHub permalink as markdown. + ![Copy permalink as markdown](documentation/changelog/0.23.0/copy-as-markdown.gif) + +### Fixes + +- Thanks to @IllusionMH, timestamps in the commit list do not text wrap. + +## 0.22.0 + +### Changes + +- Colored icons are used in the issues view.
+ ![Issues view](documentation/changelog/0.22.0/colored-icons.png) + +- File changes in the Pull Request view are expanded by default. + +- The timestamp in the pull request description has hover with the exact time. + ![Timestamp with hover](documentation/changelog/0.22.0/exact-time-hover.png) + +- There's a new command to "Leave Review Mode". It checks-out the default branch for the repository and remove the comment UI, just like the "Leave Review mode" button in the pull request description. + +- The "Description" node in the Pull Request tree has an inline action to checkout the pull request. + +- The Pull Request view lists the PR number first in the title, and also moves the "Draft" indication to be more prominent. + ![Pull Request view](documentation/changelog/0.22.0/pull-request-tree-view.png) + +- Thanks to @ejkim1996, there is an action to toggle between viewing files in a tree structure and viewing them as a flat list in the Changes in Pull Request view.
+ ![Toggle view layout](documentation/changelog/0.22.0/toggle-layout.png) + +- When a pull request is checked out, opening a file associated with it will automatically reveal this it in the CHanges in Pull Request view. + +### Notable fixes + +- Clicking on a comment link on the pull request description page now always opens the associated file. + +- Thanks to @KaiSforza, the `The remote is not a GitHub repository.` error is now surpressed for remotes outside of those set in `githubPullRequests.remotes`. + +## 0.21.4 + +- Adopt latest API changes for TreeItem in VS Code 1.52.0 + +## 0.21.3 + +- Adopt latest API changes for VS Code 1.52.0. + +## 0.21.2 + +- Revert previous changes for proposed API changes since the minimum engine version is currently set to 1.50.0. + +## 0.21.1 + +- Adopt latest proposed API changes. + +## 0.21.0 + +### Changes + +- GitHub permalinks in issue hovers for files that exist in your current workspace will be opened locally. + ![Open Code Link](documentation/changelog/0.21.0/openCodeLink.gif) + +- We are trying out a new "focused review" mode which changes the layout when you checkout a PR. You can try it with the `githubPullRequests.focusedMode` setting. + ![Focused Review Mode](documentation/changelog/0.21.0/focusedReview.png) + +- Issue suggestions are now shown in code comment editors. + +![Issue suggest in comment](documentation/changelog/0.21.0/issue-suggest-comment.png) + +- When you Start Working on an issue, it will be assigned to you if you permissions in the repository allow it. + +- Thanks to @rishab-pdx, the description page of the pull request now has an action to copy the link to the PR. + +### Notable fixes + +- Instead of showing two login buttons (one each in the Pull Requests and Issues views), we now show a login view. + +![Login View](documentation/changelog/0.21.0/loginView.png) + +- Editor tab titles are now shortened to the file name when viewing diffs from a pull request, and are suffixed with `(Pull Request)`. + +## 0.20.1 + +- Fixes loading the extension in VSCode Insider's 1.50 + +## 0.20.0 + +### Changes + +- Pull requests and issues now follows a multi-repo model. This means that if you have folders in your workspace from different repositories you'll see the issues and pull requests from the repositories at the same time. You can also have pull requests checked out from multiple repositories. + +![Multi-repo](documentation/changelog/0.20.0/multirepo.png) + +- There is new setting, `githubPullRequests.pullRequestDescription`, to control how the description is set when creating a pull request. By default, this uses the pull request template in the repository if it exists, but can also be changed to use the latest commit message, a custom message input at create time, or to ask which of these options to use. Thank you to @heaths for this feature! + +### Notable fixes + +- GitHub Action based status checks are now rendered on the description page. +- Commenting on stale pull requests is better handled. + ## 0.19.0 ### Changes @@ -32,14 +2099,17 @@ - The Issues view now shows the markdown card on hover. ![Tree Hover](documentation/changelog/0.18.0/tree-hover.gif) + - Label suggestions are provided in the new issue editor. ![Label Suggestion](documentation/changelog/0.18.0/label-suggestion.gif) + - Hovers work for issues formatted as GH-123. - The `githubIssues.issueBranchTitle` setting can take the new variable `${sanitizedIssueTitle}`. - If you have uncommitted changes in your issue branch when you try to create a pull request, you'll be prompted to commit them. ![Commit Prompt](documentation/changelog/0.18.0/commit-prompt.png) + - Closed issues in the Issues view use the closed icon. - The Pull Requests and Issues views use the visually appealing welcome mechanism to display the Sign in button. @@ -57,11 +2127,13 @@ - Improved display of labels in issue hovers by swapping out the existing svg version of the labels for a newly supported html version. ![Markdown labels](documentation/changelog/0.17.0/markdown-label.png) + - When creating a GitHub permalink, we now try to pick the "best" remote if the currently opened repository has several remotes. The priority of remote is `upstream`, `ups`, `origin`, then other names. The first remote from that ordering which contains the same commit that the current branch is on will be used in the permalink. - Issue creation now better takes into account the file that you're coming from when you create an issue instead of only listening for repository change when a different repository is selected in the SCM view. - The new issue editor now lets you edit the title, assignees, labels, and description. ![New issue editor](documentation/changelog/0.17.0/new-issue-editor.gif) + - User and issue completions now only show in plaintext and in comments! No more noisy issue suggestions when you trigger suggest in code. ### Notable fixes @@ -74,9 +2146,10 @@ ## 0.16.0 -We have been working on a new set of GitHub issue related features. Take a look at our [documentation](https://github.com/microsoft/vscode-pull-request-github/blob/master/documentation/IssueFeatures.md) for more details on these and how to use them! +We have been working on a new set of GitHub issue related features. Take a look at our [documentation](https://github.com/microsoft/vscode-pull-request-github/blob/main/documentation/IssueFeatures.md) for more details on these and how to use them! ### Changes + - New issues view that supports custom queries - User and issue completions in editors, which can be configured per language or disabled - Hover for issues and users in the editor @@ -92,11 +2165,13 @@ We have been working on a new set of GitHub issue related features. Take a look - Update dependencies ### Bug fixes + - Remove nonstandard anyways [1602](https://github.com/microsoft/vscode-pull-request-github/pull/1602) by @curtisgibby ## 0.15.0 ### Bug fixes + - Update remotes setting description [1478](https://github.com/microsoft/vscode-pull-request-github/issues/1478) - Fix display of review comment summary on description page [1486](https://github.com/microsoft/vscode-pull-request-github/issues/1486) - Fix creation of PRs when source branch is not listed in remotes setting [1326](https://github.com/microsoft/vscode-pull-request-github/issues/1326) @@ -107,9 +2182,11 @@ We have been working on a new set of GitHub issue related features. Take a look ## 0.14.0 ### Changes + - Remove branching logic for graphql [1431](https://github.com/microsoft/vscode-pull-request-github/issues/1431) ### Bug fixes + - Set 'authServer' query parameter when opening login url [1440](https://github.com/microsoft/vscode-pull-request-github/issues/1440) - Prevent reflow of PR title on hover [1450](https://github.com/microsoft/vscode-pull-request-github/issues/1450) - Show rendered markdown after editing on description page [1457](https://github.com/microsoft/vscode-pull-request-github/issues/1457) @@ -121,6 +2198,7 @@ We have been working on a new set of GitHub issue related features. Take a look We saw a huge amount of community participation this release, thank you so much to everyone who contributed! ### Changes + - Option to view changed files in a tree instead of a list [653](https://github.com/microsoft/vscode-pull-request-github/issues/653) by @yulrizka - Allow choosing title when creating pull request [906](https://github.com/microsoft/vscode-pull-request-github/issues/906) by @nminchow - Add context menu actions to open original/modified file [35](https://github.com/microsoft/vscode-pull-request-github/issues/35) by @anoadragon453 and @Tunous @@ -131,6 +2209,7 @@ We saw a huge amount of community participation this release, thank you so much - Clicking status bar item brings up configuration actions [360](https://github.com/microsoft/vscode-pull-request-github/issues/360) ### Bug fixes + - Sign-out picker: do not close on focus lost [1348](https://github.com/microsoft/vscode-pull-request-github/issues/1348) - Align "Request Changes" and "Approve" by comment [297](https://github.com/microsoft/vscode-pull-request-github/issues/297) by @guilherme-gm - Align commit hash in PR description and avoid breaking username on hyphen [1353](https://github.com/microsoft/vscode-pull-request-github/issues/1353) by @JeffreyCA @@ -175,21 +2254,25 @@ We saw a huge amount of community participation this release, thank you so much ## 0.11.2 ### Bug fixes + - Comments sometimes duplicated on diff files from pull request tree [#1337](https://github.com/microsoft/vscode-pull-request-github/issues/1337) ## 0.11.1 ### Bug fixes + - Pull request tree incorrectly shows 'No git repositories found' [#1334](https://github.com/microsoft/vscode-pull-request-github/issues/1334) ## 0.11.0 ### Changes + - Add support for deleting a PR after close or merge [#350](https://github.com/microsoft/vscode-pull-request-github/issues/350) - Update GraphQL timeline event query - Update dependencies ### Bug fixes + - Fix formatting of GitHub errors [#1298](https://github.com/microsoft/vscode-pull-request-github/issues/1298) - Show error when user doesn't have permission to push to remote [#1299](https://github.com/microsoft/vscode-pull-request-github/issues/1299) - Remove usage of rootPath and fix suggested edits [#1312](https://github.com/microsoft/vscode-pull-request-github/issues/1312) @@ -202,6 +2285,7 @@ We saw a huge amount of community participation this release, thank you so much ## 0.1## 0.0 ### Changes + - Use pull request template when creating PR [#798](https://github.com/microsoft/vscode-pull-request-github/issues/798) - Improvements to pull request tree display message when loading or not signed in (#1269)(https://github.com/microsoft/vscode-pull-request-github/issues/1269) - Change telemetry library to application insights [#1264)(https://github.com/microsoft/vscode-pull-request-github/issues/1264) @@ -209,6 +2293,7 @@ We saw a huge amount of community participation this release, thank you so much - Update dependencies ### Bug fixes + - Set a default array for `githubPullRequests.remotes` [#1289](https://github.com/microsoft/vscode-pull-request-github/issues/1289) - Fix spacing between textbox and buttons on description page [#1287](https://github.com/microsoft/vscode-pull-request-github/issues/1287) - Fix updates to comment thread cache when creating the first comment in a thread [#1282](https://github.com/microsoft/vscode-pull-request-github/pull/1282) @@ -217,30 +2302,35 @@ We saw a huge amount of community participation this release, thank you so much - Fix error when trying to add reviewers on description page [#1181](https://github.com/microsoft/vscode-pull-request-github/issues/1181) - Enable removing a label on description page [#1258](https://github.com/microsoft/vscode-pull-request-github/issues/1258) -***Thank You*** +**_Thank You_** + - Description view of PR: make whole commit line clickable [#1259](https://github.com/microsoft/vscode-pull-request-github/issues/1259) by @tobudim - Sort description view labels case sensitively [#1008](https://github.com/microsoft/vscode-pull-request-github/issues/1008) by @haryps ## 0.9.0 ### Changes + - Adopt new commenting api [#1168](https://github.com/microsoft/vscode-pull-request-github/issues/1168) - Set tree selection on tree node when clicking on an inline action [1245](https://github.com/microsoft/vscode-pull-request-github/issues/1245) ### Bug fixes + - Command palette sign in not working [#1213](https://github.com/microsoft/vscode-pull-request-github/issues/1213) - Limit comment areas on the base side of diff editors to deleted lines [#153](https://github.com/microsoft/vscode-pull-request-github/issues/153) -- Ensure extension activates when ssh config contains "Host *+*" [#1255](https://github.com/microsoft/vscode-pull-request-github/issues/1255) +- Ensure extension activates when ssh config contains "Host _+_" [#1255](https://github.com/microsoft/vscode-pull-request-github/issues/1255) - Update wording of sign in notification [#757](https://github.com/microsoft/vscode-pull-request-github/issues/757) - Address @octokit/rest deprecations [#1227](https://github.com/microsoft/vscode-pull-request-github/issues/1255) ## 0.8.0 ### Changes + - Add Draft PR support [#1129](https://github.com/microsoft/vscode-pull-request-github/issues/1129) -- Support specifying custom category in PR tree view [#1106](https://github.com/microsoft/vscode-pull-request-github/issues/1106) +- Support specifying custom category in PR tree view [#1106](https://github.com/microsoft/vscode-pull-request-github/issues/1106) ### Bug fixes + - Do not show commands before extension has fully activated [#1198](https://github.com/microsoft/vscode-pull-request-github/issues/1198) - Description page is blank after ## 0.7.0 upgrade [#1175](https://github.com/microsoft/vscode-pull-request-github/issues/1175) - Change "wants to merge" text when viewing merged PR [#1027](https://github.com/microsoft/vscode-pull-request-github/issues/1027) @@ -250,35 +2340,38 @@ We saw a huge amount of community participation this release, thank you so much - Display a message before opening file diffs in the browser [#442](https://github.com/microsoft/vscode-pull-request-github/issues/442), thank you [@malwilley](https://github.com/malwilley)! - Swap order of PR branch and target branch [#784](https://github.com/microsoft/vscode-pull-request-github/issues/784) - ## 0.7.0 **Engineering** + - Description view is now rendered with React [#1096](https://github.com/microsoft/vscode-pull-request-github/pull/1096) - Performance improvement - [Decrease time to enter review mode](https://github.com/microsoft/vscode-pull-request-github/pull/1131) ### Bug fixes + - Pull Request Tree GitHub Enterprise Avatars [#1121](https://github.com/microsoft/vscode-pull-request-github/pull/1121) - Unable to expand PRs whose branch contains a # character [#1059](https://github.com/microsoft/vscode-pull-request-github/issues/1059) ## 0.6.1 ### Bug fixes + - Fix git onDidOpenRepository eventing [#1122](https://github.com/Microsoft/vscode-pull-request-github/pull/1122) ## 0.6.0 ### Changes + - Introduce new comment provider API [#972](https://github.com/Microsoft/vscode-pull-request-github/pull/972) - Fix rendering of checkboxes on description page [#1038](https://github.com/Microsoft/vscode-pull-request-github/issues/1038) - Fix [#1082](https://github.com/Microsoft/vscode-pull-request-github/issues/1082) to make sure commands are scoped to the correct tree - Fix [#291](https://github.com/Microsoft/vscode-pull-request-github/issues/291) to use monospace font for code blocks on description page - ## 0.5.1 ### Changes + - Fix description not displaying after creating a pull request [#1041](https://github.com/Microsoft/vscode-pull-request-github/pull/1041) ## 0.5.0 @@ -288,6 +2381,7 @@ We saw a huge amount of community participation this release, thank you so much - From 0.5.0, you at least need VSCode 1.32 to install and run the extension. ### Changes + - Add support for reacting to comments [#46](https://github.com/Microsoft/vscode-pull-request-github/issues/46) - Display reviewers on the description page, and support adding and removing them [#412](https://github.com/Microsoft/vscode-pull-request-github/issues/412) - Support adding and removing labels from the description page [#933](https://github.com/Microsoft/vscode-pull-request-github/issues/933) @@ -296,10 +2390,12 @@ We saw a huge amount of community participation this release, thank you so much - Fix [#912](https://github.com/Microsoft/vscode-pull-request-github/issues/912), approving the PR scrolls to the top of the page **New settings** + - `githubPullRequests.showInSCM` can be used to configure where the `GitHub Pull Requests` tree is shown, either the new GitHub viewlet or -the SCM viewlet where it was before. By default, the tree is shown in the GitHub viewlet. + the SCM viewlet where it was before. By default, the tree is shown in the GitHub viewlet. **Thank You** + - Disable not available merge methods [#946](https://github.com/Microsoft/vscode-pull-request-github/pull/946) by @IllusionMH - Fix LGTM.com alerts [#948](https://github.com/Microsoft/vscode-pull-request-github/pull/948) by @samlanning @@ -310,6 +2406,7 @@ the SCM viewlet where it was before. By default, the tree is shown in the GitHub - From 0.4.0, you at least need VSCode 1.31 to install and run the extension. ### Changes + - Display mergeability on PR description page [#773](https://github.com/Microsoft/vscode-pull-request-github/pull/773) - Add an inline action to open the description to the side [#310](https://github.com/Microsoft/vscode-pull-request-github/issues/310) - Add refresh button to the description page [#771](https://github.com/Microsoft/vscode-pull-request-github/pull/771) @@ -325,16 +2422,19 @@ the SCM viewlet where it was before. By default, the tree is shown in the GitHub As part of this release, we have begun adopting GitHub's GraphQL API in some places to support new features and improve performance. The reviews feature and linkifying of issue references depend on the GraphQL API. This API is not available for GitHub Enterprise, so please note that these features will not be available for those projects. **New settings** + - `githubPullRequests.remotes` can be used to configure what remotes pull requests should be displayed from. By default, the extension will look for `upstream` and `origin` remotes -by convention and fetch from these, or will fetch from any other remotes if these are not present. + by convention and fetch from these, or will fetch from any other remotes if these are not present. - `githubPullRequests.defaultMergeMethod` specifies which merge method (`merge`, `rebase`, or `squash`) to select by default, both on the description page and when using the `Merge Pull Request` command. -This is set to `merge` by default. + This is set to `merge` by default. **Thank You** -* Fix log output [PR #804](https://github.com/Microsoft/vscode-pull-request-github/pull/804) by @Ikuyadeu -* Update setting description to use non deprecated key [PR #916](https://github.com/Microsoft/vscode-pull-request-github/pull/916) by @mkonikov + +- Fix log output [PR #804](https://github.com/Microsoft/vscode-pull-request-github/pull/804) by @Ikuyadeu +- Update setting description to use non deprecated key [PR #916](https://github.com/Microsoft/vscode-pull-request-github/pull/916) by @mkonikov ## 0.3.2 + - Honor the new `git.openDiffOnClick` setting [#753](https://github.com/Microsoft/vscode-pull-request-github/pull/753) - Prompt to stage all changes when running the suggest changes with nothing staged [#744](https://github.com/Microsoft/vscode-pull-request-github/pull/744) - Add an inline "Open File" action in the "Changes in Pull Request" tree and match styling of git changes [#738](https://github.com/Microsoft/vscode-pull-request-github/pull/738) @@ -345,6 +2445,7 @@ This is set to `merge` by default. - Fix [#728](https://github.com/Microsoft/vscode-pull-request-github/issues/738), keep description page data up to date when updating title and description ## 0.3.1 + - Add status check information on PR description page [#713](https://github.com/Microsoft/vscode-pull-request-github/pull/713) - Add button for creating a pull request on PR tree view [#709](https://github.com/Microsoft/vscode-pull-request-github/pull/709) - Add "Suggest Edit" command [#688](https://github.com/Microsoft/vscode-pull-request-github/pull/688) @@ -360,12 +2461,13 @@ This is set to `merge` by default. **Thank You** -* [Jeffrey (@JeffreyCA)](https://github.com/JeffreyCA) - * Correct timestamp format [PR #686](https://github.com/Microsoft/vscode-pull-request-github/pull/686) - * Render Markdown line breaks as
[PR #679](https://github.com/Microsoft/vscode-pull-request-github/pull/679) - * Support absolute and relative timestamps [PR #644](https://github.com/Microsoft/vscode-pull-request-github/pull/644) +- [Jeffrey (@JeffreyCA)](https://github.com/JeffreyCA) + - Correct timestamp format [PR #686](https://github.com/Microsoft/vscode-pull-request-github/pull/686) + - Render Markdown line breaks as
[PR #679](https://github.com/Microsoft/vscode-pull-request-github/pull/679) + - Support absolute and relative timestamps [PR #644](https://github.com/Microsoft/vscode-pull-request-github/pull/644) ## 0.2.3 + - Fix [#607], read `~/.ssh/config` to resolve hosts - Fix [#572], by [@yoh1496], add support for GitHub Enterprise behind a proxy - Fix [#658], ensure correct button enablement when reloading pending comment from cache @@ -403,6 +2505,7 @@ This is set to `merge` by default. - Fix for native promise polyfill removal from VSCode extension host in Insiders 1.29 ## 0.1.6 + - Fix for [#500](https://github.com/Microsoft/vscode-pull-request-github/issues/500) and [#440](https://github.com/Microsoft/vscode-pull-request-github/issues/440), more lenient remote parsing - Fix for [#383](https://github.com/Microsoft/vscode-pull-request-github/issues/383), move to github.com domain for the authentication server - Fix for [#498](https://github.com/Microsoft/vscode-pull-request-github/issues/498), make sure comments gets updated on refresh event @@ -410,17 +2513,21 @@ This is set to `merge` by default. - FIx for [#507](https://github.com/Microsoft/vscode-pull-request-github/issues/507), loosen scope restrictions for older version of GitHub Enterprise ## 0.1.5 + - Fix for [#449](https://github.com/Microsoft/vscode-pull-request-github/issues/449), authentication blocked when `docs-article-templates` extension is installed - Fix for [#429](https://github.com/Microsoft/vscode-pull-request-github/issues/429), avoid unneccessary refreshes of the tree view ## 0.1.4 + - Do not ship `.vscode-test/**` files ## 0.1.3 + - Fix for [#382](https://github.com/Microsoft/vscode-pull-request-github/issues/382), authentication on enterprise servers without a `/rate_limit` path - Fix for [#419](https://github.com/Microsoft/vscode-pull-request-github/issues/419), improve parsing of git remotes and show a warning if parse fails ## 0.1.2 + - Fix for [#395](https://github.com/Microsoft/vscode-pull-request-github/issues/395), tree view not shown when the extension failed to parse a remote - [#399](https://github.com/Microsoft/vscode-pull-request-github/issues/399), use `badge.foreground` color for PR status badge - Fix for [#380](https://github.com/Microsoft/vscode-pull-request-github/issues/380), HTML content in diff on the overview was unescaped diff --git a/README.md b/README.md index a9be643d8d..a30bc22998 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ -[![Build Status](https://rebornix.visualstudio.com/Pull%20Request/_apis/build/status/Pull%20Request%20Build?branchName=master)](https://rebornix.visualstudio.com/Pull%20Request/_build/latest?definitionId=5&branchName=master) +[![Build Status](https://dev.azure.com/vscode/vscode-pull-request-github/_apis/build/status/vscode-pull-request-github%20%28pr%29?branchName=main)](https://dev.azure.com/vscode/vscode-pull-request-github/_build?definitionId=44&branchName=main) > Review and manage your GitHub pull requests and issues directly in VS Code This extension allows you to review and manage GitHub pull requests and issues in Visual Studio Code. The support includes: -- Authenticating and connecting VS Code to GitHub. + +- Authenticating and connecting VS Code to GitHub and GitHub Enterprise. - Listing and browsing PRs from within VS Code. - Reviewing PRs from within VS Code with in-editor commenting. - Validating PRs from within VS Code with easy checkouts. @@ -19,18 +20,20 @@ This extension allows you to review and manage GitHub pull requests and issues i ![Issue Demo](.readme/issueDemo.gif) # Getting Started + It's easy to get started with GitHub Pull Requests for Visual Studio Code. Simply follow these steps to get started. -1. Make sure you have VSCode version 1.27.0 or higher. -1. Download the extension from [the marketplace](https://aka.ms/vscodepr-download). -1. Reload VS Code after the installation (click the reload button next to the extension). -1. Open your desired GitHub repository. -1. If you're using version 0.5.0 of the extension or higher, a new viewlet should be added to the bottom of the activity bar. For older versions, the `GitHub Pull Requests` treeview will appear in the SCM Viewlet. +1. Install the extension from within VS Code or download it from [the marketplace](https://aka.ms/vscodepr-download). +1. Open your desired GitHub repository in VS Code. +1. A new viewlet will appear on the activity bar which shows a list of pull requests and issues. +1. Use the button on the viewlet to sign in to GitHub. 1. You may need to configure the `githubPullRequests.remotes` setting, by default the extension will look for PRs for `origin` and `upstream`. If you have different remotes, add them to the remotes list. -1. A notification should appear asking you to sign in to GitHub; follow the directions to authenticate. 1. You should be good to go! +Check out https://www.youtube.com/watch?v=LdSwWxVzUpo for additional getting started tips! + # Configuring the extension + There are several settings that can be used to configure the extension. As mentioned above, `githubPullRequests.remotes` is used to specify what remotes the extension should try to fetch pull requests from. @@ -65,6 +68,7 @@ Queries use [GitHub search syntax](https://help.github.com/en/articles/understan To view additional settings for the extension, you can open VS Code settings and search for "github pull requests". # Issues + This extension is still in development, so please refer to our [issue tracker for known issues](https://github.com/Microsoft/vscode-pull-request-github/issues), and please contribute with additional information if you encounter an issue yourself. ## Questions? Authentication? GitHub Enterprise? @@ -73,10 +77,9 @@ See our [wiki](https://github.com/Microsoft/vscode-pull-request-github/wiki) for ## Contributing -[![Total alerts](https://img.shields.io/lgtm/alerts/g/Microsoft/vscode-pull-request-github.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/Microsoft/vscode-pull-request-github/alerts/) - If you're interested in contributing, or want to explore the source code of this extension yourself, see our [contributing guide](https://github.com/Microsoft/vscode-pull-request-github/wiki/Contributing), which includes: - - [How to Build and Run](https://github.com/Microsoft/vscode-pull-request-github/wiki/Contributing#build-and-run) - - [Architecture](https://github.com/Microsoft/vscode-pull-request-github/wiki/Contributing#architecture) - - [Making Pull Requests](https://github.com/Microsoft/vscode-pull-request-github/wiki/Contributing#pull-requests) - - [Code of Conduct](https://github.com/Microsoft/vscode-pull-request-github/wiki/Contributing#code-of-conduct) + +- [How to Build and Run](https://github.com/Microsoft/vscode-pull-request-github/wiki/Contributing#build-and-run) +- [Architecture](https://github.com/Microsoft/vscode-pull-request-github/wiki/Contributing#architecture) +- [Making Pull Requests](https://github.com/Microsoft/vscode-pull-request-github/wiki/Contributing#pull-requests) +- [Code of Conduct](https://github.com/Microsoft/vscode-pull-request-github/wiki/Contributing#code-of-conduct) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..f7b89984f0 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,41 @@ + + +## Security + +Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). + +If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)), please report it to us as described below. + +## Reporting Security Issues + +**Please do not report security vulnerabilities through public GitHub issues.** + +Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report). + +If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/en-us/msrc/pgp-key-msrc). + +You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). + +Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: + + * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) + * Full paths of source file(s) related to the manifestation of the issue + * The location of the affected source code (tag/branch/commit or direct URL) + * Any special configuration required to reproduce the issue + * Step-by-step instructions to reproduce the issue + * Proof-of-concept or exploit code (if possible) + * Impact of the issue, including how an attacker might exploit the issue + +This information will help us triage your report more quickly. + +If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs. + +## Preferred Languages + +We prefer all communications to be in English. + +## Policy + +Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/en-us/msrc/cvd). + + \ No newline at end of file diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt deleted file mode 100644 index 684f89c5eb..0000000000 --- a/ThirdPartyNotices.txt +++ /dev/null @@ -1,405 +0,0 @@ -VS Code Extension for Managing GitHub Pull Requests - -THIRD-PARTY SOFTWARE NOTICES AND INFORMATION -Do Not Translate or Localize - -This project incorporates components from the projects listed below. The original copyright notices and the licenses under which Microsoft received such components are set forth below. Microsoft reserves all rights not expressly granted herein, whether by implication, estoppel or otherwise. - -%% GitHub/VisualStudio NOTICES AND INFORMATION BEGIN HERE -========================================= -Copyright (c) GitHub 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. -========================================= -END OF GitHub/VisualStudio NOTICES AND INFORMATION - - -%% octokit/rest.js NOTICES AND INFORMATION BEGIN HERE -========================================= -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. -========================================= -END OF octokit/rest.js NOTICES AND INFORMATION - - -%% parro-it/git-credential-node NOTICES AND INFORMATION BEGIN HERE -========================================= -Copyright (C) 2017 Andrea Parodi - -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. -========================================= -END OF parro-it/git-credential-node NOTICES AND INFORMATION - - -%% markdown-it/markdown-it NOTICES AND INFORMATION BEGIN HERE -========================================= -Copyright (c) 2014 Vitaly Puzrin, Alex Kocharin. - -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. -========================================= -END OF markdown-it/markdown-it NOTICES AND INFORMATION - - -%% moment/moment NOTICES AND INFORMATION BEGIN HERE -========================================= -Copyright (c) JS Foundation 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. -========================================= -END OF moment/moment NOTICES AND INFORMATION - - -%% raszi/node-tmp NOTICES AND INFORMATION BEGIN HERE -========================================= -The MIT License (MIT) - -Copyright (c) 2014 KARASZI István - -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. -========================================= -END OF raszi/node-tmp NOTICES AND INFORMATION - - -%% Microsoft/vscode-extension-vscode NOTICES AND INFORMATION BEGIN HERE -========================================= -vscode-extension-vscode - -The MIT License (MIT) - -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. -========================================= -END OF Microsoft/vscode-extension-vscode NOTICES AND INFORMATION - - -%% mcecot/markdown-it-checkbox NOTICES AND INFORMATION BEGIN HERE -========================================= -The MIT License (MIT) - -Copyright (c) 2015 Markus Cecot - -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. -========================================= -END OF mcecot/markdown-it-checkbox NOTICES AND INFORMATION - - -%% omnidan/node-emoji NOTICES AND INFORMATION BEGIN HERE -========================================= -The MIT License (MIT) - -Copyright (c) 2014 Daniel Bugl - -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. -========================================= -END OF omnidan/node-emoji NOTICES AND INFORMATION - - -%% bitinn/node-fetch NOTICES AND INFORMATION BEGIN HERE -========================================= -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. -========================================= -END OF bitinn/node-fetch NOTICES AND INFORMATION - - -%% stefanpenner/es6-promise NOTICES AND INFORMATION BEGIN HERE -========================================= -Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and 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. -========================================= -END OF stefanpenner/es6-promise NOTICES AND INFORMATION - - -%% svbergerem/markdown-it-sanitizer NOTICES AND INFORMATION BEGIN HERE -========================================= -The MIT License (MIT) - -Copyright (c) 2015 Steffen van Bergerem - -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. -========================================= -END OF svbergerem/markdown-it-sanitizer NOTICES AND INFORMATION - -%% component/debounce NOTICES AND INFORMATION BEGIN HERE -========================================= -The MIT License (MIT) - -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. -========================================= -END OF component/debounce NOTICES AND INFORMATION - -%% dotnil/ssh-config NOTICES AND INFORMATION BEGIN HERE -========================================= -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. -========================================= -END OF dotnil/ssh-config NOTICES AND INFORMATION - -%% facebook/react NOTICES AND INFORMATION BEGIN HERE -========================================= -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. -========================================= -END OF facebook/react NOTICES AND INFORMATION \ No newline at end of file diff --git a/azure-pipeline.nightly.yml b/azure-pipeline.nightly.yml index 988170753b..68099f3613 100644 --- a/azure-pipeline.nightly.yml +++ b/azure-pipeline.nightly.yml @@ -2,41 +2,92 @@ trigger: none pr: none -jobs: -- job: nightly_release - displayName: Nightly Release - pool: - name: Hosted macOS - steps: - - - template: scripts/ci/common-setup.yml - - - script: yarn run test - displayName: Run test suite - env: - TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/test-results.xml - - - script: yarn global add vsce - displayName: Install vsce - - - script: > - node ./scripts/prepare-nightly-build.js - -v "$VERSION" -i "$NAME" -n "$DISPLAYNAME" -d "$DESCRIPTION" -p "$PUBLISHER" - displayName: Generate package.json - env: - NAME: vscode-pull-request-github-insiders - DISPLAYNAME: GitHub Pull Request Nightly Build - DESCRIPTION: Nightly Build GitHub Pull Request extension - PUBLISHER: GitHub - - - script: | - mv ./package.json ./package.json.bak - mv ./package.insiders.json ./package.json - mv ./README.md ./README.md.bak - mv ./README.insiders.md ./README.md - mv ./src/constants.ts ./src/constants.ts.bak - mv ./src/constants.insiders.ts ./src/constants.ts - vsce publish -p "$TOKEN" --yarn --noVerify - displayName: Publish - env: - TOKEN: $(vsce.token) +schedules: + - cron: '0 4 * * Mon-Fri' + displayName: Nightly Release Schedule + always: true + branches: + include: + - main + +resources: + repositories: + - repository: templates + type: github + name: microsoft/vscode-engineering + 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: npm ci + displayName: Install dependencies + retryCountOnTaskFailure: 3 + + - script: npm 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: npm ci + displayName: Install dependencies + retryCountOnTaskFailure: 3 + + - script: npm run bundle + displayName: Compile + + - bash: | + /usr/bin/Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & + echo ">>> Started xvfb" + displayName: Start xvfb + + - script: npm run test + displayName: Run test suite + env: + DISPLAY: ':99.0' + TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/test-results.xml + + # - script: npm run browsertest --browserType=chromium + # displayName: Run test suite (chromium) + # env: + # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-chromium-test-results.xml + + # - script: npm run browsertest --browserType=firefox + # displayName: Run test suite (firefox) + # env: + # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-firefox-test-results.xml + + # - script: npm run browsertest --browserType=webkit + # 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: ${{ parameters.publishExtension }} diff --git a/azure-pipeline.pr.yml b/azure-pipeline.pr.yml index 1e4bb00be0..df40e8d2b9 100644 --- a/azure-pipeline.pr.yml +++ b/azure-pipeline.pr.yml @@ -1,21 +1,48 @@ jobs: -- job: test_suite - displayName: Test suite - pool: - name: Hosted macOS - steps: - - - template: scripts/ci/common-setup.yml - - - script: yarn run test - displayName: Run test suite - env: - TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/test-results.xml - - - task: PublishTestResults@2 - displayName: Publish test results - inputs: - testResultsFormat: JUnit - testResultsFiles: $(Agent.HomeDirectory)/test-results.xml - testRunTitle: Mocha test suite - condition: succeededOrFailed() \ No newline at end of file + - job: test_suite + displayName: Test suite + pool: + vmImage: 'macos-15' + steps: + - template: scripts/ci/common-setup.yml + + - script: npm run compile + displayName: Compile + + - script: npm run hygiene + displayName: Run hygiene checks + + - script: npm run lint + displayName: Run lint + + - script: npm run check:commands + displayName: Verify command registrations + + - script: npm run test + displayName: Run test suite + env: + TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/test-results.xml + + # - script: npm run browsertest --browserType=chromium + # displayName: Run test suite (chromium) + # env: + # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-chromium-test-results.xml + + # - script: npm run browsertest --browserType=firefox + # displayName: Run test suite (firefox) + # env: + # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-firefox-test-results.xml + + # - script: npm run browsertest --browserType=webkit + # displayName: Run test suite (webkit) + # env: + # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-webkit-test-results.xml + + - task: PublishTestResults@2 + displayName: Publish test results + inputs: + testResultsFormat: JUnit + testResultsFiles: $(Agent.HomeDirectory)/*test-results.xml + testRunTitle: Mocha test suite + failTaskOnFailedTests: true + condition: succeededOrFailed() diff --git a/azure-pipeline.release.yml b/azure-pipeline.release.yml new file mode 100644 index 0000000000..94c8158f19 --- /dev/null +++ b/azure-pipeline.release.yml @@ -0,0 +1,81 @@ +name: $(Date:yyyyMMdd)$(Rev:.r) + +trigger: + branches: + include: + - main +pr: none + +resources: + repositories: + - repository: templates + type: github + name: microsoft/vscode-engineering + 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: npm ci + displayName: Install dependencies + retryCountOnTaskFailure: 3 + + - script: npm run bundle + displayName: Compile + + testSteps: + - script: npm ci + displayName: Install dependencies + retryCountOnTaskFailure: 3 + + - script: npm run bundle + displayName: Compile + + - bash: | + /usr/bin/Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & + echo ">>> Started xvfb" + displayName: Start xvfb + + - script: npm run test + displayName: Run test suite + env: + DISPLAY: ':99.0' + TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/test-results.xml + + # - script: npm run browsertest --browserType=chromium + # displayName: Run test suite (chromium) + # env: + # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-chromium-test-results.xml + + # - script: npm run browsertest --browserType=firefox + # displayName: Run test suite (firefox) + # env: + # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-firefox-test-results.xml + + # - script: npm run browsertest --browserType=webkit + # 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 new file mode 100644 index 0000000000..95672b1ff2 --- /dev/null +++ b/build/filters.js @@ -0,0 +1,87 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Largely copied from https://github.com/microsoft/vscode/blob/72208d7bbb0e151a54f012ffb382095f0f4c5ba4/build/filters.js + +/** + * Hygiene works by creating cascading subsets of all our files and + * passing them through a sequence of checks. Here are the current subsets, + * named according to the checks performed on them. + */ + +module.exports.all = [ + '*', + 'build/**/*', + 'common/**/*', + 'scripts/**/*', + 'src/**/*', + 'test/**/*', + 'webviews/**/*' +]; + +module.exports.unicodeFilter = [ + '**', + // except specific files + '!documentation/**/*', + '!src/@types/vscode.proposed*.d.ts', + '!**/ThirdPartyNotices.txt', + '!**/LICENSE.{txt,rtf}', + '!**/LICENSE', + '!**/CHANGELOG.md', + '!*.yml', + '!resources/emojis.json' +]; + +module.exports.indentationFilter = [ + '**', + + // except specific files + '!CHANGELOG.md', + '!documentation/**/*', + '!**/ThirdPartyNotices.txt', + '!**/LICENSE.{txt,rtf}', + '!**/LICENSE', + '!**/*.yml', + '!resources/emojis.json', + + // except multiple specific files + '!**/package.json', + '!**/package-lock.json', + '!**/yarn.lock', + '!**/yarn-error.log', + '!**/fixtures/**/*' +]; + +module.exports.copyrightFilter = [ + '**', + '!documentation/**/*', + '!.readme/**/*', + '!.vscode/**/*', + '!.github/**/*', + '!.husky/**/*', + '!tsconfig.base.json', + '!tsconfig.browser.json', + '!tsconfig.json', + '!tsconfig.test.json', + '!tsconfig.webviews.json', + '!tsconfig.scripts.json', + '!tsfmt.json', + '!**/queries*.gql', + '!**/*.yml', + '!**/*.md', + '!package.nls.json', + '!**/package-lock.json', + '!**/*.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 new file mode 100644 index 0000000000..9f6b4c5bd0 --- /dev/null +++ b/build/hygiene.js @@ -0,0 +1,286 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Largely copied from https://github.com/microsoft/vscode/blob/72208d7bbb0e151a54f012ffb382095f0f4c5ba4/build/hygiene.js + +const filter = require('gulp-filter'); +const es = require('event-stream'); +const VinylFile = require('vinyl'); +const vfs = require('vinyl-fs'); +const path = require('path'); +const fs = require('fs'); +const pall = require('p-all'); +const { all, tsFormattingFilter, copyrightFilter, unicodeFilter, indentationFilter } = require('./filters'); + +const copyrightHeaderLines = [ + '/*---------------------------------------------------------------------------------------------', + ' * Copyright (c) Microsoft Corporation. All rights reserved.', + ' * Licensed under the MIT License. See License.txt in the project root for license information.', + ' *--------------------------------------------------------------------------------------------*/', +]; + +function hygiene(some) { + const tsfmt = require('typescript-formatter'); + + let errorCount = 0; + + function getFileLines(file) { + if (!file.__lines) { + file.__lines = file.contents.toString('utf8').split(/\r\n|\r|\n/); + } + + return file.__lines; + } + + const unicode = es.through(function (file) { + const lines = file.contents.toString('utf8').split(/\r\n|\r|\n/); + file.__lines = lines; + + let skipNext = false; + lines.forEach((line, i) => { + if (/allow-any-unicode-next-line/.test(line)) { + skipNext = true; + return; + } + if (skipNext) { + skipNext = false; + return; + } + // Please do not add symbols that resemble ASCII letters! + const m = /([^\t\n\r\x20-\x7E⊃⊇✔︎✓🎯⚠️🛑🔴🚗🚙🚕🎉✨❗⇧⌥⌘×÷¦⋯…↑↓→→←↔⟷·•●◆▼⟪⟫┌└├⏎↩√φ]+)/g.exec(line); + if (m) { + console.error( + file.relative + `(${i + 1},${m.index + 1}): Unexpected unicode character: "${m[0]}" (charCode: ${m[0].charCodeAt(0)}). To suppress, use // allow-any-unicode-next-line` + ); + errorCount++; + } + }); + + this.emit('data', file); + }); + + const indentation = es.through(function (file) { + const lines = getFileLines(file); + + 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' + ); + errorCount++; + } + }); + + this.emit('data', file); + }); + + const copyrights = es.through(function (file) { + const lines = getFileLines(file); + + for (let i = 0; i < copyrightHeaderLines.length; i++) { + if (lines[i] !== copyrightHeaderLines[i]) { + console.error(file.relative + ': Missing or bad copyright statement'); + errorCount++; + break; + } + } + + this.emit('data', file); + }); + + const formatting = es.map(function (file, cb) { + tsfmt + .processString(file.path, file.contents.toString('utf8'), { + verify: false, + tsfmt: true, + // verbose: true, + // keep checkJS happy + editorconfig: undefined, + replace: undefined, + tsconfig: undefined, + tsconfigFile: undefined, + tsfmtFile: undefined, + vscode: undefined, + vscodeFile: undefined, + }) + .then( + (result) => { + const original = result.src.replace(/\r\n/gm, '\n'); + const formatted = result.dest.replace(/\r\n/gm, '\n'); + + if (original !== formatted) { + console.error( + `File not formatted. Run the 'Format Document' command to fix it:`, + file.relative + ); + errorCount++; + } + cb(null, file); + }, + (err) => { + cb(err); + } + ); + }); + + let input; + + if (Array.isArray(some) || typeof some === 'string' || !some) { + const options = { base: '.', follow: true, allowEmpty: true }; + if (some) { + input = vfs.src(some, options); + } else { + input = vfs.src(all, options); + } + } else { + input = some; + } + + const unicodeFilterStream = filter(unicodeFilter, { restore: true }); + + const result = input + .pipe(filter((f) => !f.stat.isDirectory())) + .pipe(unicodeFilterStream) + .pipe(unicode) + .pipe(unicodeFilterStream.restore) + .pipe(filter(indentationFilter)) + .pipe(indentation) + .pipe(filter(copyrightFilter)) + .pipe(copyrights); + + const streams = [ + result.pipe(filter(tsFormattingFilter)).pipe(formatting) + ]; + + let count = 0; + return es.merge(...streams).pipe( + es.through( + function (data) { + count++; + if (process.env['TRAVIS'] && count % 10 === 0) { + process.stdout.write('.'); + } + this.emit('data', data); + }, + function () { + process.stdout.write('\n'); + if (errorCount > 0) { + this.emit( + 'error', + 'Hygiene failed with ' + + errorCount + + ` errors. Check 'build / gulpfile.hygiene.js'.` + ); + } else { + this.emit('end'); + } + } + ) + ); +} + +module.exports.hygiene = hygiene; + +function createGitIndexVinyls(paths) { + const cp = require('child_process'); + const repositoryPath = process.cwd(); + + const fns = paths.map((relativePath) => () => + new Promise((c, e) => { + const fullPath = path.join(repositoryPath, relativePath); + + fs.stat(fullPath, (err, stat) => { + if (err && err.code === 'ENOENT') { + // ignore deletions + return c(null); + } else if (err) { + return e(err); + } + + cp.exec( + process.platform === 'win32' ? `git show :${relativePath}` : `git show ':${relativePath}'`, + { maxBuffer: 2000 * 1024, encoding: 'buffer' }, + (err, out) => { + if (err) { + return e(err); + } + + c( + new VinylFile({ + path: fullPath, + base: repositoryPath, + contents: out, + stat, + }) + ); + } + ); + }); + }) + ); + + return pall(fns, { concurrency: 4 }).then((r) => r.filter((p) => !!p)); +} + +// this allows us to run hygiene as a git pre-commit hook +if (require.main === module) { + const cp = require('child_process'); + + process.on('unhandledRejection', (reason, p) => { + console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); + process.exit(1); + }); + + if (process.argv.length > 2) { + hygiene(process.argv.slice(2)).on('error', (err) => { + console.error(); + console.error(err); + process.exit(1); + }); + } else { + cp.exec( + 'git diff --cached --name-only', + { maxBuffer: 2000 * 1024 }, + (err, out) => { + if (err) { + console.error(); + console.error(err); + process.exit(1); + } + + const some = out.split(/\r?\n/).filter((l) => !!l); + + if (some.length > 0) { + console.log('Reading git index versions...'); + + createGitIndexVinyls(some) + .then( + (vinyls) => + new Promise((c, e) => + hygiene(es.readArray(vinyls)) + .on('end', () => c()) + .on('error', e) + ) + ) + .catch((err) => { + console.error(); + console.error(err); + 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 66% rename from src/github/milestoneModel.ts rename to common/types.ts index 20b52325c9..094d680957 100644 --- a/src/github/milestoneModel.ts +++ b/common/types.ts @@ -1,12 +1,9 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * 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[]; -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface RemoteInfo { + owner: string; + repositoryName: string; +} diff --git a/common/views.ts b/common/views.ts new file mode 100644 index 0000000000..9e2f35fdf2 --- /dev/null +++ b/common/views.ts @@ -0,0 +1,190 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RemoteInfo } from './types'; +import { ClosedEvent, CommentEvent } from '../src/common/timelineEvent'; +import { GithubItemStateEnum, IAccount, ILabel, IMilestone, IProject, ITeam, MergeMethod, MergeMethodsAvailability } from '../src/github/interface'; +import { DisplayLabel, PreReviewState } from '../src/github/views'; + +export interface CreateParams { + availableBaseRemotes: RemoteInfo[]; + availableCompareRemotes: RemoteInfo[]; + branchesForRemote: string[]; + branchesForCompare: string[]; + + defaultBaseRemote?: RemoteInfo; + defaultBaseBranch?: string; + defaultCompareRemote?: RemoteInfo; + defaultCompareBranch?: string; + defaultTitle?: string; + defaultDescription?: string; + + pendingTitle?: string; + pendingDescription?: string; + baseRemote?: RemoteInfo; + baseBranch?: string; + compareRemote?: RemoteInfo; + compareBranch?: string; + isDraftDefault: boolean; + isDraft?: boolean; + labels?: ILabel[]; + isDarkTheme?: boolean; + + validate?: boolean; + showTitleValidationError?: boolean; + createError?: string; + + autoMergeDefault: boolean; + autoMerge?: boolean; + autoMergeMethod?: MergeMethod; + allowAutoMerge?: boolean; + defaultMergeMethod?: MergeMethod; + mergeMethodsAvailability?: MergeMethodsAvailability; +} + +export interface ScrollPosition { + x: number; + y: number; +} + +export interface CreatePullRequest { + title: string; + body: string; + owner: string; + repo: string; + base: string + compareBranch: string; + compareOwner: string; + compareRepo: string; + draft: boolean; + autoMerge: boolean; + autoMergeMethod?: MergeMethod; + labels: ILabel[]; +} + +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; +} + +export interface OpenLocalFileArgs { + file: string; + startLine: number; + endLine: number; + href: string; +} + +// #endregion \ No newline at end of file diff --git a/documentation/IssueFeatures.md b/documentation/IssueFeatures.md index 496c8d4cbf..de7db2dff0 100644 --- a/documentation/IssueFeatures.md +++ b/documentation/IssueFeatures.md @@ -1,82 +1,116 @@ -We've added some experimental GitHub issue features. - -# Code actions - -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`. - -![Create Issue from Comment](images/createIssueFromComment.gif) - -The code action trigger defaults are `TODO`, `todo`, `BUG`, `FIXME`, `ISSUE`, and `HACK`. You can modify these with the `githubIssues.createIssueTriggers` setting. -You can also create an issue from a text selection by running the **Create Issue from Selection** command. - -# Commands - -There are two new commands for easily creating GitHub permalinks. The **Copy GitHub Permalink** command will take the text you have selected and make a git hub permalink. The **Open Permalink in GitHub** command creates the same permalink, but also opens the link in your browser. - -# Inline Completion Suggestions - -## Issues - -The issue completion is triggered by typing `#` or by running the **Trigger Suggest** command after a `#`. By default, only issues assigned to you will be in the suggestions, but you can change this by [customizing the query](#customize-query). The initial sorting of the issues should be by milestone due date, with the "current" milestone first. If your repository doesn't use milestone due dates, then the milestone date will be guessed by the name of the milestone. Within each milestone, issues are sorted by most recently modified. If there are milestones you want to ignore, you can configure them with the `githubIssues.ignoreMilestones` setting. Issue completions work in the editor and in the Source Control commit message. - -![Issue Completion Editor](images/issueCompletionEditor.png) - -![Issue Completion SCM](images/issueCompletionSCM.png) - -In the Source Control commit message and in most other files, the completion will insert the issue number. In markdown files, it will insert a markdown link to the issue such as `[#1234](https://github.com/Microsoft/vscode-pull-request-github/issues/1234)`. For the SCM input box you can configure what is inserted for the completion using `"githubIssues.issueCompletionFormatScm": "${issueNumberLabel}"`. - -If triggering issue completion on `#` is too noisy for you, you can configure that trigger character to be ignored with the `"githubIssues.ignoreCompletionTrigger"` setting. - -## Users - -The user completion is triggered by `@` or by running the **Trigger Suggest** command after a `@`. All users that can have issues assigned to them will be suggested. - -![User Completion](images/userCompletion.png) - -Selecting a completion item will insert the username, such as @alexr00 - -# Hovers - -## Issues - -When you hover over an issue (`#1234` or a full GitHub url) a card similar to the hover card from GitHub will show. - -![Issue Hover](images/issueHover.png) - -## Users - -Similarly, there is also a hover for users such as `@alexr00`. - -![User Hover](images/userHover.png) - -# View - -You can see issues listed in the Issues view. The default query shows the issues for the repository you currently have opened that are assigned to you, ordered by milestone. The view is enabled with the setting `"githubIssues.show": true`. - -![Issue View](images/issueView.png) - -## Customize Query - -If the default query doesn't suit you, you can use a custom query to change the issues that are shown in the issues view. - -```json -"githubIssues.queries": [ - { - "label": "My Issues", - "query": "default" - }, - { - "label": "Remote Release", - "query": "assignee:alexr00 state:open repo:Microsoft/vscode-remote-release sort:updated-desc" - } - ] -``` - -## Working on an issue - -From the issues view you can start working on an issue. This creates a branch, populates the commit message, and gives you new actions to take on the issue. When you're done working on the issue, you can Stop Working on it or create a pull request. - -![Start Working](images/startWorking.gif) - -**Start Working** is customizable. If you don't want a branch to be created, use `"githubIssues.useBranchForIssues": "off"`. If you always want to be prompted to enter the name of the branch use the `"prompt"` option of the setting. If you have a different naming scheme for your branches you can use `"githubIssues.issueBranchTitle": "${user}/issue${issueNumber}"` to configure it. - +We've added some experimental GitHub issue features. + +# Code actions and CodeLens + +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) + +The code action trigger defaults are `TODO`, `todo`, `BUG`, `FIXME`, `ISSUE`, and `HACK`. You can modify these with the `githubIssues.createIssueTriggers` setting. +You can also create an issue from a text selection by running the **Create Issue from Selection** command. + +# Commands + +There are two new commands for easily creating GitHub permalinks. The **Copy GitHub Permalink** command will take the text you have selected and make a GitHub permalink. The **Open Permalink on GitHub** command creates the same permalink, but also opens the link in your browser. + +# Creating an issue + +When creating an issue from any of the **Create Issue** commands, you have the option to modify a subset of issue properties from an editor using the pencil icon. + +![Create Issue Quick Input](images/quickCreateIssue.png) + +The issue editor is a simple text file with some conventions to allow setting the issue title, assignees, labels, and body. + +![Create Issue Editor](images/issueEditor.png) + +To edit other issue properties, you'll need to open the issue on GitHub. + +# Inline Completion Suggestions + +## Issues + +The issue completion is triggered by typing `#` or by running the **Trigger Suggest** command after a `#`. By default, only issues assigned to you will be in the suggestions, but you can change this by [customizing the query](#customize-query). The initial sorting of the issues should be by milestone due date, with the "current" milestone first. If your repository doesn't use milestone due dates, then the milestone date will be guessed by the name of the milestone. Within each milestone, issues are sorted by most recently modified. If there are milestones you want to ignore, you can configure them with the `githubIssues.ignoreMilestones` setting. Issue completions work in the editor and in the Source Control commit message. + +![Issue Completion Editor](images/issueCompletionEditor.png) + +![Issue Completion SCM](images/issueCompletionSCM.png) + +In the Source Control commit message and in most other files, the completion will insert the issue number. In markdown files, it will insert a markdown link to the issue such as `[#1234](https://github.com/Microsoft/vscode-pull-request-github/issues/1234)`. For the SCM input box you can configure what is inserted for the completion using `"githubIssues.issueCompletionFormatScm": "${issueNumberLabel}"`. + +If triggering issue completion on `#` is too noisy for you, you can configure that trigger character to be ignored with the `"githubIssues.ignoreCompletionTrigger"` setting. + +## Users + +The user completion is triggered by `@` or by running the **Trigger Suggest** command after a `@`. All users that can have issues assigned to them will be suggested. + +![User Completion](images/userCompletion.png) + +Selecting a completion item will insert the username, such as @alexr00 + +# Hovers + +## Issues + +When you hover over an issue (`#1234` or a full GitHub url) a card similar to the hover card from GitHub will show. + +![Issue Hover](images/issueHover.png) + +## Users + +Similarly, there is also a hover for users such as `@alexr00`. + +![User Hover](images/userHover.png) + +# View + +You can see issues listed in the Issues view. The default query shows the issues for the repository you currently have opened that are assigned to you, ordered by milestone. The view is enabled with the setting `"githubIssues.show": true`. + +![Issue View](images/issueView.png) + +## Customize Query + +If the default query doesn't suit you, you can use a custom query to change the issues that are shown in the issues view. + +```json +"githubIssues.queries": [ + { + "label": "My Issues", + "query": "default" + }, + { + "label": "Remote Release", + "query": "assignee:alexr00 state:open repo:Microsoft/vscode-remote-release sort:updated-desc" + } + ] +``` + +## Working on an issue + +From the issues view you can start working on an issue. This creates a branch, populates the commit message, and gives you new actions to take on the issue. When you're done working on the issue, you can Stop Working on it or create a pull request. + +![Start Working](images/startWorking.gif) + +**Start Working** is customizable. If you don't want a branch to be created, use `"githubIssues.useBranchForIssues": "off"`. If you always want to be prompted to enter the name of the branch use the `"prompt"` option of the setting. If you have a different naming scheme for your branches you can use `"githubIssues.issueBranchTitle": "${user}/issue${issueNumber}"` to configure it. + +## GitHub variables + +Some of the settings (ex. `githubIssues.workingIssueFormatScm` and `githubIssues.issueCompletionFormatScm`) for the issues features support `${variablesName}` style variables. The following variables exist and may be available in some settings: + +- `${user}`: The currently logged in user. +- `${issueNumber}`: The number of the current issue. +- `${issueNumberLabel}`: A label for the current issue such as `#123` or if the issue is outside of the current repo then `owner/repo#123`. +- `${issueTitle}`: The title of the current issue. +- `${repository}`: The currently open repository. +- `${owner}`: The owner of the current repository. +- `${sanitizedIssueTitle}`: The title of the current issue with characters that git forbids removed. +- `${sanitizedLowercaseIssueTitle}`: The lowercase title of the current issue with characters that git forbids removed. +- `${issueType}`: The name of the issue type (e.g. `Feature`, `Hotfix`), if the issue has an issue type set. If the issue does not have an issue type, the variable is left as-is. +- `${sanitizedIssueType}`: The issue type name with characters that git forbids removed. +- `${sanitizedLowercaseIssueType}`: The lowercase issue type name with characters that git forbids removed. 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.144.0/upload-an-image.gif b/documentation/changelog/0.144.0/upload-an-image.gif new file mode 100644 index 0000000000..bb033e54a0 Binary files /dev/null and b/documentation/changelog/0.144.0/upload-an-image.gif differ diff --git a/documentation/changelog/0.20.0/multirepo.png b/documentation/changelog/0.20.0/multirepo.png new file mode 100644 index 0000000000..d6c875e0fe Binary files /dev/null and b/documentation/changelog/0.20.0/multirepo.png differ diff --git a/documentation/changelog/0.21.0/focusedReview.png b/documentation/changelog/0.21.0/focusedReview.png new file mode 100644 index 0000000000..c5b0b15c3f Binary files /dev/null and b/documentation/changelog/0.21.0/focusedReview.png differ diff --git a/documentation/changelog/0.21.0/issue-suggest-comment.png b/documentation/changelog/0.21.0/issue-suggest-comment.png new file mode 100644 index 0000000000..7a89042a99 Binary files /dev/null and b/documentation/changelog/0.21.0/issue-suggest-comment.png differ diff --git a/documentation/changelog/0.21.0/loginView.png b/documentation/changelog/0.21.0/loginView.png new file mode 100644 index 0000000000..4dc6cc0a47 Binary files /dev/null and b/documentation/changelog/0.21.0/loginView.png differ diff --git a/documentation/changelog/0.21.0/openCodeLink.gif b/documentation/changelog/0.21.0/openCodeLink.gif new file mode 100644 index 0000000000..f2ce5d1e62 Binary files /dev/null and b/documentation/changelog/0.21.0/openCodeLink.gif differ diff --git a/documentation/changelog/0.22.0/colored-icons.png b/documentation/changelog/0.22.0/colored-icons.png new file mode 100644 index 0000000000..d848d49cff Binary files /dev/null and b/documentation/changelog/0.22.0/colored-icons.png differ diff --git a/documentation/changelog/0.22.0/exact-time-hover.png b/documentation/changelog/0.22.0/exact-time-hover.png new file mode 100644 index 0000000000..03bcac4282 Binary files /dev/null and b/documentation/changelog/0.22.0/exact-time-hover.png differ diff --git a/documentation/changelog/0.22.0/pull-request-tree-view.png b/documentation/changelog/0.22.0/pull-request-tree-view.png new file mode 100644 index 0000000000..be830f8d61 Binary files /dev/null and b/documentation/changelog/0.22.0/pull-request-tree-view.png differ diff --git a/documentation/changelog/0.22.0/toggle-layout.png b/documentation/changelog/0.22.0/toggle-layout.png new file mode 100644 index 0000000000..a78e265cc3 Binary files /dev/null and b/documentation/changelog/0.22.0/toggle-layout.png differ diff --git a/documentation/changelog/0.23.0/copy-as-markdown.gif b/documentation/changelog/0.23.0/copy-as-markdown.gif new file mode 100644 index 0000000000..d8ad9a8e95 Binary files /dev/null and b/documentation/changelog/0.23.0/copy-as-markdown.gif differ diff --git a/documentation/changelog/0.23.0/create-from-terminal.gif b/documentation/changelog/0.23.0/create-from-terminal.gif new file mode 100644 index 0000000000..45c18f96ae Binary files /dev/null and b/documentation/changelog/0.23.0/create-from-terminal.gif differ diff --git a/documentation/changelog/0.23.0/new-create-experience.gif b/documentation/changelog/0.23.0/new-create-experience.gif new file mode 100644 index 0000000000..635611724a Binary files /dev/null and b/documentation/changelog/0.23.0/new-create-experience.gif differ diff --git a/documentation/changelog/0.24.0/activity_bar_viewlets.png b/documentation/changelog/0.24.0/activity_bar_viewlets.png new file mode 100644 index 0000000000..d30aac1d96 Binary files /dev/null and b/documentation/changelog/0.24.0/activity_bar_viewlets.png differ diff --git a/documentation/changelog/0.24.0/assignees_and_milestone.png b/documentation/changelog/0.24.0/assignees_and_milestone.png new file mode 100644 index 0000000000..3dc162941d Binary files /dev/null and b/documentation/changelog/0.24.0/assignees_and_milestone.png differ diff --git a/documentation/changelog/0.24.0/focused_mode_actions.png b/documentation/changelog/0.24.0/focused_mode_actions.png new file mode 100644 index 0000000000..cb0640ac8d Binary files /dev/null and b/documentation/changelog/0.24.0/focused_mode_actions.png differ diff --git a/documentation/changelog/0.24.0/resolved_conversations.png b/documentation/changelog/0.24.0/resolved_conversations.png new file mode 100644 index 0000000000..3be38dad1b Binary files /dev/null and b/documentation/changelog/0.24.0/resolved_conversations.png differ diff --git a/documentation/changelog/0.25.0/create_pull_request.png b/documentation/changelog/0.25.0/create_pull_request.png new file mode 100644 index 0000000000..7e8c6a82d9 Binary files /dev/null and b/documentation/changelog/0.25.0/create_pull_request.png differ diff --git a/documentation/changelog/0.25.0/update_milestone.gif b/documentation/changelog/0.25.0/update_milestone.gif new file mode 100644 index 0000000000..8253b0ec58 Binary files /dev/null and b/documentation/changelog/0.25.0/update_milestone.gif differ diff --git a/documentation/changelog/0.26.0/file-decoration.png b/documentation/changelog/0.26.0/file-decoration.png new file mode 100644 index 0000000000..199b8b9f6d Binary files /dev/null and b/documentation/changelog/0.26.0/file-decoration.png differ diff --git a/documentation/changelog/0.26.0/mark-as-viewed.gif b/documentation/changelog/0.26.0/mark-as-viewed.gif new file mode 100644 index 0000000000..67545fbc9b Binary files /dev/null and b/documentation/changelog/0.26.0/mark-as-viewed.gif differ diff --git a/documentation/changelog/0.26.0/resolve-conversation.png b/documentation/changelog/0.26.0/resolve-conversation.png new file mode 100644 index 0000000000..58c5029c1d Binary files /dev/null and b/documentation/changelog/0.26.0/resolve-conversation.png differ diff --git a/documentation/changelog/0.28.0/status-bar-name.png b/documentation/changelog/0.28.0/status-bar-name.png new file mode 100644 index 0000000000..905a4fd2f7 Binary files /dev/null and b/documentation/changelog/0.28.0/status-bar-name.png differ diff --git a/documentation/changelog/0.29.0/merged-pr-sidebar.png b/documentation/changelog/0.29.0/merged-pr-sidebar.png new file mode 100644 index 0000000000..c5e619545b Binary files /dev/null and b/documentation/changelog/0.29.0/merged-pr-sidebar.png differ diff --git a/documentation/changelog/0.30.0/comments-collapse-all.png b/documentation/changelog/0.30.0/comments-collapse-all.png new file mode 100644 index 0000000000..2607b4f5e4 Binary files /dev/null and b/documentation/changelog/0.30.0/comments-collapse-all.png differ diff --git a/documentation/changelog/0.30.0/multiline-setting.png b/documentation/changelog/0.30.0/multiline-setting.png new file mode 100644 index 0000000000..9200b12ef9 Binary files /dev/null and b/documentation/changelog/0.30.0/multiline-setting.png differ diff --git a/documentation/changelog/0.32.0/navigate-pr-query.gif b/documentation/changelog/0.32.0/navigate-pr-query.gif new file mode 100644 index 0000000000..140e44d496 Binary files /dev/null and b/documentation/changelog/0.32.0/navigate-pr-query.gif differ diff --git a/documentation/changelog/0.34.0/git-change-colors.png b/documentation/changelog/0.34.0/git-change-colors.png new file mode 100644 index 0000000000..a497ecd58b Binary files /dev/null and b/documentation/changelog/0.34.0/git-change-colors.png differ diff --git a/documentation/changelog/0.34.0/pre-release-extension.png b/documentation/changelog/0.34.0/pre-release-extension.png new file mode 100644 index 0000000000..7b949a9e94 Binary files /dev/null and b/documentation/changelog/0.34.0/pre-release-extension.png differ diff --git a/documentation/changelog/0.36.0/dont-show-button.png b/documentation/changelog/0.36.0/dont-show-button.png new file mode 100644 index 0000000000..b97c74aa0f Binary files /dev/null and b/documentation/changelog/0.36.0/dont-show-button.png differ diff --git a/documentation/changelog/0.38.0/mark-file-viewed.gif b/documentation/changelog/0.38.0/mark-file-viewed.gif new file mode 100644 index 0000000000..60c1171ea3 Binary files /dev/null and b/documentation/changelog/0.38.0/mark-file-viewed.gif differ diff --git a/documentation/changelog/0.40.0/branch-protection.png b/documentation/changelog/0.40.0/branch-protection.png new file mode 100644 index 0000000000..e426865e05 Binary files /dev/null and b/documentation/changelog/0.40.0/branch-protection.png differ diff --git a/documentation/changelog/0.42.0/mark-as-viewed-toolbar.png b/documentation/changelog/0.42.0/mark-as-viewed-toolbar.png new file mode 100644 index 0000000000..5f5d197002 Binary files /dev/null and b/documentation/changelog/0.42.0/mark-as-viewed-toolbar.png differ diff --git a/documentation/changelog/0.42.0/multiline-comments.gif b/documentation/changelog/0.42.0/multiline-comments.gif new file mode 100644 index 0000000000..98d88adaa1 Binary files /dev/null and b/documentation/changelog/0.42.0/multiline-comments.gif differ diff --git a/documentation/changelog/0.42.0/open-on-github.png b/documentation/changelog/0.42.0/open-on-github.png new file mode 100644 index 0000000000..4552770e7c Binary files /dev/null and b/documentation/changelog/0.42.0/open-on-github.png differ diff --git a/documentation/changelog/0.44.0/auto-merge-create-view.png b/documentation/changelog/0.44.0/auto-merge-create-view.png new file mode 100644 index 0000000000..c9e2da5cd7 Binary files /dev/null and b/documentation/changelog/0.44.0/auto-merge-create-view.png differ diff --git a/documentation/changelog/0.46.0/automerge-overview.png b/documentation/changelog/0.46.0/automerge-overview.png new file mode 100644 index 0000000000..9aaaae73a5 Binary files /dev/null and b/documentation/changelog/0.46.0/automerge-overview.png differ diff --git a/documentation/changelog/0.48.0/changesSinceReview.gif b/documentation/changelog/0.48.0/changesSinceReview.gif new file mode 100644 index 0000000000..c0218c7512 Binary files /dev/null and b/documentation/changelog/0.48.0/changesSinceReview.gif differ diff --git a/documentation/changelog/0.48.0/commit-and-create-pr.png b/documentation/changelog/0.48.0/commit-and-create-pr.png new file mode 100644 index 0000000000..08a71eb6be Binary files /dev/null and b/documentation/changelog/0.48.0/commit-and-create-pr.png differ diff --git a/documentation/changelog/0.50.0/githubNotifications.gif b/documentation/changelog/0.50.0/githubNotifications.gif new file mode 100644 index 0000000000..e1b993c8f4 Binary files /dev/null and b/documentation/changelog/0.50.0/githubNotifications.gif differ diff --git a/documentation/changelog/0.50.0/labelColors.png b/documentation/changelog/0.50.0/labelColors.png new file mode 100644 index 0000000000..0ce6426698 Binary files /dev/null and b/documentation/changelog/0.50.0/labelColors.png differ diff --git a/documentation/changelog/0.50.0/renamed-tooltip.png b/documentation/changelog/0.50.0/renamed-tooltip.png new file mode 100644 index 0000000000..6922e9f9ae Binary files /dev/null and b/documentation/changelog/0.50.0/renamed-tooltip.png differ diff --git a/documentation/changelog/0.52.0/tree-item-checkbox-state.png b/documentation/changelog/0.52.0/tree-item-checkbox-state.png new file mode 100644 index 0000000000..c041eb9367 Binary files /dev/null and b/documentation/changelog/0.52.0/tree-item-checkbox-state.png differ diff --git a/documentation/changelog/0.56.0/copy-vscode-dev-link.png b/documentation/changelog/0.56.0/copy-vscode-dev-link.png new file mode 100644 index 0000000000..06a4d6dd3e Binary files /dev/null and b/documentation/changelog/0.56.0/copy-vscode-dev-link.png differ diff --git a/documentation/changelog/0.56.0/pr-status-in-list.png b/documentation/changelog/0.56.0/pr-status-in-list.png new file mode 100644 index 0000000000..4fef57958a Binary files /dev/null and b/documentation/changelog/0.56.0/pr-status-in-list.png differ diff --git a/documentation/changelog/0.56.0/visible-resolve-button.png b/documentation/changelog/0.56.0/visible-resolve-button.png new file mode 100644 index 0000000000..fda987f301 Binary files /dev/null and b/documentation/changelog/0.56.0/visible-resolve-button.png differ diff --git a/documentation/changelog/0.58.0/create-with-labels.png b/documentation/changelog/0.58.0/create-with-labels.png new file mode 100644 index 0000000000..a6e6b7a802 Binary files /dev/null and b/documentation/changelog/0.58.0/create-with-labels.png differ diff --git a/documentation/changelog/0.58.0/suggest-a-change.gif b/documentation/changelog/0.58.0/suggest-a-change.gif new file mode 100644 index 0000000000..3c180fb398 Binary files /dev/null and b/documentation/changelog/0.58.0/suggest-a-change.gif 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/images/issueEditor.png b/documentation/images/issueEditor.png new file mode 100644 index 0000000000..4d6bbdd143 Binary files /dev/null and b/documentation/images/issueEditor.png differ diff --git a/documentation/images/quickCreateIssue.png b/documentation/images/quickCreateIssue.png new file mode 100644 index 0000000000..56c24c03f3 Binary files /dev/null and b/documentation/images/quickCreateIssue.png differ diff --git a/documentation/releasing.md b/documentation/releasing.md index 3b47ec2deb..9be934ecb4 100644 --- a/documentation/releasing.md +++ b/documentation/releasing.md @@ -1,36 +1,30 @@ # How to release -1. Edit version in [package.json](https://github.com/Microsoft/vscode-pull-request-github/blob/master/package.json) +1. Edit version in [package.json](https://github.com/Microsoft/vscode-pull-request-github/blob/main/package.json) - Update version of the extension - this is usually the minor version. + **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.** + - Run `npm install` to update `package-lock.json` - (If necessary) Update vscode engine version - -2. Update [CHANGELOG.md](https://github.com/Microsoft/vscode-pull-request-github/blob/master/CHANGELOG.md) +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/master/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 +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 + +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`). -5. Generate VSIX - - If you don't yet have **vsce** install it `npm install -g vsce` - - Run `vsce package --yarn`. This will generate a .vsix in the project directory. +6. Wait for the release pipeline to finish running. -6. 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 - Preview release - **Publish** release -7. Publish extension on marketplace - - Go to: https://marketplace.visualstudio.com/manage/publishers/github - - Find `GitHub Pull Requests` - - Select **...** and then **Update** and upload the .vsix you just generated +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-lock.json b/package-lock.json new file mode 100644 index 0000000000..9b5a71ae85 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,12482 @@ +{ + "name": "vscode-pull-request-github", + "version": "0.156.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vscode-pull-request-github", + "version": "0.156.0", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@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": "1.5.2", + "@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.2.0", + "jsonc-parser": "^3.3.1", + "lru-cache": "6.0.0", + "markdown-it": "^14.2.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": "14.0.0", + "vscode-tas-client": "^0.1.84", + "vsls": "^0.3.967" + }, + "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": "^10.0.10", + "@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.103.0", + "@types/webpack-env": "^1.16.0", + "@typescript-eslint/eslint-plugin": "^8.44.0", + "@typescript-eslint/parser": "^8.44.0", + "@vscode/dts": "^0.4.1", + "@vscode/test-cli": "^0.0.11", + "@vscode/test-electron": "^2.5.2", + "@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": "7.1.2", + "esbuild-loader": "4.5.0", + "eslint": "^9.36.0", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-import": "2.31.0", + "eslint-plugin-rulesdir": "^0.2.2", + "event-stream": "^4.0.1", + "fork-ts-checker-webpack-plugin": "9.1.0", + "glob": "7.1.6", + "globals": "^16.4.0", + "graphql": "15.5.0", + "graphql-tag": "2.11.0", + "gulp-filter": "^7.0.0", + "husky": "^8.0.1", + "jsdom": "28.1.0", + "jsdom-global": "3.0.2", + "json5": "2.2.2", + "merge-options": "3.0.4", + "minimist": "^1.2.6", + "mkdirp": "1.0.4", + "mocha": "^11.7.5", + "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", + "react-testing-library": "7.0.1", + "sinon": "9.0.0", + "source-map-support": "0.5.19", + "stream-browserify": "^3.0.0", + "style-loader": "4.0.0", + "svg-inline-loader": "^0.8.2", + "temp": "0.9.4", + "terser-webpack-plugin": "5.3.17", + "timers-browserify": "^2.0.12", + "ts-loader": "9.5.2", + "ts-node": "^10.9.2", + "tty": "1.0.1", + "typescript": "^5.9.2", + "typescript-eslint": "^8.44.0", + "typescript-formatter": "^7.2.2", + "vinyl-fs": "^3.0.3", + "webpack": "5.104.1", + "webpack-cli": "4.2.0" + }, + "engines": { + "node": ">=20", + "vscode": "^1.129.0" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.0.tgz", + "integrity": "sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.36.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "dev": true, + "license": "ISC", + "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" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/types": { + "version": "24.9.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^1.1.1", + "@types/yargs": "^13.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@joaomoreno/unique-names-generator": { + "version": "5.2.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jridgewell/trace-mapping/node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@koa/cors": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@koa/router": { + "version": "13.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.1", + "http-errors": "^2.0.0", + "koa-compose": "^4.1.0", + "path-to-regexp": "^6.3.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@koa/router/node_modules/http-errors": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@koa/router/node_modules/path-to-regexp": { + "version": "6.3.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@koa/router/node_modules/statuses": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@microsoft/1ds-core-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@microsoft/1ds-core-js/-/1ds-core-js-4.4.1.tgz", + "integrity": "sha512-utqwacfUkiGJROn4WC7aNdRBsRxwhNWXuqaJM2B0N0WHmv1+IhSuI7RQ3FHwxRP1dxZi/xn9aELMZ7HMStsW1w==", + "license": "MIT", + "dependencies": { + "@microsoft/applicationinsights-core-js": "3.4.1", + "@microsoft/applicationinsights-shims": "3.0.1", + "@microsoft/dynamicproto-js": "^2.0.3", + "@nevware21/ts-async": ">= 0.5.5 < 2.x", + "@nevware21/ts-utils": ">= 0.12.6 < 2.x" + } + }, + "node_modules/@microsoft/1ds-post-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@microsoft/1ds-post-js/-/1ds-post-js-4.4.1.tgz", + "integrity": "sha512-CkFEhDY7X8E2JLr6HsEvRiC0DaLOCsA7vlbq/9DJP65gAumgw2NnFNIAOg6Je5Geq1LDu76/nb2hP34p8eGggw==", + "license": "MIT", + "dependencies": { + "@microsoft/applicationinsights-core-js": "3.4.1", + "@microsoft/applicationinsights-shims": "3.0.1", + "@microsoft/dynamicproto-js": "^2.0.3", + "@nevware21/ts-async": ">= 0.5.5 < 2.x", + "@nevware21/ts-utils": ">= 0.12.6 < 2.x" + } + }, + "node_modules/@microsoft/applicationinsights-channel-js": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-channel-js/-/applicationinsights-channel-js-3.4.1.tgz", + "integrity": "sha512-QS1k6iwVwR1MznGAB1H0F9raqpevbFNbadLS5O1419pz9OEWBfF9wRQLnENCyo8QS9Q0IdiqnGAON/D8IywpWg==", + "license": "MIT", + "dependencies": { + "@microsoft/applicationinsights-core-js": "3.4.1", + "@microsoft/applicationinsights-shims": "3.0.1", + "@microsoft/dynamicproto-js": "^2.0.3", + "@nevware21/ts-async": ">= 0.5.5 < 2.x", + "@nevware21/ts-utils": ">= 0.12.6 < 2.x" + }, + "peerDependencies": { + "tslib": ">= 1.0.0" + } + }, + "node_modules/@microsoft/applicationinsights-common": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-common/-/applicationinsights-common-3.4.1.tgz", + "integrity": "sha512-CTbD0g/68tiv2yCItsodDQBYxyHdfQkG7VhvVU8OHenukpl/7W4wEuxZuOntqhv5m9Nx/DFncbz+T83nvYTG3g==", + "license": "MIT", + "dependencies": { + "@microsoft/applicationinsights-core-js": "3.4.1", + "@microsoft/applicationinsights-shims": "3.0.1", + "@microsoft/dynamicproto-js": "^2.0.3", + "@nevware21/ts-utils": ">= 0.12.6 < 2.x" + }, + "peerDependencies": { + "tslib": ">= 1.0.0" + } + }, + "node_modules/@microsoft/applicationinsights-core-js": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-core-js/-/applicationinsights-core-js-3.4.1.tgz", + "integrity": "sha512-eXIHZ1+nvBiJgVpufBiTP801Vtr5FEwjWZioUsb44NC/z/UcsZh2MDJ1mBpjaDO73LVYUw/ZZmDCCo6Pg/61kA==", + "license": "MIT", + "dependencies": { + "@microsoft/applicationinsights-shims": "3.0.1", + "@microsoft/dynamicproto-js": "^2.0.3", + "@nevware21/ts-async": ">= 0.5.5 < 2.x", + "@nevware21/ts-utils": ">= 0.12.6 < 2.x" + }, + "peerDependencies": { + "tslib": ">= 1.0.0" + } + }, + "node_modules/@microsoft/applicationinsights-shims": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-shims/-/applicationinsights-shims-3.0.1.tgz", + "integrity": "sha512-DKwboF47H1nb33rSUfjqI6ryX29v+2QWcTrRvcQDA32AZr5Ilkr7whOOSsD1aBzwqX0RJEIP1Z81jfE3NBm/Lg==", + "license": "MIT", + "dependencies": { + "@nevware21/ts-utils": ">= 0.9.4 < 2.x" + } + }, + "node_modules/@microsoft/applicationinsights-web-basic": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-web-basic/-/applicationinsights-web-basic-3.4.1.tgz", + "integrity": "sha512-V/hSlauFp1thJa57+TMv5mAYinJAQUi4zOmDmpahnDgs8g1zrQ0D8QYDmu0Zfi+9GhoD80B4yJez2+ydJPJz2w==", + "license": "MIT", + "dependencies": { + "@microsoft/applicationinsights-channel-js": "3.4.1", + "@microsoft/applicationinsights-core-js": "3.4.1", + "@microsoft/applicationinsights-shims": "3.0.1", + "@microsoft/dynamicproto-js": "^2.0.3", + "@nevware21/ts-async": ">= 0.5.5 < 2.x", + "@nevware21/ts-utils": ">= 0.12.6 < 2.x" + }, + "peerDependencies": { + "tslib": ">= 1.0.0" + } + }, + "node_modules/@microsoft/dynamicproto-js": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@microsoft/dynamicproto-js/-/dynamicproto-js-2.0.5.tgz", + "integrity": "sha512-V+Zr7PDKIEaItVwF/OyWQlKeugNRYg7KJJ+RhEIL2FMW6NlG8FN2l4XA9Z42hNtsjwJFlcUiF38pmM/AaXsF7g==", + "license": "MIT", + "dependencies": { + "@nevware21/ts-utils": ">= 0.14.0 < 2.x" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@nevware21/ts-async": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@nevware21/ts-async/-/ts-async-0.6.1.tgz", + "integrity": "sha512-W2kFiT5oPuxTrB3NrxUId/U+1AuAhIaiDQkLC4HcxkjNc+85GfELYdPQXnsDWDG8yji24F5qk6QpBDxZX3/0+g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nevware21" + }, + { + "type": "buymeacoffee", + "url": "https://www.buymeacoffee.com/nevware21" + } + ], + "license": "MIT", + "dependencies": { + "@nevware21/ts-utils": ">= 0.15.0 < 2.x" + } + }, + "node_modules/@nevware21/ts-utils": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@nevware21/ts-utils/-/ts-utils-0.15.0.tgz", + "integrity": "sha512-+bUMKIiKAgoW5uNEb5xxzBzdwdLS9SKRcOy8SxLE+KqSlIdUYV5O9nxJVq1RUYcO2DtL5DlrK1GbgcVEHv6GVA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nevware21" + }, + { + "type": "other", + "url": "https://buymeacoffee.com/nevware21" + } + ], + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.4", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.4", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.3", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.0", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.1", + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.2", + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "13.1.1", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.1.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "16.0.0", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.1.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.3", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.0.0", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.0", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "14.1.0", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@playwright/browser-chromium": { + "version": "1.54.1", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.54.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@sheerun/mutationobserver-shim": { + "version": "0.3.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@shikijs/core": { + "version": "3.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.7.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/monaco": { + "version": "3.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.7.0", + "@shikijs/types": "3.7.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/types": { + "version": "3.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "1.8.2", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "6.0.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, + "node_modules/@sinonjs/formatio": { + "version": "5.0.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1", + "@sinonjs/samsam": "^5.0.2" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "5.3.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1.6.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.1", + "dev": true, + "license": "(Unlicense OR Apache-2.0)" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tybys/wasm-util/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@types/chai": { + "version": "4.2.15", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/glob": { + "version": "7.1.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "*", + "@types/node": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*", + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lru-cache": { + "version": "5.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/marked": { + "version": "0.7.4", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/minimatch": { + "version": "3.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mocha": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.18.6", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "16.14.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "16.9.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "^16" + } + }, + "node_modules/@types/scheduler": { + "version": "0.16.1", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/sinon": { + "version": "7.0.11", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/temp": { + "version": "0.8.34", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/vscode": { + "version": "1.103.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/webpack-env": { + "version": "1.16.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "13.0.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "20.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/zen-observable": { + "version": "0.8.2", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.44.1", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.44.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.44.1", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.44.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.44.1", + "@typescript-eslint/types": "^8.44.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.44.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.44.1", + "@typescript-eslint/visitor-keys": "8.44.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.44.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.44.1", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.44.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.44.1", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.44.1", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.44.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.44.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/codicons": { + "version": "0.0.36", + "license": "CC-BY-4.0" + }, + "node_modules/@vscode/dts": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "https-proxy-agent": "^7.0.0", + "minimist": "^1.2.8", + "prompts": "^2.4.2" + }, + "bin": { + "dts": "index.js" + } + }, + "node_modules/@vscode/dts/node_modules/minimist": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@vscode/extension-telemetry": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@vscode/extension-telemetry/-/extension-telemetry-1.5.2.tgz", + "integrity": "sha512-fO4huHz5apb5RtddC8DuUeSbBqYQw1EiBaOOGngq57nGbsDgcvm0jAibTY/kigJyjY0fQ4Vx7owQcCJRUrkT4g==", + "license": "MIT", + "dependencies": { + "@microsoft/1ds-core-js": "^4.4.1", + "@microsoft/1ds-post-js": "^4.4.1", + "@microsoft/applicationinsights-common": "^3.4.1", + "@microsoft/applicationinsights-core-js": "^3.4.1", + "@microsoft/applicationinsights-web-basic": "^3.4.1" + }, + "engines": { + "vscode": "^1.75.0" + } + }, + "node_modules/@vscode/prompt-tsx": { + "version": "0.3.0-alpha.12", + "license": "MIT" + }, + "node_modules/@vscode/test-cli": { + "version": "0.0.11", + "dev": true, + "license": "MIT", + "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" + }, + "bin": { + "vscode-test": "out/bin.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vscode/test-cli/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@vscode/test-cli/node_modules/chokidar": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/@vscode/test-cli/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "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" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vscode/test-cli/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vscode/test-cli/node_modules/supports-color": { + "version": "9.4.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@vscode/test-electron": { + "version": "2.5.2", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@vscode/test-web": { + "version": "0.0.71", + "dev": true, + "license": "MIT", + "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" + }, + "bin": { + "vscode-test-web": "out/server/index.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@vscode/test-web/node_modules/minimist": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/info": { + "version": "1.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "envinfo": "^7.7.3" + }, + "peerDependencies": { + "webpack-cli": "4.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "peerDependencies": { + "webpack-cli": "4.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@wry/context": { + "version": "0.4.4", + "license": "MIT", + "dependencies": { + "@types/node": ">=6", + "tslib": "^1.9.3" + } + }, + "node_modules/@wry/equality": { + "version": "0.1.11", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/accepts": { + "version": "1.3.8", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansi-wrap": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/apollo-boost": { + "version": "0.4.9", + "license": "MIT", + "dependencies": { + "apollo-cache": "^1.3.5", + "apollo-cache-inmemory": "^1.6.6", + "apollo-client": "^2.6.10", + "apollo-link": "^1.0.6", + "apollo-link-error": "^1.0.3", + "apollo-link-http": "^1.3.1", + "graphql-tag": "^2.4.2", + "ts-invariant": "^0.4.0", + "tslib": "^1.10.0" + }, + "peerDependencies": { + "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0" + } + }, + "node_modules/apollo-cache": { + "version": "1.3.5", + "license": "MIT", + "dependencies": { + "apollo-utilities": "^1.3.4", + "tslib": "^1.10.0" + }, + "peerDependencies": { + "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0" + } + }, + "node_modules/apollo-cache-inmemory": { + "version": "1.6.6", + "license": "MIT", + "dependencies": { + "apollo-cache": "^1.3.5", + "apollo-utilities": "^1.3.4", + "optimism": "^0.10.0", + "ts-invariant": "^0.4.0", + "tslib": "^1.10.0" + }, + "peerDependencies": { + "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0" + } + }, + "node_modules/apollo-client": { + "version": "2.6.10", + "license": "MIT", + "dependencies": { + "@types/zen-observable": "^0.8.0", + "apollo-cache": "1.3.5", + "apollo-link": "^1.0.0", + "apollo-utilities": "1.3.4", + "symbol-observable": "^1.0.2", + "ts-invariant": "^0.4.0", + "tslib": "^1.10.0", + "zen-observable": "^0.8.0" + }, + "peerDependencies": { + "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0" + } + }, + "node_modules/apollo-link": { + "version": "1.2.14", + "license": "MIT", + "dependencies": { + "apollo-utilities": "^1.3.0", + "ts-invariant": "^0.4.0", + "tslib": "^1.9.3", + "zen-observable-ts": "^0.8.21" + }, + "peerDependencies": { + "graphql": "^0.11.3 || ^0.12.3 || ^0.13.0 || ^14.0.0 || ^15.0.0" + } + }, + "node_modules/apollo-link-context": { + "version": "1.0.20", + "license": "MIT", + "dependencies": { + "apollo-link": "^1.2.14", + "tslib": "^1.9.3" + } + }, + "node_modules/apollo-link-error": { + "version": "1.1.13", + "license": "MIT", + "dependencies": { + "apollo-link": "^1.2.14", + "apollo-link-http-common": "^0.2.16", + "tslib": "^1.9.3" + } + }, + "node_modules/apollo-link-http": { + "version": "1.5.17", + "license": "MIT", + "dependencies": { + "apollo-link": "^1.2.14", + "apollo-link-http-common": "^0.2.16", + "tslib": "^1.9.3" + }, + "peerDependencies": { + "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0" + } + }, + "node_modules/apollo-link-http-common": { + "version": "0.2.16", + "license": "MIT", + "dependencies": { + "apollo-link": "^1.2.14", + "ts-invariant": "^0.4.0", + "tslib": "^1.9.3" + }, + "peerDependencies": { + "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0" + } + }, + "node_modules/apollo-utilities": { + "version": "1.3.4", + "license": "MIT", + "dependencies": { + "@wry/equality": "^0.1.2", + "fast-json-stable-stringify": "^2.0.0", + "ts-invariant": "^0.4.0", + "tslib": "^1.10.0" + }, + "peerDependencies": { + "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0" + } + }, + "node_modules/append-buffer": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "license": "Python-2.0" + }, + "node_modules/arr-diff": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/arr-union": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-back": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-differ": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arrify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asn1.js": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", + "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/assert": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es6-object-assign": "^1.1.0", + "is-nan": "^1.2.1", + "object-is": "^1.0.1", + "util": "^0.12.0" + } + }, + "node_modules/assign-symbols": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/b4a": { + "version": "1.6.7", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/bare-events": { + "version": "2.6.0", + "dev": true, + "license": "Apache-2.0", + "optional": true + }, + "node_modules/bare-fs": { + "version": "4.1.6", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.1", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.6.5", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "license": "Apache-2.0" + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/brorand": { + "version": "1.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "dev": true, + "license": "ISC" + }, + "node_modules/browserify-aes": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-xor": "^1.0.3", + "cipher-base": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.3", + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/browserify-cipher": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "browserify-aes": "^1.0.4", + "browserify-des": "^1.0.0", + "evp_bytestokey": "^1.0.0" + } + }, + "node_modules/browserify-des": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.1", + "des.js": "^1.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/browserify-rsa": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.1.tgz", + "integrity": "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^5.2.1", + "randombytes": "^2.1.0", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/browserify-rsa/node_modules/bn.js": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", + "dev": true, + "license": "MIT" + }, + "node_modules/browserify-sign": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.5.tgz", + "integrity": "sha512-C2AUdAJg6rlM2W5QMp2Q4KGQMVBwR1lIimTsUnutJ8bMpW5B52pGpR2gEnNBNwijumDo5FojQ0L9JrXA8m4YEw==", + "dev": true, + "license": "ISC", + "dependencies": { + "bn.js": "^5.2.2", + "browserify-rsa": "^4.1.1", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "elliptic": "^6.6.1", + "inherits": "^2.0.4", + "parse-asn1": "^5.1.9", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/browserify-sign/node_modules/bn.js": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", + "dev": true, + "license": "MIT" + }, + "node_modules/browserify-zlib": { + "version": "0.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "pako": "~0.2.0" + } + }, + "node_modules/browserify-zlib/node_modules/pako": { + "version": "0.2.9", + "dev": true, + "license": "MIT" + }, + "node_modules/browserslist": { + "version": "4.28.1", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-equal": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/buffer-xor": { + "version": "1.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/builtin-status-codes": { + "version": "3.0.0", + "license": "MIT" + }, + "node_modules/c8": { + "version": "9.1.0", + "dev": true, + "license": "ISC", + "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" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": ">=14.14.0" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bind-apply-helpers/node_modules/function-bind": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001769", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/charenc": { + "version": "0.0.2", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chokidar/node_modules/readdirp": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^1.9.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/cipher-base": { + "version": "1.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-buffer": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/clone-stats": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cloneable-readable": { + "version": "1.1.3", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "process-nextick-args": "^2.0.0", + "readable-stream": "^2.3.5" + } + }, + "node_modules/cockatiel": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "1.2.2", + "dev": true, + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/command-line-usage": { + "version": "6.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "array-back": "^4.0.1", + "chalk": "^2.4.2", + "table-layout": "^1.0.1", + "typical": "^5.2.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/command-line-usage/node_modules/ansi-styles": { + "version": "3.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/command-line-usage/node_modules/chalk": { + "version": "2.4.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/command-line-usage/node_modules/color-convert": { + "version": "1.9.3", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/command-line-usage/node_modules/color-name": { + "version": "1.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/command-line-usage/node_modules/escape-string-regexp": { + "version": "1.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/command-line-usage/node_modules/has-flag": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/command-line-usage/node_modules/supports-color": { + "version": "5.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "dev": true, + "license": "MIT" + }, + "node_modules/commandpost": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/constants-browserify": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "1.8.0", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.1" + } + }, + "node_modules/convert-source-map/node_modules/safe-buffer": { + "version": "5.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/cookies": { + "version": "0.9.1", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig/node_modules/import-fresh": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/create-ecdh": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.1.0", + "elliptic": "^6.5.3" + } + }, + "node_modules/create-hash": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "node_modules/create-hmac": { + "version": "1.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-fetch": { + "version": "3.1.5", + "license": "MIT", + "dependencies": { + "node-fetch": "2.6.7" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypt": { + "version": "0.0.2", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/crypto-browserify": { + "version": "3.12.0", + "dev": true, + "license": "MIT", + "dependencies": { + "browserify-cipher": "^1.0.0", + "browserify-sign": "^4.0.0", + "create-ecdh": "^4.0.0", + "create-hash": "^1.1.0", + "create-hmac": "^1.1.0", + "diffie-hellman": "^5.0.0", + "inherits": "^2.0.1", + "pbkdf2": "^3.0.3", + "public-encrypt": "^4.0.0", + "randombytes": "^2.0.0", + "randomfill": "^1.0.3" + }, + "engines": { + "node": "*" + } + }, + "node_modules/css-loader": { + "version": "7.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.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.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/csstype": { + "version": "3.0.7", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dayjs": { + "version": "1.10.4", + "license": "MIT" + }, + "node_modules/debounce": { + "version": "1.2.1", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-equal": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.2.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/des.js": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diffie-hellman": { + "version": "5.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dom-testing-library": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.4.3", + "@sheerun/mutationobserver-shim": "^0.3.2", + "pretty-format": "^24.7.0", + "wait-for-expect": "^1.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer": { + "version": "0.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/duplexify": { + "version": "3.7.1", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.0", + "stream-shift": "^1.0.0" + } + }, + "node_modules/duplexify/node_modules/end-of-stream": { + "version": "1.4.5", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "0.15.3", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^2.19.0", + "lru-cache": "^4.1.5", + "semver": "^5.6.0", + "sigmund": "^1.0.1" + }, + "bin": { + "editorconfig": "bin/editorconfig" + } + }, + "node_modules/editorconfig/node_modules/lru-cache": { + "version": "4.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "node_modules/editorconfig/node_modules/semver": { + "version": "5.7.2", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/editorconfig/node_modules/yallist": { + "version": "2.1.2", + "dev": true, + "license": "ISC" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "dev": true, + "license": "ISC" + }, + "node_modules/elliptic": { + "version": "6.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/enquirer": { + "version": "2.3.6", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/envinfo": { + "version": "7.7.4", + "dev": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.0", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract/node_modules/has-symbols": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract/node_modules/object.assign": { + "version": "4.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-to-primitive/node_modules/has-symbols": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-to-primitive/node_modules/is-symbol": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es6-object-assign": { + "version": "1.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/esbuild-loader": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/esbuild-loader/-/esbuild-loader-4.5.0.tgz", + "integrity": "sha512-rVYe97IN1+VoealVHQ3STEPbfTm+vvPk7AZPrL53Pt6JUGBhHTnIqyaow7EveMFlmQ2A1bLp7q19sP4PppaCDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.28.1", + "get-tsconfig": "^4.10.1", + "loader-utils": "^2.0.4", + "webpack-sources": "^3.3.4" + }, + "funding": { + "url": "https://github.com/privatenumber/esbuild-loader?sponsor=1" + }, + "peerDependencies": { + "webpack": "^4.40.0 || ^5.0.0" + } + }, + "node_modules/esbuild-loader/node_modules/loader-utils": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.36.0", + "dev": true, + "license": "MIT", + "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.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", + "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", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-import-context": { + "version": "0.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "4.4.4", + "dev": true, + "license": "ISC", + "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" + }, + "engines": { + "node": "^16.17.0 || >=18.6.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-typescript/node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-typescript/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/eslint-import-resolver-typescript/node_modules/tinyglobby": { + "version": "0.2.15", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.31.0", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-rulesdir": { + "version": "0.2.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/eslint-scope/node_modules/estraverse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint/node_modules/eslint-scope": { + "version": "8.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.2.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/event-stream": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer": "^0.1.1", + "from": "^0.1.7", + "map-stream": "0.0.7", + "pause-stream": "^0.0.11", + "split": "^1.0.1", + "stream-combiner": "^0.2.2", + "through": "^2.3.8" + } + }, + "node_modules/events": { + "version": "3.2.0", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/evp_bytestokey": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "md5.js": "^1.3.4", + "safe-buffer": "^5.1.1" + } + }, + "node_modules/execa": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.0", + "get-stream": "^5.0.0", + "human-signals": "^1.1.1", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.0", + "onetime": "^5.1.0", + "signal-exit": "^3.0.2", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.3", + "dev": true, + "license": "ISC" + }, + "node_modules/extend": { + "version": "3.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/extend-shallow": { + "version": "3.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.11.0", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/flush-write-stream": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "readable-stream": "^2.3.6" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fork-ts-checker-webpack-plugin": { + "version": "9.1.0", + "dev": true, + "license": "MIT", + "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": "^10.0.0", + "memfs": "^3.4.1", + "minimatch": "^3.0.4", + "node-abort-controller": "^3.0.1", + "schema-utils": "^3.1.1", + "semver": "^7.3.5", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "typescript": ">3.6.0", + "webpack": "^5.11.0" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/from": { + "version": "0.1.7", + "dev": true, + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-mkdirp-stream": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11", + "through2": "^2.0.3" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/fs-monkey": { + "version": "1.1.0", + "dev": true, + "license": "Unlicense" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic/node_modules/function-bind": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic/node_modules/has-symbols": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.11.0", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "7.1.6", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob-stream": { + "version": "6.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "extend": "^3.0.0", + "glob": "^7.1.1", + "glob-parent": "^3.1.0", + "is-negated-glob": "^1.0.0", + "ordered-read-streams": "^1.0.0", + "pumpify": "^1.3.5", + "readable-stream": "^2.1.5", + "remove-trailing-separator": "^1.0.1", + "to-absolute-glob": "^2.0.0", + "unique-stream": "^2.0.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/glob-stream/node_modules/glob": { + "version": "7.2.3", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-stream/node_modules/glob-parent": { + "version": "3.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^3.1.0", + "path-dirname": "^1.0.0" + } + }, + "node_modules/glob-stream/node_modules/is-glob": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/globals": { + "version": "16.4.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/graphql": { + "version": "15.5.0", + "license": "MIT", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/graphql-tag": { + "version": "2.11.0", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0" + } + }, + "node_modules/gulp-filter": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "multimatch": "^5.0.0", + "plugin-error": "^1.0.1", + "streamfilter": "^3.0.0", + "to-absolute-glob": "^2.0.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + }, + "peerDependencies": { + "gulp": ">=4" + }, + "peerDependenciesMeta": { + "gulp": { + "optional": true + } + } + }, + "node_modules/gunzip-maybe": { + "version": "1.4.2", + "dev": true, + "license": "MIT", + "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" + }, + "bin": { + "gunzip-maybe": "bin.js" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag/node_modules/has-symbols": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hash-base": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz", + "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/hash.js": { + "version": "1.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hasown/node_modules/function-bind": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "dev": true, + "license": "MIT", + "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" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/he": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/http-assert": { + "version": "1.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-equal": "~1.0.1", + "http-errors": "~1.8.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors": { + "version": "1.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-errors/node_modules/depd": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "1.1.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.12.0" + } + }, + "node_modules/husky": { + "version": "8.0.1", + "dev": true, + "license": "MIT", + "bin": { + "husky": "lib/bin.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.2.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/interpret": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-absolute": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-relative": "^1.0.0", + "is-windows": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-arguments": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "dev": true, + "license": "MIT" + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-deflate": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extendable": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-gzip": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-nan": { + "version": "1.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negated-glob": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-relative": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unc-path": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unc-path": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "unc-path-regex": "^0.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-utf8": { + "version": "0.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/is-valid-glob": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom-global": { + "version": "3.0.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "jsdom": ">=10.0.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.2", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/just-extend": { + "version": "4.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/keygrip": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/koa": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^1.3.8", + "content-disposition": "~1.0.1", + "content-type": "^1.0.5", + "cookies": "~0.9.1", + "delegates": "^1.0.0", + "destroy": "^1.2.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "fresh": "~0.5.2", + "http-assert": "^1.5.0", + "http-errors": "^2.0.0", + "koa-compose": "^4.1.0", + "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" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/koa-compose": { + "version": "4.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/koa-morgan": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "morgan": "^1.6.1" + } + }, + "node_modules/koa-mount": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.1", + "koa-compose": "^4.1.0" + }, + "engines": { + "node": ">= 7.6.0" + } + }, + "node_modules/koa-send": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "http-errors": "^1.7.3", + "resolve-path": "^1.4.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/koa-static": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.1.0", + "koa-send": "^5.0.0" + }, + "engines": { + "node": ">= 7.6.0" + } + }, + "node_modules/koa-static/node_modules/debug": { + "version": "3.2.7", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/koa/node_modules/http-errors": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/koa/node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/koa/node_modules/mime-db": { + "version": "1.54.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/mime-types": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/statuses": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lead": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "flush-write-stream": "^1.0.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/levn/node_modules/prelude-ls": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/levn/node_modules/type-check": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lines-and-columns": { + "version": "1.1.6", + "dev": true, + "license": "MIT" + }, + "node_modules/linkify-it": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/loader-runner": { + "version": "4.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/loader-utils": { + "version": "1.4.2", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^1.0.1" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/loader-utils/node_modules/json5": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "dev": true, + "license": "ISC" + }, + "node_modules/map-stream": { + "version": "0.0.7", + "dev": true, + "license": "MIT" + }, + "node_modules/markdown-it": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.1", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/marked": { + "version": "4.0.10", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/md5": { + "version": "2.3.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "node_modules/md5.js": { + "version": "1.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "dev": true, + "license": "MIT", + "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" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/mdurl": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/media-typer": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/memfs": { + "version": "3.6.0", + "dev": true, + "license": "Unlicense", + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-options": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/miller-rabin": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "brorand": "^1.0.1" + }, + "bin": { + "miller-rabin": "bin/miller-rabin" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "dev": true, + "license": "ISC" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.7", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha": { + "version": "11.7.5", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz", + "integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==", + "dev": true, + "license": "MIT", + "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", + "is-path-inside": "^3.0.3", + "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" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/mocha-junit-reporter": { + "version": "1.23.0", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^2.2.0", + "md5": "^2.1.0", + "mkdirp": "~0.5.1", + "strip-ansi": "^4.0.0", + "xml": "^1.0.0" + }, + "peerDependencies": { + "mocha": ">=2.2.5" + } + }, + "node_modules/mocha-junit-reporter/node_modules/ansi-regex": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mocha-junit-reporter/node_modules/debug": { + "version": "2.6.9", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/mocha-junit-reporter/node_modules/mkdirp": { + "version": "0.5.5", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mocha-junit-reporter/node_modules/ms": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/mocha-junit-reporter/node_modules/strip-ansi": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mocha-multi-reporters": { + "version": "1.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.1.0", + "lodash": "^4.16.4" + } + }, + "node_modules/mocha-multi-reporters/node_modules/debug": { + "version": "3.2.7", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/mocha/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/mocha/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "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" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/morgan": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz", + "integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.4.1", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/multimatch": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "^3.0.3", + "array-differ": "^3.0.0", + "array-union": "^2.1.0", + "arrify": "^2.0.1", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/multimatch/node_modules/@types/minimatch": { + "version": "3.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/nise": { + "version": "4.1.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1.7.0", + "@sinonjs/fake-timers": "^6.0.0", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.6.7", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/now-and-later": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.3.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.5", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "has-symbols": "^1.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optimism": { + "version": "0.10.3", + "license": "MIT", + "dependencies": { + "@wry/context": "^0.4.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "dev": true, + "license": "MIT", + "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.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/optionator/node_modules/prelude-ls": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/optionator/node_modules/type-check": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.4.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/emoji-regex": { + "version": "10.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/ora/node_modules/is-unicode-supported": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/log-symbols": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/string-width": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/ordered-read-streams": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.1" + } + }, + "node_modules/os-browserify": { + "version": "0.3.0", + "dev": true, + "license": "MIT" + }, + "node_modules/own-keys": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-all": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-map": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/pako": { + "version": "1.0.11", + "dev": true, + "license": "(MIT AND Zlib)" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-asn1": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.9.tgz", + "integrity": "sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "asn1.js": "^4.10.1", + "browserify-aes": "^1.2.0", + "evp_bytestokey": "^1.0.3", + "pbkdf2": "^3.1.5", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/path-dirname": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "dev": true, + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", + "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "isarray": "0.0.1" + } + }, + "node_modules/path-to-regexp/node_modules/isarray": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pause-stream": { + "version": "0.0.11", + "dev": true, + "license": [ + "MIT", + "Apache2" + ], + "dependencies": { + "through": "~2.3" + } + }, + "node_modules/pbkdf2": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz", + "integrity": "sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "ripemd160": "^2.0.3", + "safe-buffer": "^5.2.1", + "sha.js": "^2.4.12", + "to-buffer": "^1.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/peek-stream": { + "version": "1.1.3", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "duplexify": "^3.5.0", + "through2": "^2.0.3" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/playwright": { + "version": "1.56.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.54.1", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/playwright-core": { + "version": "1.56.1", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/plugin-error": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^1.0.1", + "arr-diff": "^4.0.0", + "arr-union": "^3.1.0", + "extend-shallow": "^3.0.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/plugin-error/node_modules/ansi-colors": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-wrap": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pretty-format": { + "version": "24.9.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^24.9.0", + "ansi-regex": "^4.0.0", + "ansi-styles": "^3.2.0", + "react-is": "^16.8.4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "3.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pretty-format/node_modules/color-convert": { + "version": "1.9.3", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/pretty-format/node_modules/color-name": { + "version": "1.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/process": { + "version": "0.11.10", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/prompts": { + "version": "2.4.2", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prop-types": { + "version": "15.7.2", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/pseudomap": { + "version": "1.0.2", + "dev": true, + "license": "ISC" + }, + "node_modules/public-encrypt": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.1.0", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "parse-asn1": "^5.0.0", + "randombytes": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/pump": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/pumpify": { + "version": "1.5.1", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexify": "^3.6.0", + "inherits": "^2.0.3", + "pump": "^2.0.0" + } + }, + "node_modules/pumpify/node_modules/pump": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.2", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/randombytes": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/randomfill": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "randombytes": "^2.0.5", + "safe-buffer": "^5.1.0" + } + }, + "node_modules/react": { + "version": "16.14.0", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "16.14.0", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.19.1" + }, + "peerDependencies": { + "react": "^16.14.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "license": "MIT" + }, + "node_modules/react-testing-library": { + "version": "7.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.4.3", + "dom-testing-library": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "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" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rechoir": { + "version": "0.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.9.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/reduce-flatten": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/remove-bom-buffer": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5", + "is-utf8": "^0.2.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/remove-bom-stream": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "remove-bom-buffer": "^3.0.0", + "safe-buffer": "^5.1.0", + "through2": "^2.0.3" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/remove-trailing-separator": { + "version": "1.1.0", + "dev": true, + "license": "ISC" + }, + "node_modules/replace-ext": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-options": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "value-or-function": "^3.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/resolve-path": { + "version": "1.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "http-errors": "~1.6.2", + "path-is-absolute": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/resolve-path/node_modules/depd": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/resolve-path/node_modules/http-errors": { + "version": "1.6.3", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/resolve-path/node_modules/inherits": { + "version": "2.0.3", + "dev": true, + "license": "ISC" + }, + "node_modules/resolve-path/node_modules/setprototypeof": { + "version": "1.1.0", + "dev": true, + "license": "ISC" + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "2.6.3", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/ripemd160": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", + "integrity": "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hash-base": "^3.1.2", + "inherits": "^2.0.4" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat/node_modules/has-symbols": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.19.1", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "node_modules/schema-utils": { + "version": "3.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-javascript": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-length/node_modules/function-bind": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "dev": true, + "license": "ISC" + }, + "node_modules/sha.js": { + "version": "2.4.12", + "dev": true, + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sigmund": { + "version": "1.0.1", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-html-tokenizer": { + "version": "0.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/sinon": { + "version": "9.0.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1.7.0", + "@sinonjs/fake-timers": "^6.0.0", + "@sinonjs/formatio": "^5.0.0", + "@sinonjs/samsam": "^5.0.1", + "diff": "^4.0.2", + "nise": "^4.0.1", + "supports-color": "^7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.6.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.19", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/split": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "through": "2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ssh-config": { + "version": "4.1.1", + "license": "MIT" + }, + "node_modules/stable-hash-x": { + "version": "0.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/statuses": { + "version": "1.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/stream-browserify": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "~2.0.4", + "readable-stream": "^3.5.0" + } + }, + "node_modules/stream-browserify/node_modules/readable-stream": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/stream-combiner": { + "version": "0.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer": "~0.1.1", + "through": "~2.3.4" + } + }, + "node_modules/stream-http": { + "version": "3.2.0", + "license": "MIT", + "dependencies": { + "builtin-status-codes": "^3.0.0", + "inherits": "^2.0.4", + "readable-stream": "^3.6.0", + "xtend": "^4.0.2" + } + }, + "node_modules/stream-http/node_modules/readable-stream": { + "version": "3.6.0", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/stream-shift": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/streamfilter": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^3.0.6" + }, + "engines": { + "node": ">=8.12.0" + } + }, + "node_modules/streamfilter/node_modules/readable-stream": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/streamx": { + "version": "2.22.1", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + }, + "optionalDependencies": { + "bare-events": "^2.2.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-loader": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.27.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-inline-loader": { + "version": "0.8.2", + "dev": true, + "license": "MIT", + "dependencies": { + "loader-utils": "^1.1.0", + "object-assign": "^4.0.1", + "simple-html-tokenizer": "^0.1.1" + } + }, + "node_modules/symbol-observable": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "dev": true, + "license": "MIT" + }, + "node_modules/table-layout": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "array-back": "^4.0.1", + "deep-extend": "~0.6.0", + "typical": "^5.2.0", + "wordwrapjs": "^4.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/tapable": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar-fs": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", + "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/tas-client": { + "version": "0.2.33", + "license": "MIT" + }, + "node_modules/temp": { + "version": "0.9.4", + "dev": true, + "license": "MIT", + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp/node_modules/mkdirp": { + "version": "0.5.5", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/temporal-polyfill": { + "version": "0.3.0", + "license": "MIT", + "dependencies": { + "temporal-spec": "0.3.0" + } + }, + "node_modules/temporal-spec": { + "version": "0.3.0", + "license": "ISC" + }, + "node_modules/terser": { + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.17", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.17.tgz", + "integrity": "sha512-YR7PtUp6GMU91BgSJmlaX/rS2lGDbAF7D+Wtq7hRO+MiljNmodYvqslzCFiYVAgW+Qoaaia/QUIP4lGXufjdZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser/node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/through": { + "version": "2.3.8", + "dev": true, + "license": "MIT" + }, + "node_modules/through2": { + "version": "2.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "node_modules/through2-filter": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "through2": "~2.0.0", + "xtend": "~4.0.0" + } + }, + "node_modules/timers-browserify": { + "version": "2.0.12", + "dev": true, + "license": "MIT", + "dependencies": { + "setimmediate": "^1.0.4" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.6", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tldts": { + "version": "7.0.25", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.25.tgz", + "integrity": "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.25" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.25", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.25.tgz", + "integrity": "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-absolute-glob": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "is-absolute": "^1.0.0", + "is-negated-glob": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-buffer": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/to-through": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "through2": "^2.0.3" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-invariant": { + "version": "0.4.4", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + } + }, + "node_modules/ts-loader": { + "version": "9.5.2", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "typescript": "*", + "webpack": "^5.0.0" + } + }, + "node_modules/ts-loader/node_modules/source-map": { + "version": "0.7.4", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "dev": true, + "license": "MIT", + "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" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "1.14.1", + "license": "0BSD" + }, + "node_modules/tsscmp": { + "version": "1.0.6", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, + "node_modules/tty": { + "version": "1.0.1", + "dev": true, + "license": "ISC" + }, + "node_modules/tunnel": { + "version": "0.0.6", + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.44.1", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-formatter": { + "version": "7.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "commandpost": "^1.0.0", + "editorconfig": "^0.15.0" + }, + "bin": { + "tsfmt": "bin/tsfmt" + }, + "engines": { + "node": ">= 4.2.0" + }, + "peerDependencies": { + "typescript": "^2.1.6 || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >=3.0.0-dev" + } + }, + "node_modules/typical": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unbox-primitive/node_modules/has-symbols": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unc-path-regex": { + "version": "0.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "license": "MIT" + }, + "node_modules/unique-stream": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "json-stable-stringify-without-jsonify": "^1.0.1", + "through2-filter": "^3.0.0" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "license": "ISC" + }, + "node_modules/universalify": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "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" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-search-params-polyfill": { + "version": "8.1.1", + "license": "MIT" + }, + "node_modules/util": { + "version": "0.12.4", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "safe-buffer": "^5.1.2", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/v8-compile-cache": { + "version": "2.3.0", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/v8-to-istanbul/node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/v8-to-istanbul/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/v8-to-istanbul/node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul/node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/value-or-function": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vinyl": { + "version": "2.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^2.1.1", + "clone-buffer": "^1.0.0", + "clone-stats": "^1.0.0", + "cloneable-readable": "^1.0.0", + "remove-trailing-separator": "^1.0.1", + "replace-ext": "^1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vinyl-fs": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "fs-mkdirp-stream": "^1.0.0", + "glob-stream": "^6.1.0", + "graceful-fs": "^4.0.0", + "is-valid-glob": "^1.0.0", + "lazystream": "^1.0.0", + "lead": "^1.0.0", + "object.assign": "^4.0.4", + "pumpify": "^1.3.5", + "readable-stream": "^2.3.3", + "remove-bom-buffer": "^3.0.0", + "remove-bom-stream": "^1.2.0", + "resolve-options": "^1.1.0", + "through2": "^2.0.0", + "to-through": "^2.0.0", + "value-or-function": "^3.0.0", + "vinyl": "^2.0.0", + "vinyl-sourcemap": "^1.1.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vinyl-sourcemap": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "append-buffer": "^1.0.2", + "convert-source-map": "^1.5.0", + "graceful-fs": "^4.1.6", + "normalize-path": "^2.1.1", + "now-and-later": "^2.0.0", + "remove-bom-buffer": "^3.0.0", + "vinyl": "^2.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vinyl-sourcemap/node_modules/normalize-path": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "remove-trailing-separator": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vscode-tas-client": { + "version": "0.1.84", + "license": "MIT", + "dependencies": { + "tas-client": "0.2.33" + }, + "engines": { + "vscode": "^1.85.0" + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/vsls": { + "version": "0.3.1291", + "license": "SEE LICENSE IN LICENSE.txt" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/wait-for-expect": { + "version": "1.3.0", + "dev": true, + "license": "MIT" + }, + "node_modules/watchpack": { + "version": "2.5.1", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/webpack": { + "version": "5.104.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.4", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.16", + "watchpack": "^2.4.4", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@webpack-cli/info": "^1.1.0", + "@webpack-cli/serve": "^1.1.0", + "colorette": "^1.2.1", + "command-line-usage": "^6.1.0", + "commander": "^6.2.0", + "enquirer": "^2.3.6", + "execa": "^4.1.0", + "import-local": "^3.0.2", + "interpret": "^2.2.0", + "leven": "^3.1.0", + "rechoir": "^0.7.0", + "v8-compile-cache": "^2.2.0", + "webpack-merge": "^4.2.2" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "webpack": "4.x.x || 5.x.x" + }, + "peerDependenciesMeta": { + "@webpack-cli/generate-loader": { + "optional": true + }, + "@webpack-cli/generate-plugin": { + "optional": true + }, + "@webpack-cli/init": { + "optional": true + }, + "@webpack-cli/migrate": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/webpack-merge": { + "version": "4.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + } + }, + "node_modules/webpack-sources": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz", + "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack/node_modules/events": { + "version": "3.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/webpack/node_modules/terser-webpack-plugin": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", + "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-boxed-primitive/node_modules/has-symbols": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-boxed-primitive/node_modules/is-symbol": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrapjs": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "reduce-flatten": "^2.0.0", + "typical": "^5.2.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/workerpool": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", + "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "dev": true, + "license": "ISC" + }, + "node_modules/xml": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "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" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zen-observable": { + "version": "0.8.15", + "license": "MIT" + }, + "node_modules/zen-observable-ts": { + "version": "0.8.21", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3", + "zen-observable": "^0.8.0" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/package.json b/package.json index 577c5fe9bb..e33f921997 100644 --- a/package.json +++ b/package.json @@ -1,1289 +1,4348 @@ { - "name": "vscode-pull-request-github", - "displayName": "GitHub Pull Requests and Issues", - "description": "Pull Request and Issue Provider for GitHub", - "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" - }, - "enableProposedApi": true, - "preview": true, - "version": "0.19.0", - "publisher": "GitHub", - "engines": { - "vscode": "^1.48.0" - }, - "categories": [ - "Other" - ], - "activationEvents": [ - "onStartupFinished", - "onFileSystem:newIssue" - ], - "extensionDependencies": [ - "vscode.git" - ], - "main": "./media/extension", - "contributes": { - "configuration": { - "type": "object", - "title": "GitHub Pull Requests", - "properties": { - "githubPullRequests.telemetry.enabled": { - "type": "boolean", - "default": true, - "description": "Enable usage data and errors to be sent to a GitHub online service" - }, - "githubPullRequests.hosts": { - "type": "array", - "default": [], - "markdownDescription": "List of host credentials. For example, \"githubPullRequests.hosts\": [ { \"host\": \"https://github.com\", \"token\": \"GITHUB TOKEN\" } ]", - "items": { - "type": "object", - "properties": { - "host": { - "type": "string", - "description": "The host name of the GitHub server (for eg., 'https://github.com')" - }, - "username": { - "type": "string", - "description": "The username to access GitHub (optional)" - }, - "token": { - "type": "string", - "description": "GitHub access token with the following scopes: read:user, user:email, repo, write:discussion" - } - } - } - }, - "githubPullRequests.pullRequestTitle": { - "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", - "custom", - "ask" - ], - "enumDescriptions": [ - "Use a pull request template, or use the commit description if no templates were found", - "Use the latest commit message", - "Specify a custom description", - "Ask which of the above methods to use" - ], - "default": "template", - "description": "The description used when creating pull requests." - }, - "githubPullRequests.logLevel": { - "type": "string", - "enum": [ - "info", - "debug", - "off" - ], - "default": "info", - "description": "Logging for GitHub Pull Request extension. The log is emitted to the output channel named as GitHub Pull Request." - }, - "githubPullRequests.remotes": { - "type": "array", - "default": [ - "origin", - "upstream" - ], - "items": { - "type": "string" - }, - "markdownDescription": "List of remotes, by name, to fetch pull requests from." - }, - "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": "The label to display for the query in the Pull Requests tree" - }, - "query": { - "type": "string", - "description": "The query used for searching pull requests." - } - } - }, - "scope": "resource", - "markdownDescription": "Specifies what queries should be used in the GitHub Pull Requests tree. 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.", - "default": [ - { - "label": "Waiting For My Review", - "query": "is:open review-requested:${user}" - }, - { - "label": "Assigned To Me", - "query": "is:open assignee:${user}" - }, - { - "label": "Created By Me", - "query": "is:open author:${user}" - } - ] - }, - "githubPullRequests.defaultMergeMethod": { - "type": "string", - "enum": [ - "merge", - "squash", - "rebase" - ], - "default": "merge", - "description": "The method to use when merging pull requests." - }, - "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.fileListLayout": { - "type": "string", - "enum": [ - "flat", - "tree" - ], - "default": "tree", - "description": "The layout to use when displaying changed files list." - }, - "githubPullRequests.defaultDeletionMethod.selectLocalBranch": { - "type": "boolean", - "default": true, - "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": { - "type": "boolean", - "default": true, - "description": "When true, the option to delete the remote will be selected by default when deleting a branch from a pull request." - }, - "telemetry.optout": { - "type": "boolean", - "default": false, - "deprecationMessage": "The setting `telemetry.optout` has been deprecated in favor of `githubPullRequests.telemetry.enabled`.", - "description": "Disable usage data and errors to be sent to a GitHub online service" - }, - "github.hosts": { - "type": "array", - "default": [], - "deprecationMessage": "The setting `github.hosts` has been deprecated in favor of `githubPullRequests.hosts`.", - "description": "List of host credentials. For example, \"github.hosts\": [ { \"host\": \"https://github.com\", \"token\": \"GITHUB TOKEN\" } ]", - "items": { - "type": "object", - "properties": { - "host": { - "type": "string", - "description": "The host name of the GitHub server (for eg., 'https://github.com')" - }, - "username": { - "type": "string", - "description": "The username to access GitHub (optional)" - }, - "token": { - "type": "string", - "description": "GitHub access token with the following scopes: read:user, user:email, repo, write:discussion" - } - } - } - }, - "githubIssues.ignoreMilestones": { - "type": "array", - "default": [], - "description": "An array of milestones titles to never show issues from." - }, - "githubIssues.createIssueTriggers": { - "type": "array", - "items": { - "type": "string", - "description": "String that enables the 'Create issue from comment' code action. Should not contain whitespace." - }, - "default": [ - "TODO", - "todo", - "BUG", - "FIXME", - "ISSUE", - "HACK" - ], - "description": "Strings that will cause the 'Create issue from comment' code action to show." - }, - "githubIssues.createInsertFormat": { - "type": "string", - "enum": [ - "number", - "url" - ], - "default": "number", - "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": { - "type": "boolean", - "default": true, - "description": "Controls whether completion suggestions are shown for issues." - }, - "githubIssues.userCompletions.enabled": { - "type": "boolean", - "default": true, - "description": "Controls whether completion suggestions are shown for users." - }, - "githubIssues.ignoreCompletionTrigger": { - "type": "array", - "items": { - "type": "string", - "description": "Language that issue completions should not trigger on '#'." - }, - "default": [ - "python", - "makefile" - ], - "description": "Languages that the '#' character should not be used to trigger issue completion suggestions." - }, - "githubIssues.ignoreUserCompletionTrigger": { - "type": "array", - "items": { - "type": "string", - "description": "Language that user completions should not trigger on '@'." - }, - "default": [ - "python" - ], - "description": "Languages that the '@' character should not be used to trigger user completion suggestions." - }, - "githubIssues.workingIssueBranch": { - "type": "string", - "deprecationMessage": "This setting is replaced by the better named setting githubIssues.issueBranchTitle and may be removed in the future.", - "description": "Advanced settings for the name of the branch that is created when you start working on an issue. ${user} will be replace with the currently logged in username and ${issueNumber} will be replaced with the current issue number. You can also use ${sanitizedIssueTitle}." - }, - "githubIssues.issueBranchTitle": { - "type": "string", - "default": "${user}/issue${issueNumber}", - "markdownDescription": "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 removed" - }, - "githubIssues.useBranchForIssues": { - "type": "string", - "enum": [ - "on", - "off", - "prompt" - ], - "enumDescriptions": [ - "A branch will always be checked out when you start working on an issue. If the branch doesn't exist, it will be created.", - "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.", - "A prompt will show for setting the name of the branch that will be created and checked out." - ], - "default": "on", - "description": "Determines whether a branch should be checked out when working on an issue. To configure the name of the branch, set `githubIssues.issueBranchTitle`." - }, - "githubIssues.issueCompletionFormatScm": { - "type": "string", - "default": "${issueTitle} ${issueNumberLabel}", - "markdownDescription": "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" - }, - "githubIssues.workingIssueFormatScm": { - "type": "string", - "default": "${issueTitle} \nFixes ${issueNumberLabel}", - "markdownDescription": "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}`" - }, - "githubIssues.queries": { - "type": "array", - "items": { - "type": "object", - "properties": { - "label": { - "type": "string", - "description": "The label to display for the query in the Issues tree." - }, - "query": { - "type": "string", - "markdownDescription": "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}`." - } - } - }, - "scope": "resource", - "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.", - "default": [ - { - "label": "My Issues", - "query": "default" - }, - { - "label": "Created Issues", - "query": "author:${user} state:open repo:${owner}/${repository} sort:created-desc" - } - ] - } - } - }, - "viewsContainers": { - "activitybar": [ - { - "id": "github-pull-requests", - "title": "GitHub", - "icon": "resources/icons/pull-request.svg" - } - ] - }, - "views": { - "github-pull-requests": [ - { - "id": "pr:github", - "name": "Pull Requests", - "when": "config.git.enabled", - "icon": "resources/icons/pull-request.svg" - }, - { - "id": "prStatus:github", - "name": "Changes In Pull Request", - "when": "config.git.enabled && github:inReviewMode", - "icon": "resources/icons/pull-request.svg" - }, - { - "id": "issues:github", - "name": "Issues", - "when": "config.git.enabled", - "icon": "resources/icons/light/issues.svg" - } - ] - }, - "commands": [ - { - "command": "pr.create", - "title": "Create Pull Request", - "icon": "$(plus)", - "category": "GitHub Pull Requests" - }, - { - "command": "pr.createDraft", - "title": "Create Draft Pull Request", - "icon": "$(plus)", - "category": "GitHub Pull Requests" - }, - { - "command": "auth.signout", - "title": "Sign out of GitHub", - "category": "GitHub Pull Requests" - }, - { - "command": "pr.pick", - "title": "Checkout Pull Request", - "category": "GitHub Pull Requests" - }, - { - "command": "pr.merge", - "title": "Merge Pull Request", - "category": "GitHub Pull Requests" - }, - { - "command": "pr.readyForReview", - "title": "Mark Pull Request Ready For Review", - "category": "GitHub Pull Requests" - }, - { - "command": "pr.close", - "title": "Close Pull Request", - "category": "GitHub Pull Requests" - }, - { - "command": "pr.openPullRequestInGitHub", - "title": "Open Pull Request in GitHub", - "category": "GitHub Pull Requests" - }, - { - "command": "pr.refreshPullRequest", - "title": "Refresh Pull Request", - "category": "GitHub Pull Requests" - }, - { - "command": "pr.openFileInGitHub", - "title": "Open File in GitHub", - "category": "GitHub Pull Requests" - }, - { - "command": "pr.copyCommitHash", - "title": "Copy Commit Hash", - "category": "GitHub Pull Requests" - }, - { - "command": "pr.openOriginalFile", - "title": "Open Original File", - "category": "GitHub Pull Requests" - }, - { - "command": "pr.openModifiedFile", - "title": "Open Modified File", - "category": "GitHub Pull Requests" - }, - { - "command": "pr.openDiffView", - "title": "Open Diff View", - "category": "GitHub Pull Requests", - "icon": "$(compare-changes)" - }, - { - "command": "pr.openChangedFile", - "title": "Open Changed File in PR", - "category": "GitHub Pull Requests" - }, - { - "command": "pr.openDescription", - "title": "View Pull Request Description", - "category": "GitHub Pull Requests", - "when": "github:inReviewMode" - }, - { - "command": "pr.openDescriptionToTheSide", - "title": "Open Pull Request Description to the Side", - "icon": "$(split-horizontal)" - }, - { - "command": "pr.refreshDescription", - "title": "Refresh Pull Request Description", - "category": "GitHub Pull Requests" - }, - { - "command": "review.openFile", - "title": "Open File", - "icon": "$(go-to-file)" - }, - { - "command": "review.suggestDiff", - "title": "Suggest Edit" - }, - { - "command": "pr.refreshList", - "title": "Refresh Pull Requests List", - "icon": "$(refresh)", - "category": "GitHub Pull Requests" - }, - { - "command": "pr.refreshChanges", - "title": "Refresh", - "icon": "$(refresh)", - "category": "GitHub Pull Requests" - }, - { - "command": "pr.configurePRViewlet", - "title": "Configure...", - "category": "GitHub Pull Requests", - "icon": "$(gear)" - }, - { - "command": "pr.deleteLocalBranch", - "title": "Delete Local Branch", - "category": "GitHub Pull Requests" - }, - { - "command": "pr.signin", - "title": "Sign in to GitHub", - "category": "GitHub Pull Requests" - }, - { - "command": "pr.deleteLocalBranchesNRemotes", - "title": "Delete local branches and remotes", - "category": "GitHub Pull Requests" - }, - { - "command": "pr.createComment", - "title": "Add Comment", - "category": "GitHub Pull Requests", - "enablement": "!commentIsEmpty" - }, - { - "command": "pr.startReview", - "title": "Start Review", - "category": "GitHub Pull Requests", - "enablement": "!commentIsEmpty" - }, - { - "command": "pr.deleteReview", - "title": "Delete Review", - "category": "GitHub Pull Requests" - }, - { - "command": "pr.editComment", - "title": "Edit Comment", - "category": "GitHub Pull Requests", - "icon": "$(edit)" - }, - { - "command": "pr.cancelEditComment", - "title": "Cancel", - "category": "GitHub Pull Requests" - }, - { - "command": "pr.saveComment", - "title": "Save", - "category": "GitHub Pull Requests", - "enablement": "!commentIsEmpty" - }, - { - "command": "pr.deleteComment", - "title": "Delete Comment", - "category": "GitHub Pull Requests", - "icon": "$(close)" - }, - { - "command": "pr.finishReview", - "title": "Finish Review", - "category": "GitHub Pull Requests", - "enablement": "!commentIsEmpty" - }, - { - "command": "pr.signinAndRefreshList", - "title": "Sign in and Refresh", - "category": "GitHub Pull Requests" - }, - { - "command": "pr.configureRemotes", - "title": "Configure Remotes...", - "category": "GitHub Pull Requests" - }, - { - "command": "issue.createIssueFromSelection", - "title": "Create Issue From Selection", - "category": "GitHub Issues" - }, - { - "command": "issue.createIssueFromClipboard", - "title": "Create Issue From Clipboard", - "category": "GitHub Issues" - }, - { - "command": "issue.copyGithubPermalink", - "title": "Copy GitHub Permalink", - "category": "GitHub Issues" - }, - { - "command": "issue.openGithubPermalink", - "title": "Open Permalink in Github", - "category": "GitHub Issues" - }, - { - "command": "issue.openIssue", - "title": "Open Issue", - "category": "GitHub Issues", - "icon": "$(globe)" - }, - { - "command": "issue.copyIssueNumber", - "title": "Copy Number", - "category": "GitHub Issues" - }, - { - "command": "issue.copyIssueUrl", - "title": "Copy Url", - "category": "GitHub Issues" - }, - { - "command": "issue.refresh", - "title": "Refresh", - "category": "GitHub Issues", - "icon": "$(refresh)" - }, - { - "command": "issue.suggestRefresh", - "title": "Refresh Suggestions", - "category": "GitHub Issues" - }, - { - "command": "issue.startWorking", - "title": "Start Working on Issue", - "category": "GitHub Issues", - "icon": "$(arrow-right)" - }, - { - "command": "issue.startWorkingBranchDescriptiveTitle", - "title": "Start Working on Issue and Checkout Topic Branch", - "category": "GitHub Issues", - "icon": "$(arrow-right)" - }, - { - "command": "issue.continueWorking", - "title": "Continue Working on Issue", - "category": "GitHub Issues", - "icon": "$(arrow-right)" - }, - { - "command": "issue.startWorkingBranchPrompt", - "title": "Start Working and Set Branch...", - "category": "GitHub Issues" - }, - { - "command": "issue.stopWorking", - "title": "Stop Working on Issue", - "category": "GitHub Issues", - "icon": "$(primitive-square)" - }, - { - "command": "issue.stopWorkingBranchDescriptiveTitle", - "title": "Stop Working on Issue and Leave Topic Branch", - "category": "GitHub Issues", - "icon": "$(primitive-square)" - }, - { - "command": "issue.statusBar", - "title": "Current Issue Options", - "category": "GitHub Issues" - }, - { - "command": "issue.getCurrent", - "title": "Get current issue", - "category": "GitHub Issues" - }, - { - "command": "issue.editQuery", - "title": "Edit Query", - "category": "GitHub Issues", - "icon": "$(edit)" - }, - { - "command": "issue.createIssue", - "title": "Create an Issue", - "category": "GitHub Issues", - "icon": "$(plus)" - }, - { - "command": "issue.createIssueFromFile", - "title": "Create Issue", - "icon": "$(check)" - }, - { - "command": "issue.issueCompletion", - "title": "Issue Completion Chosen" - }, - { - "command": "issue.userCompletion", - "title": "User Completion Chosen" - }, - { - "command": "issue.signinAndRefreshList", - "title": "Sign in and Refresh", - "category": "GitHub Issues" - } - ], - "viewsWelcome": [ - { - "view": "pr:github", - "when": "ReposManagerStateContext == NeedsAuthentication", - "contents": "You have not yet signed in with GitHub\n[Sign in](command:pr.signin)" - }, - { - "view": "issues:github", - "when": "ReposManagerStateContext == NeedsAuthentication", - "contents": "You have not yet signed in with GitHub\n[Sign in](command:pr.signin)" - } - ], - "keybindings": [ - { - "key": "ctrl+shift+space", - "command": "issue.suggestRefresh", - "when": "suggestWidgetVisible" - }, - { - "key": "ctrl+s", - "command": "issue.createIssueFromFile", - "when": "resourceScheme == newIssue && config.files.autoSave != off" - } - ], - "menus": { - "commandPalette": [ - { - "command": "auth.signout", - "when": "gitOpenRepositoryCount != 0 && github:authenticated" - }, - { - "command": "pr.configureRemotes", - "when": "gitOpenRepositoryCount != 0" - }, - { - "command": "pr.configurePRViewlet", - "when": "gitOpenRepositoryCount != 0" - }, - { - "command": "pr.pick", - "when": "false" - }, - { - "command": "review.openFile", - "when": "false" - }, - { - "command": "pr.openChangedFile", - "when": "false" - }, - { - "command": "pr.close", - "when": "gitOpenRepositoryCount != 0 && github:inReviewMode" - }, - { - "command": "pr.create", - "when": "gitOpenRepositoryCount != 0 && github:authenticated && !github:inReviewMode" - }, - { - "command": "pr.createDraft", - "when": "gitOpenRepositoryCount != 0 && github:authenticated && !github:inReviewMode" - }, - { - "command": "pr.merge", - "when": "gitOpenRepositoryCount != 0 && github:inReviewMode" - }, - { - "command": "pr.readyForReview", - "when": "gitOpenRepositoryCount != 0 && github:inReviewMode" - }, - { - "command": "pr.openPullRequestInGitHub", - "when": "gitOpenRepositoryCount != 0 && github:inReviewMode" - }, - { - "command": "pr.refreshDescription", - "when": "gitOpenRepositoryCount != 0 && github:inReviewMode" - }, - { - "command": "pr.openFileInGitHub", - "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.openDescriptionToTheSide", - "when": "false" - }, - { - "command": "pr.openDescription", - "when": "gitOpenRepositoryCount != 0 && github:inReviewMode" - }, - { - "command": "pr.refreshList", - "when": "gitOpenRepositoryCount != 0 && github:authenticated && github:hasGitHubRemotes" - }, - { - "command": "pr.refreshChanges", - "when": "false" - }, - { - "command": "pr.signin", - "when": "gitOpenRepositoryCount != 0 && github:hasGitHubRemotes" - }, - { - "command": "pr.signinAndRefreshList", - "when": "false" - }, - { - "command": "pr.copyCommitHash", - "when": "false" - }, - { - "command": "pr.createComment", - "when": "false" - }, - { - "command": "pr.startReview", - "when": "false" - }, - { - "command": "pr.deleteReview", - "when": "false" - }, - { - "command": "pr.editComment", - "when": "false" - }, - { - "command": "pr.cancelEditComment", - "when": "false" - }, - { - "command": "pr.saveComment", - "when": "false" - }, - { - "command": "pr.deleteComment", - "when": "false" - }, - { - "command": "pr.finishReview", - "when": "false" - }, - { - "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.copyGithubPermalink", - "when": "github:hasGitHubRemotes && github:authenticated" - }, - { - "command": "issue.openGithubPermalink", - "when": "github:hasGitHubRemotes && github:authenticated" - }, - { - "command": "issue.issueCompletion", - "when": "false" - }, - { - "command": "issue.userCompletion", - "when": "false" - }, - { - "command": "issue.signinAndRefreshList", - "when": "false" - } - ], - "view/title": [ - { - "command": "pr.create", - "when": "gitOpenRepositoryCount != 0 && github:initialized && view =~ /pr:/", - "group": "navigation" - }, - { - "command": "pr.configurePRViewlet", - "when": "gitOpenRepositoryCount != 0 && github:initialized && view =~ /(pr|issues):/", - "group": "navigation" - }, - { - "command": "pr.refreshList", - "when": "gitOpenRepositoryCount != 0 && github:initialized && view =~ /pr:/", - "group": "navigation" - }, - { - "command": "pr.refreshChanges", - "when": "view =~ /prStatus/", - "group": "navigation" - }, - { - "command": "issue.refresh", - "when": "view =~ /issues:github/", - "group": "navigation" - }, - { - "command": "issue.createIssue", - "when": "view =~ /issues:github/ && github:hasGitHubRemotes", - "group": "navigation" - } - ], - "view/item/context": [ - { - "command": "pr.pick", - "when": "view =~ /pr:/ && viewItem =~ /pullrequest(:local)?:nonactive|description/", - "group": "pullrequest@1" - }, - { - "command": "pr.refreshPullRequest", - "when": "view =~ /pr:/ && viewItem =~ /pullrequest|description/", - "group": "pullrequest@2" - }, - { - "command": "pr.openPullRequestInGitHub", - "when": "view =~ /pr:/ && viewItem =~ /pullrequest|description/", - "group": "pullrequest@3" - }, - { - "command": "pr.deleteLocalBranch", - "when": "view =~ /pr:/ && viewItem =~ /pullrequest:local:nonactive/", - "group": "pullrequest@4" - }, - { - "command": "pr.openFileInGitHub", - "when": "view =~ /(pr|prStatus)/ && viewItem =~ /filechange/" - }, - { - "command": "pr.copyCommitHash", - "when": "view =~ /prStatus/ && viewItem =~ /commit/" - }, - { - "command": "pr.openDescriptionToTheSide", - "group": "inline", - "when": "view =~ /(pr|prStatus)/ && viewItem =~ /description/" - }, - { - "command": "review.openFile", - "group": "inline", - "when": "config.git.openDiffOnClick && view =~ /prStatus/ && viewItem =~ /filechange(?!:DELETE)/" - }, - { - "command": "pr.openDiffView", - "group": "inline", - "when": "!config.git.openDiffOnClick && view =~ /prStatus/ && viewItem =~ /filechange(?!:DELETE)/" - }, - { - "command": "pr.openOriginalFile", - "when": "view =~ /(pr|prStatus)/ && viewItem =~ /filechange:MODIFY/" - }, - { - "command": "pr.openModifiedFile", - "when": "view =~ /(pr|prStatus)/ && viewItem =~ /filechange:MODIFY/" - }, - { - "command": "issue.openIssue", - "when": "view =~ /issues:github/ && viewItem =~ /(current|continue)?issue/", - "group": "inline@2" - }, - { - "command": "issue.openIssue", - "when": "view =~ /issues:github/ && viewItem =~ /(current|continue)?issue/", - "group": "issues_0@0" - }, - { - "command": "issue.startWorking", - "when": "view =~ /issues:github/ && viewItem == issue && config.githubIssues.useBranchForIssues != on", - "group": "inline@1" - }, - { - "command": "issue.startWorkingBranchDescriptiveTitle", - "when": "view =~ /issues:github/ && viewItem == issue && config.githubIssues.useBranchForIssues == on", - "group": "inline@1" - }, - { - "command": "issue.startWorking", - "when": "view =~ /issues:github/ && viewItem == continueissue && config.githubIssues.useBranchForIssues != on", - "group": "inline@1" - }, - { - "command": "issue.startWorkingBranchDescriptiveTitle", - "when": "view =~ /issues:github/ && viewItem == continueissue && config.githubIssues.useBranchForIssues == on", - "group": "inline@1" - }, - { - "command": "issue.startWorking", - "alt": "issue.startWorkingBranchPrompt", - "when": "view =~ /issues:github/ && viewItem == issue", - "group": "issues_0@1" - }, - { - "command": "issue.continueWorking", - "when": "view =~ /issues:github/ && viewItem == continueissue", - "group": "issues_0@1" - }, - { - "command": "pr.create", - "when": "view =~ /issues:github/ && viewItem == currentissue", - "group": "issues_0@1" - }, - { - "command": "issue.stopWorking", - "when": "view =~ /issues:github/ && viewItem == currentissue", - "group": "issues_0@2" - }, - { - "command": "issue.stopWorking", - "when": "view =~ /issues:github/ && viewItem == currentissue && config.githubIssues.useBranchForIssues != on", - "group": "inline@1" - }, - { - "command": "issue.stopWorkingBranchDescriptiveTitle", - "when": "view =~ /issues:github/ && viewItem == currentissue && config.githubIssues.useBranchForIssues == on", - "group": "inline@1" - }, - { - "command": "issue.copyIssueNumber", - "when": "view =~ /issues:github/ && viewItem =~ /(current|continue)?issue/", - "group": "issues_1@1" - }, - { - "command": "issue.copyIssueUrl", - "when": "view =~ /issues:github/ && viewItem =~ /(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" - } - ], - "editor/title": [ - { - "command": "review.openFile", - "group": "navigation", - "when": "resourceScheme =~ /^review$/" - }, - { - "command": "issue.createIssueFromFile", - "group": "navigation", - "when": "resourceFilename == NewIssue.md" - } - ], - "scm/title": [ - { - "command": "review.suggestDiff", - "when": "scmProvider == git && github:inReviewMode", - "group": "inline" - } - ], - "comments/commentThread/context": [ - { - "command": "pr.createComment", - "group": "inline", - "when": "commentController =~ /^(browse|review)/" - }, - { - "command": "pr.startReview", - "group": "inline", - "when": "commentController =~ /browse/ && !prInDraft" - }, - { - "command": "pr.startReview", - "group": "inline", - "when": "commentController =~ /review/ && !reviewInDraftMode" - }, - { - "command": "pr.deleteReview", - "group": "inline", - "when": "commentController =~ /browse/ && prInDraft" - }, - { - "command": "pr.deleteReview", - "group": "inline", - "when": "commentController =~ /review/ && reviewInDraftMode" - }, - { - "command": "pr.finishReview", - "group": "inline", - "when": "commentController =~ /browse/ && prInDraft" - }, - { - "command": "pr.finishReview", - "group": "inline", - "when": "commentController =~ /review/ && reviewInDraftMode" - } - ], - "comments/comment/title": [ - { - "command": "pr.editComment", - "group": "inline@1", - "when": "commentController =~ /^(browse|review)/ && comment =~ /canEdit/" - }, - { - "command": "pr.deleteComment", - "group": "inline@2", - "when": "commentController =~ /^(browse|review)/ && comment =~ /canDelete/" - } - ], - "comments/comment/context": [ - { - "command": "pr.cancelEditComment", - "group": "inline@1", - "when": "commentController =~ /^(browse|review)/" - }, - { - "command": "pr.saveComment", - "group": "inline@2", - "when": "commentController =~ /^(browse|review)/" - } - ] - }, - "colors": [ - { - "id": "issues.newIssueDecoration", - "defaults": { - "dark": "#ffffff48", - "light": "#00000048", - "highContrast": "editor.foreground" - }, - "description": "The color used for the assignees and labels fields in a new issue editor." - } - ] - }, - "scripts": { - "vscode:prepublish": "webpack --env.production", - "postinstall": "cd src/typings && npx vscode-dts master && npx vscode-dts dev master", - "compile": "tsc -p ./", - "clean": "rm -r out/ media/ preview-src/dist/", - "watch": "webpack --watch --env.development --info-verbosity verbose", - "test": "yarn run preprocess && node ./out/test/runTests.js", - "preprocess": "yarn run compile && yarn run preprocess-gql && yarn run preprocess-svg", - "preprocess-gql": "node scripts/preprocess-gql --in src/github/queries.gql --out out/github/queries.gql", - "preprocess-svg": "node scripts/preprocess-svg --in resources/ --out preview-src/dist/resources" - }, - "devDependencies": { - "@types/chai": "^4.1.4", - "@types/debounce": "^3.0.0", - "@types/glob": "7.1.1", - "@types/graphql": "^14.0.5", - "@types/keytar": "^4.0.1", - "@types/lru-cache": "^5.1.0", - "@types/marked": "^0.7.2", - "@types/mocha": "^5.2.2", - "@types/node": "^12.0.0", - "@types/node-fetch": "^2.1.4", - "@types/query-string": "^6.1.1", - "@types/react": "^16.8.4", - "@types/react-dom": "^16.8.2", - "@types/sinon": "7.0.11", - "@types/temp": "0.8.34", - "@types/uuid": "^3.4.3", - "@types/webpack": "^4.41.6", - "css-loader": "^3.4.2", - "glob": "7.1.6", - "jsdom": "16.2.0", - "jsdom-global": "3.0.2", - "minimist": "^1.2.3", - "mkdirp": "0.5.1", - "mocha": "^7.0.1", - "mocha-junit-reporter": "1.23.0", - "mocha-multi-reporters": "1.1.7", - "react": "^16.12.0", - "react-dom": "^16.12.0", - "react-testing-library": "7.0.1", - "sinon": "9.0.0", - "source-map-support": "0.5.16", - "style-loader": "^1.1.3", - "svg-inline-loader": "^0.8.2", - "temp": "0.9.1", - "ts-loader": "^6.2.1", - "tslint": "^6.0.0", - "tslint-webpack-plugin": "^2.1.0", - "tty": "1.0.1", - "typescript": "^3.8.3", - "vscode-test": "^1.3.0", - "webpack": "^4.41.6", - "webpack-cli": "^3.3.11" - }, - "dependencies": { - "@octokit/rest": "^18.0.0", - "@octokit/types": "^5.1.0", - "apollo-boost": "^0.1.22", - "apollo-link-context": "^1.0.12", - "debounce": "^1.2.0", - "es6-promise": "^4.2.5", - "events": "^3.0.0", - "git-credential-node": "^1.1.0", - "graphql": "^14.0.2", - "js-base64": "^2.5.2", - "lru-cache": "^5.1.1", - "marked": "^0.8.0", - "moment": "^2.22.1", - "node-emoji": "^1.8.1", - "node-fetch": "3.0.0-beta.7", - "query-string": "^6.2.0", - "ssh-config": "^2.0.0-alpha.3", - "tunnel": "0.0.6", - "uuid": "^3.3.3", - "vscode-extension-telemetry": "^0.1.3", - "vsls": "^0.3.967" - }, - "optionalDependencies": { - "spawn-sync": "^2.0.0" - }, - "resolutions": { - "webpack/**/tar": "^4.4.2" - }, - "license": "MIT" -} \ No newline at end of file + "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", + "chatContextProvider", + "chatParticipantAdditions", + "chatParticipantPrivate", + "chatSessionsProvider", + "codiconDecoration", + "codeActionRanges", + "commentingRangeHint", + "commentReactor", + "commentReveal", + "commentsDraftState", + "commentThreadApplicability", + "contribAccessibilityHelpContent", + "contribCommentEditorActionsMenu", + "contribCommentPeekContext", + "contribCommentThreadAdditionalMenu", + "contribCommentsViewThreadMenus", + "contribEditorContentMenu", + "contribShareMenu", + "diffCommand", + "languageModelToolResultAudience", + "markdownAlertSyntax", + "quickDiffProvider", + "remoteCodingAgents", + "shareProvider", + "tabInputMultiDiff", + "tokenInformation", + "treeItemMarkdownLabel", + "treeViewMarkdownMessage" + ], + "version": "0.156.0", + "publisher": "GitHub", + "engines": { + "node": ">=20", + "vscode": "^1.129.0" + }, + "categories": [ + "Other", + "AI", + "Chat" + ], + "extensionDependencies": [ + "vscode.github-authentication" + ], + "activationEvents": [ + "onStartupFinished", + "onFileSystem:newIssue", + "onFileSystem:pr", + "onFileSystem:githubpr", + "onFileSystem:githubcommit", + "onFileSystem:review", + "onWebviewPanel:IssueOverview", + "onWebviewPanel:PullRequestOverview", + "onChatContextProvider:githubpr", + "onChatContextProvider:githubissue" + ], + "browser": "./dist/browser/extension", + "l10n": "./dist/browser/extension", + "main": "./dist/extension", + "capabilities": { + "untrustedWorkspaces": { + "supported": true + }, + "virtualWorkspaces": true + }, + "contributes": { + "chatContext": [ + { + "id": "githubpr", + "icon": "$(github)", + "displayName": "GitHub Pull Requests" + }, + { + "id": "githubissue", + "icon": "$(issues)", + "displayName": "GitHub Issues" + } + ], + "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", + "branchName", + "none", + "Copilot" + ], + "enumDescriptions": [ + "%githubPullRequests.pullRequestDescription.template%", + "%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": { + "type": "string", + "enum": [ + "info", + "debug", + "off" + ], + "default": "info", + "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": [ + "origin", + "upstream" + ], + "items": { + "type": "string" + }, + "markdownDescription": "%githubPullRequests.remotes.markdownDescription%" + }, + "githubPullRequests.autoRepositoryDetection": { + "markdownDescription": "%githubPullRequests.autoRepositoryDetection.markdownDescription%", + "default": "workspace", + "enum": [ + "workspace", + true + ], + "markdownEnumDescriptions": [ + "%githubPullRequests.autoRepositoryDetection.workspace%", + "%githubPullRequests.autoRepositoryDetection.true%" + ] + }, + "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%" + } + }, + "default": { + "label": "%githubPullRequests.queries.assignedToMe%", + "query": "repo:${owner}/${repository} is:open assignee:${user}" + } + }, + "scope": "resource", + "markdownDescription": "%githubPullRequests.queries.markdownDescription%", + "default": [ + { + "label": "%githubPullRequests.queries.copilotOnMyBehalf%", + "query": "repo:${owner}/${repository} is:open author:copilot 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": "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": [ + "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.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, + "description": "%githubPullRequests.defaultDeletionMethod.selectLocalBranch.description%" + }, + "githubPullRequests.defaultDeletionMethod.selectRemote": { + "type": "boolean", + "default": true, + "description": "%githubPullRequests.defaultDeletionMethod.selectRemote.description%" + }, + "githubPullRequests.defaultDeletionMethod.selectWorktree": { + "type": "boolean", + "default": false, + "description": "%githubPullRequests.defaultDeletionMethod.selectWorktree.description%" + }, + "githubPullRequests.deleteBranchAfterMerge": { + "type": "boolean", + "default": false, + "description": "%githubPullRequests.deleteBranchAfterMerge.description%" + }, + "githubPullRequests.enableAttestationCommits": { + "type": [ + "boolean", + "string" + ], + "default": false, + "markdownDescription": "%githubPullRequests.enableAttestationCommits.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", + "always" + ], + "enumDescriptions": [ + "%githubPullRequests.createOnPublishBranch.never%", + "%githubPullRequests.createOnPublishBranch.ask%", + "%githubPullRequests.createOnPublishBranch.always%" + ], + "default": "ask", + "description": "%githubPullRequests.createOnPublishBranch.description%" + }, + "githubPullRequests.commentExpandState": { + "type": "string", + "enum": [ + "expandUnresolved", + "collapseAll", + "collapsePreexisting" + ], + "enumDescriptions": [ + "%githubPullRequests.commentExpandState.expandUnresolved%", + "%githubPullRequests.commentExpandState.collapseAll%", + "%githubPullRequests.commentExpandState.collapsePreexisting%" + ], + "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.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%" + }, + "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.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": { + "type": "string", + "enum": [ + "single", + "review" + ], + "default": "review", + "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", + "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", + "markdownDescription": "%githubPullRequests.devMode.description%", + "default": false + }, + "githubIssues.ignoreMilestones": { + "type": "array", + "default": [], + "description": "%githubIssues.ignoreMilestones.description%" + }, + "githubIssues.createIssueTriggers": { + "type": "array", + "items": { + "type": "string", + "description": "%githubIssues.createIssueTriggers.items%" + }, + "default": [ + "TODO", + "todo", + "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", + "crystal", + "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": [], + "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.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}\nFixes ${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%" + }, + "githubIssues.issueAvatarDisplay": { + "type": "string", + "enum": [ + "author", + "assignee", + "state", + "generic" + ], + "enumDescriptions": [ + "%githubIssues.issueAvatarDisplay.author%", + "%githubIssues.issueAvatarDisplay.assignee%", + "%githubIssues.issueAvatarDisplay.state%", + "%githubIssues.issueAvatarDisplay.generic%" + ], + "default": "author", + "description": "%githubIssues.issueAvatarDisplay.description%" + }, + "githubPullRequests.focusedMode": { + "properties": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + }, + "enum": [ + "firstDiff", + "overview", + "multiDiff", + false + ], + "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%" + }, + "githubPullRequests.pullRequestAvatarDisplay": { + "type": "string", + "enum": [ + "author", + "state", + "generic" + ], + "enumDescriptions": [ + "%githubPullRequests.pullRequestAvatarDisplay.author%", + "%githubPullRequests.pullRequestAvatarDisplay.state%", + "%githubPullRequests.pullRequestAvatarDisplay.generic%" + ], + "default": "author", + "description": "%githubPullRequests.pullRequestAvatarDisplay.description%" + }, + "githubIssues.alwaysPromptForNewIssueRepo": { + "type": "boolean", + "default": false, + "description": "%githubIssues.alwaysPromptForNewIssueRepo.description%" + } + } + }, + "yamlValidation": [ + { + "fileMatch": ".github/ISSUE_TEMPLATE/!(config).{yml,yaml}", + "url": "https://json.schemastore.org/github-issue-forms.json" + }, + { + "fileMatch": ".github/ISSUE_TEMPLATE/config.{yml,yaml}", + "url": "https://json.schemastore.org/github-issue-config.json" + } + ], + "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 && !github:resolvingConflicts", + "icon": "$(github-inverted)", + "accessibilityHelpContent": "%view.pr.github.accessibilityHelpContent%" + }, + { + "id": "issues:github", + "name": "%view.issues.github.name%", + "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:createPullRequestWebview", + "type": "webview", + "name": "%view.github.create.pull.request.name%", + "when": "github:createPullRequest || github:revertPullRequest", + "icon": "$(git-pull-request-create)", + "visibility": "visible", + "initialSize": 2 + }, + { + "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 && !github:revertPullRequest", + "icon": "$(diff-multiple)", + "visibility": "visible", + "initialSize": 3 + }, + { + "id": "github:activePullRequest", + "type": "webview", + "name": "%view.github.active.pull.request.name%", + "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", + "icon": "$(git-pull-request)" + } + ] + }, + "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%", + "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.pickInWorktree", + "title": "%command.pr.pickInWorktreeFromDescription.title%", + "category": "%command.pull.request.category%", + "icon": "$(folder-library)" + }, + { + "command": "pr.pickInWorktreeFromDescription", + "title": "%command.pr.pickInWorktree.title%", + "category": "%command.pull.request.category%", + "icon": "$(folder-library)" + }, + { + "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.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%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.readyForReview", + "title": "%command.pr.readyForReview.title%", + "category": "%command.pull.request.category%" + }, + { + "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%" + }, + { + "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.revealFileInOS", + "title": "%command.pr.revealFileInOS.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.focusDescriptionInput", + "title": "%command.pr.focusDescriptionInput.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": "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.toggleHideViewedFiles", + "title": "%command.pr.toggleHideViewedFiles.title%", + "icon": "$(filter)", + "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.uploadFile", + "title": "%command.pr.uploadFile.title%", + "category": "%command.pull.request.category%", + "icon": "$(cloud-upload)" + }, + { + "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%" + }, + { + "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%", + "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": "$(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", + "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.preReview", + "title": "%command.pr.preReview.title%", + "category": "%command.pull.request.category%", + "enablement": "!pr:preReviewing && !pr:creating", + "icon": "$(comment)" + }, + { + "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": "pr.resolveConflict", + "title": "%command.pr.resolveConflict.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.acceptMerge", + "title": "%command.pr.acceptMerge.title%", + "category": "%command.pull.request.category%" + }, + { + "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.checkoutFromChat", + "title": "%command.pr.checkoutFromChat.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.applyChangesFromChat", + "title": "%command.pr.applyChangesFromChat.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": "pr.addAttestationCommit", + "title": "%command.pr.addAttestationCommit.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", + "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.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%" + }, + { + "command": "pr.generateTitleAndDescription", + "title": "%command.pr.generateTitleAndDescription.title%", + "category": "%command.pull.request.category%" + } + ], + "viewsWelcome": [ + { + "view": "github:login", + "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.loginNoEnterprise.contents%" + }, + { + "view": "github:login", + "when": "(ReposManagerStateContext == NeedsAuthentication) && ((!github:hasGitHubRemotes && gitOpenRepositoryCount) || config.github-enterprise.uri)", + "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%" + }, + { + "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": [ + { + "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" + }, + { + "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": { + "commandPalette": [ + { + "command": "github.api.preloadPullRequest", + "when": "false" + }, + { + "command": "pr.addAttestationCommit", + "when": "config.githubPullRequests.enableAttestationCommits" + }, + { + "command": "pr.configureRemotes", + "when": "gitHubOpenRepositoryCount != 0" + }, + { + "command": "pr.configurePRViewlet", + "when": "gitHubOpenRepositoryCount != 0" + }, + { + "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.pickInWorktree", + "when": "false" + }, + { + "command": "pr.pickInWorktreeFromDescription", + "when": "false" + }, + { + "command": "pr.exit", + "when": "github:inReviewMode" + }, + { + "command": "pr.dismissNotification", + "when": "false" + }, + { + "command": "pr.markAllCopilotNotificationsAsRead", + "when": "false" + }, + { + "command": "pr.resetViewedFiles", + "when": "github:inReviewMode" + }, + { + "command": "review.openFile", + "when": "false" + }, + { + "command": "review.openLocalFile", + "when": "false" + }, + { + "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": "false" + }, + { + "command": "pr.readyForReviewDescription", + "when": "false" + }, + { + "command": "pr.readyForReviewAndMergeDescription", + "when": "false" + }, + { + "command": "pr.readyForReviewAndMerge", + "when": "false" + }, + { + "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.revealFileInOS", + "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.focusDescriptionInput", + "when": "github:pullRequestDescriptionVisible" + }, + { + "command": "pr.showDiffSinceLastReview", + "when": "false" + }, + { + "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.checkoutFromChat", + "when": "false" + }, + { + "command": "pr.applyChangesFromDescription", + "when": "false" + }, + { + "command": "pr.applyChangesFromChat", + "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" + }, + { + "command": "pr.setFileListLayoutAsTree", + "when": "false" + }, + { + "command": "pr.setFileListLayoutAsFlat", + "when": "false" + }, + { + "command": "pr.toggleHideViewedFiles", + "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.uploadFile", + "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.unresolveReviewThread", + "when": "false" + }, + { + "command": "pr.unresolveReviewThreadFromView", + "when": "false" + }, + { + "command": "pr.resolveReviewThread", + "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.copyPrLink", + "when": "false" + }, + { + "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.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" + }, + { + "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.assignToCodingAgent", + "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": "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" + }, + { + "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" + }, + { + "command": "pr.generateTitleAndDescription", + "when": "false" + } + ], + "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": "navigation1" + }, + { + "command": "pr.setFileListLayoutAsFlat", + "when": "view == prStatus:github && !fileListLayout:flat", + "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", + "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": "issues.configureIssuesViewlet", + "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" + }, + { + "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)/", + "group": "1_pullrequest@1" + }, + { + "command": "pr.exit", + "when": "view == pr:github && viewItem =~ /pullrequest(:local)?:active/", + "group": "1_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.pickOnCodespaces", + "when": "view == pr:github && viewItem =~ /pullrequest(:local)?:nonactive/ && (!isWeb || remoteName != codespaces && virtualWorkspace != vscode-vfs)", + "group": "1_pullrequest@3" + }, + { + "command": "pr.pickInWorktree", + "when": "view == pr:github && viewItem =~ /pullrequest(:local)?:nonactive/ && !isWeb", + "group": "1_pullrequest@4" + }, + { + "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/", + "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": "4_pullrequest@4" + }, + { + "command": "pr.dismissNotification", + "when": "view == pr:github && viewItem =~ /pullrequest(.*):notification/", + "group": "4_pullrequest@5" + }, + { + "command": "pr.markAllCopilotNotificationsAsRead", + "when": "view == pr:github && viewItem =~ /copilot-query/", + "group": "0_category@1" + }, + { + "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.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@1", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /(pullrequest|description):(active|nonactive):hasChangesSinceReview:showingAllChanges/" + }, + { + "command": "pr.showDiffAll", + "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.chat && !config.chat.disableAIFeatures" + }, + { + "command": "notification.openOnGitHub", + "group": "issues_0@1", + "when": "view == notifications:github && (viewItem == 'Issue' || viewItem == 'PullRequest')" + }, + { + "command": "notification.markAsRead", + "group": "inline@3", + "when": "view == notifications:github && (viewItem == 'Issue' || viewItem == 'PullRequest')" + }, + { + "command": "notification.markAsRead", + "group": "issues_0@2", + "when": "view == notifications:github && (viewItem == 'Issue' || viewItem == 'PullRequest')" + }, + { + "command": "notification.markAsDone", + "group": "inline@4", + "when": "view == notifications:github && (viewItem == 'Issue' || viewItem == 'PullRequest')" + }, + { + "command": "notification.markAsDone", + "group": "issues_0@3", + "when": "view == notifications:github && (viewItem == 'Issue' || viewItem == 'PullRequest')" + }, + { + "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 && showInlineOpenFileAction && view == prStatus:github && viewItem =~ /filechange(?!:DELETE)/" + }, + { + "command": "pr.openDiffView", + "group": "inline@0", + "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/", + "group": "0_open@2" + }, + { + "command": "pr.openModifiedFile", + "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", + "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": "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@2" + }, + { + "command": "issue.startWorkingBranchDescriptiveTitle", + "when": "view == issues:github && viewItem =~ /^(link)?issue/ && config.githubIssues.useBranchForIssues == on", + "group": "inline@2" + }, + { + "command": "issue.startWorking", + "when": "view == issues:github && viewItem =~ /^(link)?continueissue/ && config.githubIssues.useBranchForIssues != on", + "group": "inline@2" + }, + { + "command": "issue.startWorkingBranchDescriptiveTitle", + "when": "view == issues:github && viewItem =~ /^(link)?continueissue/ && config.githubIssues.useBranchForIssues == on", + "group": "inline@2" + }, + { + "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.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_2@1" + }, + { + "command": "issue.copyIssueUrl", + "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/", + "group": "issues_2@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.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", + "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)" + } + ], + "editor/content": [ + { + "command": "pr.acceptMerge", + "when": "isMergeResultEditor && mergeEditorBaseUri =~ /^(githubpr|gitpr):/" + } + ], + "scm/title": [ + { + "command": "pr.create", + "when": "scmProvider =~ /^git|^remoteHub:github/ && scmProviderRootUri in github:reposNotInReviewMode", + "group": "navigation" + } + ], + "scm/resourceGroup/context": [ + { + "command": "review.createSuggestionsFromChanges", + "when": "scmProviderRootUri in github:reposInReviewMode && scmProvider =~ /^git|^remoteHub:github/ && scmResourceGroup == workingTree", + "group": "inline@-2" + } + ], + "scm/resourceState/context": [ + { + "command": "review.createSuggestionsFromChanges", + "when": "scmProviderRootUri in github:reposInReviewMode && scmProvider =~ /^git|^remoteHub:github/ && scmResourceGroup == workingTree", + "group": "1_modification@5" + } + ], + "scm/repository": [ + { + "command": "pr.create", + "when": "scmProvider =~ /^git|^remoteHub:github/ && scmProviderRootUri in github:reposNotInReviewMode", + "group": "inline" + } + ], + "comments/commentThread/context": [ + { + "command": "pr.createComment", + "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)/ && !github:activeCommentHasSuggestion" + }, + { + "command": "pr.uploadFile", + "group": "inline@4", + "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/" + }, + { + "command": "pr.applySuggestionWithCopilot", + "when": "commentController =~ /^github-review/ && !config.chat.disableAIFeatures" + } + ], + "comments/comment/title": [ + { + "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": "overflow@1", + "when": "commentController =~ /^github-(browse|review)/ && comment =~ /canEdit/" + }, + { + "command": "pr.deleteComment", + "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": "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" + } + ], + "editor/context": [ + { + "command": "review.createSuggestionFromChange", + "when": "activeEditor == workbench.editors.textDiffEditor && (resourcePath in github:unviewedFiles || resourcePath in github:viewedFiles)", + "group": "2_git@6" + } + ], + "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": [ + { + "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_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.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.pickInWorktreeFromDescription", + "group": "checkout@1", + "when": "webviewId == PullRequestOverview && github:checkoutMenu && !isWeb" + }, + { + "command": "pr.checkoutOnVscodeDevFromDescription", + "group": "checkout@2", + "when": "webviewId == PullRequestOverview && github:checkoutMenu" + }, + { + "command": "pr.checkoutOnCodespacesFromDescription", + "group": "checkout@3", + "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.checkoutFromChat", + "group": "navigation@0", + "when": "chatSessionType == copilot-cloud-agent && workspaceFolderCount > 0 && github.vscode-pull-request-github.activated && !config.chat.disableAIFeatures" + }, + { + "command": "pr.applyChangesFromChat", + "group": "navigation@1", + "when": "chatSessionType == copilot-cloud-agent && workspaceFolderCount > 0 && github.vscode-pull-request-github.activated && !config.chat.disableAIFeatures" + } + ] + }, + "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": "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": { + "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": "#cb2431", + "light": "#cb2431", + "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 + } + } + ], + "chatSkills": [ + { + "path": "./src/lm/skills/summarize-github-issue-pr-notification/SKILL.md" + }, + { + "path": "./src/lm/skills/suggest-fix-issue/SKILL.md" + }, + { + "path": "./src/lm/skills/form-github-search-query/SKILL.md" + }, + { + "path": "./src/lm/skills/show-github-search-result/SKILL.md" + }, + { + "path": "./src/lm/skills/address-pr-comments/SKILL.md" + }, + { + "path": "./src/lm/skills/create-pull-request/SKILL.md" + } + ], + "languageModelTools": [ + { + "name": "github-pull-request_issue_fetch", + "tags": [ + "github", + "issues", + "prs" + ], + "toolReferenceName": "issue_fetch", + "displayName": "%languageModelTools.github-pull-request_issue_fetch.displayName%", + "userDescription": "%languageModelTools.github-pull-request_issue_fetch.description%", + "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_labels_fetch", + "tags": [ + "github", + "labels" + ], + "toolReferenceName": "labels_fetch", + "displayName": "%languageModelTools.github-pull-request_labels_fetch.displayName%", + "userDescription": "%languageModelTools.github-pull-request_labels_fetch.description%", + "modelDescription": "Fetch all labels from a GitHub repository. Returns the label names, colors, and descriptions as a JSON object.", + "icon": "$(tag)", + "canBeReferencedInPrompt": true, + "inputSchema": { + "type": "object", + "properties": { + "repo": { + "type": "object", + "description": "The repository to fetch labels from.", + "properties": { + "owner": { + "type": "string", + "description": "The owner of the repository to fetch labels from." + }, + "name": { + "type": "string", + "description": "The name of the repository to fetch labels from." + } + }, + "required": [ + "owner", + "name" + ] + } + } + }, + "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%", + "userDescription": "%languageModelTools.github-pull-request_notification_fetch.description%", + "modelDescription": "Get a GitHub notification's details as a JSON object.", + "icon": "$(info)", + "canBeReferencedInPrompt": true, + "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_doSearch", + "tags": [ + "github", + "issues", + "search" + ], + "toolReferenceName": "doSearch", + "displayName": "%languageModelTools.github-pull-request_doSearch.displayName%", + "userDescription": "%languageModelTools.github-pull-request_doSearch.description%", + "modelDescription": "Execute a GitHub search given a well formed GitHub search query. Make sure to form a good search query first", + "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_currentActivePullRequest", + "tags": [ + "github", + "pull request" + ], + "toolReferenceName": "activePullRequest", + "displayName": "%languageModelTools.github-pull-request_currentActivePullRequest.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, and PR state. For PRs created by Copilot, it also includes the session logs which indicate the development process and decisions made by the coding agent. Does NOT include status checks/CI results; use the pullRequestStatusChecks tool instead. 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,\" 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_currentActivePullRequest.description%", + "when": "config.githubPullRequests.experimental.chat", + "inputSchema": { + "type": "object", + "properties": { + "refresh": { + "type": "boolean", + "description": "Whether to fetch fresh data from GitHub or return cached data. Set to true to ensure the most up-to-date information, especially after recent changes. Set to false to improve performance when up-to-date information is not critical." + } + } + } + }, + { + "name": "github-pull-request_pullRequestStatusChecks", + "tags": [ + "github", + "pull request", + "ci", + "status checks" + ], + "toolReferenceName": "pullRequestStatusChecks", + "displayName": "%languageModelTools.github-pull-request_pullRequestStatusChecks.displayName%", + "modelDescription": "Get the status checks and CI failures for a GitHub pull request (PR). This includes check run statuses, workflow names, failure logs, and review requirements (approvals needed, current approvals, requested changes). Use this tool when the user asks about CI status/failures, build results, check runs, status checks, whether a PR is ready to merge, or similar queries. Requires a pull request number.", + "icon": "$(check-all)", + "canBeReferencedInPrompt": true, + "inputSchema": { + "type": "object", + "properties": { + "repo": { + "type": "object", + "description": "The repository to get the pull request status checks from.", + "properties": { + "owner": { + "type": "string", + "description": "The owner of the repository." + }, + "name": { + "type": "string", + "description": "The name of the repository." + } + }, + "required": [ + "owner", + "name" + ] + }, + "pullRequestNumber": { + "type": "number", + "description": "The number of the pull request to get status checks for." + } + }, + "required": [ + "pullRequestNumber" + ] + }, + "userDescription": "%languageModelTools.github-pull-request_pullRequestStatusChecks.description%", + "when": "config.githubPullRequests.experimental.chat" + }, + { + "name": "github-pull-request_pullRequestInViewport", + "tags": [ + "github", + "pull request" + ], + "toolReferenceName": "openPullRequest", + "displayName": "%languageModelTools.github-pull-request_pullRequestInViewport.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, and PR state. For PRs created by Copilot, it also includes the session logs which indicate the development process and decisions made by the coding agent. Does NOT include status checks/CI results; use the pullRequestStatusChecks tool instead. When asked about the currently open pull request, do this first! Use this tool for any request related to \"pull request details,\" \"what changed,\" 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_pullRequestInViewport.description%", + "when": "config.githubPullRequests.experimental.chat" + }, + { + "name": "github-pull-request_create_pull_request", + "tags": [ + "github", + "pull request" + ], + "toolReferenceName": "create_pull_request", + "displayName": "%languageModelTools.github-pull-request_create_pull_request.displayName%", + "userDescription": "%languageModelTools.github-pull-request_create_pull_request.description%", + "modelDescription": "Create a new GitHub pull request. Requires a title and head branch. The base branch and repo default to the repository defaults. Returns the created pull request number, URL, and details.", + "icon": "$(git-pull-request-new)", + "canBeReferencedInPrompt": true, + "inputSchema": { + "type": "object", + "properties": { + "repo": { + "type": "object", + "description": "The repository to create the pull request in.", + "properties": { + "owner": { + "type": "string", + "description": "The owner of the repository." + }, + "name": { + "type": "string", + "description": "The name of the repository." + } + } + }, + "title": { + "type": "string", + "description": "The title of the pull request." + }, + "body": { + "type": "string", + "description": "The body/description of the pull request." + }, + "head": { + "type": "string", + "description": "The name of the branch where your changes are implemented (branch name only, without owner prefix)." + }, + "headOwner": { + "type": "string", + "description": "The owner of the head branch repository. Defaults to the origin/push remote repository owner." + }, + "base": { + "type": "string", + "description": "The name of the branch you want the changes pulled into. Defaults to the repository's default branch." + }, + "draft": { + "type": "boolean", + "description": "Indicates whether the pull request is a draft." + } + }, + "required": [ + "title", + "head" + ] + }, + "when": "config.githubPullRequests.experimental.chat" + }, + { + "name": "github-pull-request_resolveReviewThread", + "tags": [ + "github", + "pull request", + "review" + ], + "toolReferenceName": "resolveReviewThread", + "displayName": "%languageModelTools.github-pull-request_resolveReviewThread.displayName%", + "modelDescription": "Resolve a review thread on the active GitHub pull request. Use the threadId from the reviewThreads array returned by the activePullRequest tool. Only resolves threads where canResolve is true and isResolved is false.", + "icon": "$(pass)", + "canBeReferencedInPrompt": true, + "userDescription": "%languageModelTools.github-pull-request_resolveReviewThread.description%", + "when": "config.githubPullRequests.experimental.chat", + "inputSchema": { + "type": "object", + "properties": { + "threadId": { + "type": "string", + "description": "The GraphQL node ID of the review thread to resolve. Obtain this from the id field in the reviewThreads array of the activePullRequest tool output." + } + }, + "required": [ + "threadId" + ] + } + } + ] + }, + "scripts": { + "postinstall": "npm run 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", + "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 . --ext .ts,.tsx", + "package": "npx vsce package", + "test": "npm run test:preprocess && node ./out/src/test/runTests.js", + "test:preprocess": "npm run compile:test && npm run test:preprocess-gql && npm run test:preprocess-svg && npm 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": "npm 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\" && 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", + "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": "^10.0.10", + "@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.103.0", + "@types/webpack-env": "^1.16.0", + "@typescript-eslint/eslint-plugin": "^8.44.0", + "@typescript-eslint/parser": "^8.44.0", + "@vscode/dts": "^0.4.1", + "@vscode/test-cli": "^0.0.11", + "@vscode/test-electron": "^2.5.2", + "@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": "7.1.2", + "esbuild-loader": "4.5.0", + "eslint": "^9.36.0", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-import": "2.31.0", + "eslint-plugin-rulesdir": "^0.2.2", + "event-stream": "^4.0.1", + "fork-ts-checker-webpack-plugin": "9.1.0", + "glob": "7.1.6", + "globals": "^16.4.0", + "graphql": "15.5.0", + "graphql-tag": "2.11.0", + "gulp-filter": "^7.0.0", + "husky": "^8.0.1", + "jsdom": "28.1.0", + "jsdom-global": "3.0.2", + "json5": "2.2.2", + "merge-options": "3.0.4", + "minimist": "^1.2.6", + "mkdirp": "1.0.4", + "mocha": "^11.7.5", + "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", + "react-testing-library": "7.0.1", + "sinon": "9.0.0", + "source-map-support": "0.5.19", + "stream-browserify": "^3.0.0", + "style-loader": "4.0.0", + "svg-inline-loader": "^0.8.2", + "temp": "0.9.4", + "terser-webpack-plugin": "5.3.17", + "timers-browserify": "^2.0.12", + "ts-loader": "9.5.2", + "ts-node": "^10.9.2", + "tty": "1.0.1", + "typescript": "^5.9.2", + "typescript-eslint": "^8.44.0", + "typescript-formatter": "^7.2.2", + "vinyl-fs": "^3.0.3", + "webpack": "5.104.1", + "webpack-cli": "4.2.0" + }, + "dependencies": { + "@joaomoreno/unique-names-generator": "^5.2.0", + "@octokit/rest": "22.0.0", + "@octokit/types": "14.1.0", + "@vscode/codicons": "^0.0.36", + "@vscode/extension-telemetry": "1.5.2", + "@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.2.0", + "jsonc-parser": "^3.3.1", + "lru-cache": "6.0.0", + "markdown-it": "^14.2.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": "14.0.0", + "vscode-tas-client": "^0.1.84", + "vsls": "^0.3.967" + }, + "overrides": { + "mocha": { + "diff": "7.0.0", + "serialize-javascript": "7.0.5" + }, + "elliptic": "6.6.1" + }, + "resolutions": { + "string_decoder": "^1.3.0" + }, + "license": "MIT" +} diff --git a/package.nls.json b/package.nls.json index 28b6331c77..ea64843094 100644 --- a/package.nls.json +++ b/package.nls.json @@ -1,11 +1,458 @@ { - "description": "Extension to add task support for npm scripts.", - "displayName": "Npm support for VSCode", - "config.npm.autoDetect": "Controls whether auto detection of npm scripts is on or off. Default is on.", - "config.npm.runSilent": "Run npm commands with the `--silent` option.", - "config.npm.packageManager": "The package manager used to run scripts.", - "config.npm.exclude": "Configure glob patterns for folders that should be excluded from automatic script detection.", - "npm.parseError": "Npm task detection: failed to parse the file {0}", - "taskdef.script": "The npm script to customize.", - "taskdef.path": "The path to the folder of the package.json file that provides the script. Can be ommitted." + "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.autoRepositoryDetection.markdownDescription": "Controls which repositories are automatically detected and opened by the extension.", + "githubPullRequests.autoRepositoryDetection.workspace": "Only detect repositories within the current workspace folders.", + "githubPullRequests.autoRepositoryDetection.true": "Detect all repositories found by the Git extension, including those outside workspace folders.", + "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.defaultDeletionMethod.selectWorktree.description": "When true, the option to remove the associated worktree 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.enableAttestationCommits.description": "Enables adding an attestation commit (an empty, signed commit) to the head of a pull request branch as a way to attest to a pull request even when its individual commits are unsigned. Requires commit signing to be configured for git. Set to `true` to enable with the default commit message, set to a string to use that string as the commit message, or set to `false` to disable.", + "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- `${issueType}` will be replaced with the issue type name (e.g. `Feature`, `Hotfix`). For branch-safe variants, use `${sanitizedIssueType}` or `${sanitizedLowercaseIssueType}` \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.", + "githubPullRequests.pullRequestAvatarDisplay.description": "Which icon to use in the pull request tree view", + "githubPullRequests.pullRequestAvatarDisplay.author": "Show the pull request author avatar", + "githubPullRequests.pullRequestAvatarDisplay.state": "Show the pull request type (draft or not) and state (open/closed/merged) as a colored icon", + "githubPullRequests.pullRequestAvatarDisplay.generic": "Show a GitHub icon", + "githubIssues.issueAvatarDisplay.state": "Show the issue state (open/closed) as a colored icon", + "githubIssues.issueAvatarDisplay.generic": "Show an issues icon regardless of state", + "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.pickInWorktree.title": "Checkout in Worktree", + "command.pr.pickInWorktreeFromDescription.title": "Checkout Pull Request in Worktree", + "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.pr.addAttestationCommit.title": "Add Attestation Commit", + "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.uploadFile.title": "Upload File", + "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.checkoutFromChat.title": "Checkout", + "command.pr.applyChangesFromDescription.title": "Apply", + "command.pr.applyChangesFromChat.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", + "command.pr.generateTitleAndDescription.title": "Generate Pull Request Title and Description", + "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_fetch.description": "Get the details of a GitHub issue or pull request.", + "languageModelTools.github-pull-request_issue_summarize.displayName": "Summarize a GitHub Issue or pull request", + "languageModelTools.github-pull-request_labels_fetch.displayName": "Fetch labels from a GitHub repository", + "languageModelTools.github-pull-request_labels_fetch.description": "Fetch all labels from a GitHub repository, including their names, colors, and descriptions.", + "languageModelTools.github-pull-request_notification_fetch.displayName": "Get a GitHub Notification", + "languageModelTools.github-pull-request_notification_fetch.description": "Get the details of 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_doSearch.description": "Search for GitHub issues and pull requests.", + "languageModelTools.github-pull-request_renderIssues.displayName": "Render issue items in a markdown table", + "languageModelTools.github-pull-request_currentActivePullRequest.displayName": "Active Pull Request", + "languageModelTools.github-pull-request_currentActivePullRequest.description": "Get information about the active GitHub pull request. This information includes: comments, files changed, pull request title + description, and pull request state.", + "languageModelTools.github-pull-request_pullRequestStatusChecks.displayName": "Pull Request Status Checks", + "languageModelTools.github-pull-request_pullRequestStatusChecks.description": "Get the status checks and CI results for a GitHub pull request.", + "languageModelTools.github-pull-request_pullRequestInViewport.displayName": "Open Pull Request", + "languageModelTools.github-pull-request_pullRequestInViewport.description": "Get information about the open GitHub pull request. This information includes: comments, files changed, pull request title + description, and pull request state.", + "languageModelTools.github-pull-request_resolveReviewThread.displayName": "Resolve Review Thread", + "languageModelTools.github-pull-request_resolveReviewThread.description": "Resolve a review thread on the active GitHub pull request.", + "languageModelTools.github-pull-request_create_pull_request.displayName": "Create a GitHub pull request", + "languageModelTools.github-pull-request_create_pull_request.description": "Create a new GitHub pull request with a title, head branch, and optional body, base branch, and draft flag." } \ No newline at end of file diff --git a/preview-src/app.tsx b/preview-src/app.tsx deleted file mode 100644 index 8966f334ea..0000000000 --- a/preview-src/app.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from 'react'; -import { useContext, useState, useEffect } from 'react'; -import { render } from 'react-dom'; -import { Overview } from './overview'; -import PullRequestContext from './context'; -import { PullRequest } from './cache'; - -export function main() { - render( - {pr => } - , document.getElementById('app')); -} - -export function Root({ children }) { - const ctx = useContext(PullRequestContext); - const [pr, setPR] = useState(ctx.pr); - useEffect(() => { - ctx.onchange = setPR; - setPR(ctx.pr); - }, []); - ctx.postMessage({ command: 'ready' }); - ctx.postMessage({ command: 'pr.debug', args: ('initialized ' + (pr ? 'with PR' : 'without PR')) }); - return pr ? children(pr) :
Loading...
; -} \ No newline at end of file diff --git a/preview-src/cache.ts b/preview-src/cache.ts deleted file mode 100644 index 60a88cc2cf..0000000000 --- a/preview-src/cache.ts +++ /dev/null @@ -1,64 +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 { vscode } from './message'; -import { GithubItemStateEnum, IAccount, ReviewState, ILabel, MergeMethod, MergeMethodsAvailability, PullRequestMergeability, PullRequestChecks } from '../src/github/interface'; -import { TimelineEvent } from '../src/common/timelineEvent'; - -export interface PullRequest { - number: number; - title: string; - url: string; - createdAt: string; - body: string; - bodyHTML?: string; - author: IAccount; - state: GithubItemStateEnum; - events: TimelineEvent[]; - isCurrentlyCheckedOut: boolean; - base: string; - head: string; - labels: ILabel[]; - commitsCount: number; - repositoryDefaultBranch: any; - /** - * 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; }; - status: PullRequestChecks; - mergeable: PullRequestMergeability; - defaultMergeMethod: MergeMethod; - mergeMethodsAvailability: MergeMethodsAvailability; - reviewers: ReviewState[]; - isDraft?: boolean; - isIssue: boolean; -} - -export function getState(): PullRequest { - return vscode.getState(); -} - -export function setState(pullRequest: PullRequest): void { - const oldPullRequest = getState(); - - if (oldPullRequest && - oldPullRequest.number && oldPullRequest.number === pullRequest.number) { - pullRequest.pendingCommentText = oldPullRequest.pendingCommentText; - } - - if (pullRequest) { vscode.setState(pullRequest); } -} - -export function updateState(data: Partial): void { - const pullRequest = vscode.getState(); - vscode.setState(Object.assign(pullRequest, data)); -} diff --git a/preview-src/comment.tsx b/preview-src/comment.tsx deleted file mode 100644 index 4c3ab2c8f8..0000000000 --- a/preview-src/comment.tsx +++ /dev/null @@ -1,314 +0,0 @@ -import * as React from 'react'; -import { useContext, useState, useEffect, useRef, useCallback } from 'react'; - -import { Spaced, nbsp } from './space'; -import { Avatar, AuthorLink } from './user'; -import Timestamp from './timestamp'; -import { IComment } from '../src/common/comment'; -import { PullRequest } from './cache'; -import PullRequestContext from './context'; -import { editIcon, deleteIcon, commentIcon } from './icon'; -import { GithubItemStateEnum } from '../src/github/interface'; -import { useStateProp } from './hooks'; -import emitter from './events'; - -export type Props = Partial & { - headerInEditMode?: boolean - isPRDescription?: boolean -}; - -export function CommentView(comment: Props) { - const { id, pullRequestReviewId, canEdit, canDelete, bodyHTML, body, isPRDescription } = comment; - 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 [inEditMode, setEditMode] = useState(!!currentDraft); - const [showActionBar, setShowActionBar] = useState(false); - - if (inEditMode) { - return React.cloneElement( - comment.headerInEditMode - ? : <>, {}, [ - { - if (pr.pendingCommentDrafts) { - delete pr.pendingCommentDrafts[id]; - } - setEditMode(false); - } - } - onSave={ - async text => { - try { - const result = isPRDescription - ? await setDescription(text) - : await editComment({ comment: comment as IComment, text }); - - setBodyHtml(result.bodyHTML); - setBodyMd(text); - } finally { - setEditMode(false); - } - } - } /> - ]); - } - - return setShowActionBar(true)} - onMouseLeave={() => setShowActionBar(false)} - >{showActionBar - ?
- - {canEdit ? : null} - {canDelete ? : null} -
- : null - } - -
; -} - -type CommentBoxProps = { - for: Partial - header?: React.ReactChild - onMouseEnter?: any - onMouseLeave?: any - children?: any -}; - -function CommentBox({ - for: comment, - onMouseEnter, onMouseLeave, children }: CommentBoxProps) { - const { user, author, createdAt, htmlUrl, isDraft } = comment; - return
-
-
- - - - { - createdAt - ? <> - commented{nbsp} - - - : pending - } - { - isDraft - ? <> - Pending - - : null - } - -
- {children} -
-
; -} - -type FormInputSet = { - [name: string]: HTMLInputElement | HTMLTextAreaElement -}; - -type EditCommentProps = { - id: number - body: string - onCancel: () => void - onSave: (body: string) => Promise -}; - -function EditComment({ id, body, onCancel, onSave }: EditCommentProps) { - const { updateDraft } = useContext(PullRequestContext); - const draftComment = useRef<{ body: string, dirty: boolean }>({ body, dirty: false }); - const form = useRef(); - - useEffect(() => { - const interval = setInterval( - () => { - if (draftComment.current.dirty) { - updateDraft(id, draftComment.current.body); - draftComment.current.dirty = false; - } - }, - 500); - return () => clearInterval(interval); - }, - [draftComment]); - - const submit = useCallback( - async () => { - const { markdown, submitButton }: FormInputSet = form.current; - submitButton.disabled = true; - try { - await onSave(markdown.value); - } finally { - submitButton.disabled = false; - } - }, - [form, onSave]); - - const onSubmit = useCallback( - event => { - event.preventDefault(); - submit(); - }, - [submit] - ); - - const onKeyDown = useCallback( - e => { - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { - e.preventDefault(); - submit(); - } - }, - [submit] - ); - - const onInput = useCallback( - e => { - draftComment.current.body = (e.target as any).value; - draftComment.current.dirty = true; - }, - [draftComment]); - - return
- -
- - -
-
- : -

- {currentTitle} (#{number}) -

; - - return
setShowActionBar(true)} - onMouseLeave={() => setShowActionBar(false)}> - {editableTitle} -
- {/* - For whatever reason, triple click on a block element in MacOS will select everything in that element, *and* every `user-select: false` block adjacent to that element. - Add an empty selectable div here to block triple click on title from selecting the following buttons. Issue #628. - */} -
- { - (canEdit && showActionBar && !inEditMode) - ?
- {} -
- :
- } -
- - -
-
; -} - -const CheckoutButtons = ({ isCurrentlyCheckedOut, isIssue }) => { - const { exitReviewMode, checkout } = useContext(PullRequestContext); - const [isBusy, setBusy] = useState(false); - - const onClick = async (command: string) => { - try { - setBusy(true); - - switch (command) { - case 'checkout': - await checkout(); - break; - case 'exitReviewMode': - await exitReviewMode(); - break; - default: - throw new Error(`Can't find action ${command}`); - } - } finally { - setBusy(false); - } - }; - - if (isCurrentlyCheckedOut) { - return <> - - - ; - } else if (!isIssue) { - return ; - } else { - return null; - } -}; - -export function getStatus(state: GithubItemStateEnum, isDraft: boolean) { - if (state === GithubItemStateEnum.Merged) { - return 'Merged'; - } else if (state === GithubItemStateEnum.Open) { - return isDraft ? 'Draft' : 'Open'; - } else { - return 'Closed'; - } -} - -function getActionText(state: GithubItemStateEnum) { - if (state === GithubItemStateEnum.Merged) { - return 'merged changes'; - } else { - return 'wants to merge changes'; - } -} \ No newline at end of file diff --git a/preview-src/hooks.ts b/preview-src/hooks.ts deleted file mode 100644 index fd82d13f61..0000000000 --- a/preview-src/hooks.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Dispatch, SetStateAction, useState, useEffect } from 'react'; - -/** - * useState, but track the value of a prop. - * - * When the prop value changes, the tracked state will be updated to match. - * - * @param prop S the prop to track - */ -export function useStateProp(prop: S): [S, Dispatch>] { - const [state, setState] = useState(prop); - useEffect(() => { - if (state !== prop) { - setState(prop); - } - }, [prop]); - return [state, setState]; -} diff --git a/preview-src/icon.tsx b/preview-src/icon.tsx deleted file mode 100644 index 6b213ba27d..0000000000 --- a/preview-src/icon.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import * as React from 'react'; - -export const Icon = ({ className='', src, title }: { className?: string, title?: string, src: string }) => - ; - -export default Icon; - -export const commitIcon = ; -export const mergeIcon = ; -export const editIcon = ; -export const checkIcon = ; -export const plusIcon = ; -export const deleteIcon = ; -export const pendingIcon = ; -export const commentIcon = ; -export const diffIcon = ; -export const alertIcon = ; diff --git a/preview-src/index.css b/preview-src/index.css deleted file mode 100644 index 0752493ae7..0000000000 --- a/preview-src/index.css +++ /dev/null @@ -1,1109 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -#app { - display: grid; - grid-template-columns: 670px auto; -} - -#title { - grid-column-start: 1; - grid-column-end: 3; - grid-row: 1; -} - -#main { - grid-column: 1; - grid-row: 2; -} - -#sidebar { - grid-column: 2; - grid-row: 2; - padding-left: 20px; -} - -a:focus, input:focus, select:focus, textarea:focus, .title-text:focus { - outline: 1px solid var(--vscode-focusBorder); -} - -.title-text { - margin-right: 5px; -} - - .title { - display: flex; - align-items: flex-start; - margin: 20px 0; - padding-bottom: 10px; - border-bottom: 1px solid var(--vscode-list-inactiveSelectionBackground); -} - -.title .pr-number { - margin-left: 5px; -} - -.loading-indicator{ - position: fixed; - top: 50%; - left: 40%; - transform: translate(-50%,-50%); -} - -.comment-body li div { - display: inline; -} - -.comment-body code, .comment-body a, span.lineContent { - overflow-wrap: break-word; -} - -#title:empty { - border: none; -} - -h2 { - margin: 0; -} - -body hr { - display: block; - height: 1px; - border: 0; - border-top: 1px solid #555; - margin: 0 !important; - padding: 0; -} - -body a { - text-decoration: none; -} - -body a:hover { - text-decoration: underline; -} - -body .comment-container .avatar-container { - margin-right: 12px; -} - -body .comment-container .avatar-container a { - display: flex; -} - -body .comment-container .avatar-container img.avatar, -body .comment-container .avatar-container .avatar-icon svg { - margin-right: 0; -} - -body img.avatar, -body span.avatar-icon svg { - width: 24px; - height: 24px; -} - -.vscode-light .avatar-icon { - filter: invert(100%); -} - -body img.avatar { - vertical-align: middle; -} - -body a.avatar-link:focus { - outline-offset: 2px; -} - -body .comment-container.comment, -body .comment-container.review { - background-color: var(--vscode-editor-background); -} - - -.review-comment-container { - width: 100%; - display: flex; - flex-direction: column; - position: relative; -} - -body .comment-container .review-comment-header { - position: relative; - display: flex; - width: 100%; - box-sizing: border-box; - padding: 6px; - font-size: 12px; - color: var(--vscode-foreground); - align-items: center; - background: var(--vscode-list-inactiveSelectionBackground); - border: 1px solid var(--vscode-list-inactiveSelectionBackground); -} - -.description-header { - float: right; - height: 32px; -} - -.review-comment-header .comment-actions { - margin-left: auto; -} - -.review-comment-header .pending { - color: inherit; - font-style: italic; -} - -.comment-actions button { - background-color: transparent; - padding: 0 6px; - line-height: normal; - font-size: 11px; -} - -.comment-actions button svg { - margin-right: 0; - height: 14px; -} - -.status-check { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: 5px; - margin-left: 15px; -} - -.status-item, .form-actions { - display: flex; -} - -.form-actions > input[type=submit] { - margin-left: auto; -} - -.status-check-detail-text { - margin-left: 0.7em; -} - -.ready-for-review-container { - border-top: 1px solid; - padding-top: 10px; -} - -.ready-for-review-button { - float: right; -} - -.ready-for-review-icon { - float: left; -} - -.ready-for-review-heading { - font-size: 1.2; - font-weight: bold; -} - -.ready-for-review-meta { - font-size: 0.9; -} - -#confirm-merge { - margin-left: auto; -} - -.status-section { - padding-bottom: 12px; -} - -.status-section:last-of-type { - padding-bottom: 0px; -} - -#status-checks a { - margin-left: 10px; - cursor: pointer; -} - -#status-checks { - padding: 10px; - border: 1px solid var(--vscode-list-inactiveSelectionBackground); - margin-top: 20px; -} - -#status-checks summary { - display: flex; - align-items: center; -} - -#status-checks svg { - margin-right: 6px; - width: 16px; -} - -#status-checks .merge-select-container { - display: flex; - align-items: center; -} - -#status-checks .merge-select-container > * { - margin-right: 5px; -} - -#status-checks .branch-status-container { - display: inline-block; -} - -#status-checks .branch-status-message { - display: inline-block; - line-height: 100%; - padding: 0 10px; -} - -body .comment-container .review-comment-header > span, -body .comment-container .review-comment-header > a, -body .commit .commit-message > a, -body .merged .merged-message > a { - margin-right: 4px; - white-space: nowrap; -} - -body .comment-container .review-comment-container .pending-label, -body .diff .diffHeader .outdatedLabel { - border: 1px solid; - border-radius: 2px 2px 2px 2px; - padding: 0.1rem 0.3rem; - font-style: italic; - margin-left: 5px; -} - -body .diff .diffPath { - margin-right: 4px; -} - -body .comment-container .comment-body, .review-body { - padding: 10px; - border: 1px solid var(--vscode-list-inactiveSelectionBackground); - border-top: none; -} - -body .comment-container .review-comment-container .review-comment-body { - padding: 0; - margin: 0 0 0 36px; - border: none; -} - -body .comment-container .comment-body > p, body .comment-container .comment-body > div > p, .comment-container .review-body > p { - margin-top: 0; -} - -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; -} - -body { - margin: auto; - width: 100%; - max-width: 925px; - padding: 0 32px; - box-sizing: border-box; -} - -body .hidden { - display: none !important; -} - -body .hidden-focusable { - height: 0 !important; - overflow: hidden; -} - -button, input[type=submit] { - background-color: var(--vscode-button-background); - color: var(--vscode-button-foreground); - border-radius: 0px; - border: 1px solid transparent; - outline: none; - padding: 4px 12px; - font-size: 13px; - line-height: 18px; - white-space: nowrap; - user-select: none; -} - -button:focus, input[type=submit]:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: 2px; -} - -button:hover:enabled, button:focus:enabled, input[type=submit]:focus:enabled { - background-color: var(--vscode-button-hoverBackground); - cursor: pointer; -} - -.comment-actions button:hover:enabled, .comment-actions button:focus:enabled { - background-color: transparent; -} - -body button.checkedOut { - color: var(--vscode-foreground); - opacity: 1 !important; - border: none; - background-color: transparent; -} - -body button.secondary { - background-color: var(--vscode-button-secondaryBackground); - color: var(--vscode-button-secondaryForeground); -} - -body button.secondary:hover { - background-color: var(--vscode-button-secondaryHoverBackground); -} - -body button svg, body button svg path { - fill: var(--vscode-foreground); -} - -body button .icon { - width: 1em; - height: 1em; - margin-right: 6px; -} - -.prIcon { - display: flex; - border-radius: 10px; - margin-right: 5px; - margin-top: 18px; -} - -.overview-title { - display: flex; - position: relative; -} - -.overview-title h2 { - font-size: 24px; -} - -.overview-title textarea { - min-height: 50px; -} - -.overview-title .button-group { - padding-top: 5px; - margin-left: auto; - display: flex; - align-self: start; -} - -.title-container { - width: 100%; -} - -.subtitle { - display: flex; - align-items: center; - margin-top: 8px; -} - -.subtitle .avatar, -.subtitle .avatar-icon svg { - margin-right: 8px; -} - -.subtitle .author { - margin-right: 8px; -} - -.subtitle .created-at { - margin-left: auto; - white-space: nowrap; -} - -body .overview-title .button-group button { - margin-left: 10px; - display: flex; -} - -#status { - box-sizing: border-box; - line-height: 18px; - background: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); - border-radius: 4px; - padding: 2px 8px; - margin-right: 12px; -} - -.section { - padding-bottom: 20px; -} - -.section-header { - padding-bottom: 8px; - display: flex; -} - -.section button { - margin-left: auto; - padding: 0; - background: transparent; - display: flex; -} - -.section .icon { - margin-right: 0; -} - -.section .avatar-link { - margin-right: 8px; -} - -.section button:hover, .section button:focus { - background: transparent; -} - -.section svg { - width: 16px; - height: 16px; - display: block; - margin-right: 0; -} - -.section-item .avatar-container { - flex-shrink: 0; -} - -.section-item .login { - width: 129px; - flex-shrink: 0; -} - -.section-item { - margin-bottom: 8px; - display: flex; - align-items: center; -} - -.section-item img.avatar { - width: 18px; - height: 18px; -} - -.label { - padding: 2px 0 2px 6px; - height: 16px; - border-radius: 2px; - background: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); - white-space: nowrap; -} - -.commit svg { - width: 16px; - height: auto; - margin-right: 8px; - flex-shrink: 0; -} - -.comment-container.commit, -.comment-container.merged { - padding: 16px 0 0 12px; - box-sizing: border-box -} - -.commit, .review, .merged { - display: flex; - width: 100%; - border: none; - font-size: 12px; - color: var(--vscode-foreground); -} - -.review { - margin: 0px 8px; - padding: 4px 0; -} - -.commit .commit-message, -.merged .merged-message { - display: flex; - align-items: center; - line-height: 18px; - overflow: hidden; -} - -.commit .commit-message .avatar-container, -.merged .merged-message .avatar-container { - margin-right: 4px; - flex-shrink: 0; -} - -.commit .avatar-container .avatar, -.commit .avatar-container .avatar-icon, -.commit .avatar-container .avatar-icon svg, -.merged .avatar-container .avatar, -.merged .avatar-container .avatar-icon, -.merged .avatar-container .avatar-icon svg { - width: 18px; - height: 18px; -} - -.commit .commit-message .message, -.merged .merged-message .message { - overflow: inherit; - text-overflow: ellipsis; - white-space: nowrap; -} - -.commit .sha { - align-self: center; - margin-left: auto; -} - -.merged .merged-message .message, -.merged .inline-sha { - margin: 0 4px 0 0; -} - -.merged svg { - width: 14px; - height: auto; - margin-right: 8px; - flex-shrink: 0; -} - -.details { - display: flex; - flex-direction: column; - width: 100%; -} - -#description .comment-container { - padding-top: 0px; -} - -.comment-container { - position: relative; - padding-top: 20px; - width: 100%; - display: flex; - margin: 0; -} - -.comment-container[data-type="commit"] { - padding: 8px 0; -} - -.comment-container[data-type="commit"] + .comment-container[data-type="commit"], -.comment-container:first-of-type { - border-top: none; -} - -.comment-body .review-comment { - padding: 3px; - box-sizing: border-box; - border-top: 1px solid var(--vscode-list-inactiveSelectionBackground); -} - -.review-comment-container .review-comment .review-comment-header { - border: none; - background: none; -} - -.review-comment-container .review-comment .comment-body { - border: none; - padding: 4px 12px 8px 12px; -} - -.comment-body .line { - align-items: center; - display: flex; - flex-wrap: wrap; - margin-bottom: 8px; -} - -body .comment-form { - padding: 20px 0 10px; -} - -.review-comment-container .comment-form { - margin: 0 0 0 36px; - padding: 10px 0; -} - -textarea, input[type='text'] { - display: block; - box-sizing: border-box; - padding: 10px; - width: 100%; - resize: vertical; - font-size: 13px; - border: 1px solid var(--vscode-dropdown-border); - background-color: var(--vscode-input-background); - color: var(--vscode-input-foreground); - font-family: var(--vscode-editor-font-family); -} - -select { - display: block; - box-sizing: border-box; - padding: 4px 12px; - border-radius: 0; - font-size: 13px; - border: 1px solid var(--vscode-dropdown-border); - background-color: var(--vscode-dropdown-background); - color: var(--vscode-dropdown-foreground); -} - -.task-list-item { - list-style-type: none; -} - -#status-checks textarea { - margin: 10px 0; -} - -textarea { - min-height: 100px; - max-height: 500px; -} - -textarea:focus { - outline: 1px solid var(--vscode-focusBorder); -} - -.editing-form { - padding: 5px 0; - display: flex; - flex-direction: column; -} - -.editing-form .form-actions { - margin-left: auto; - padding: 5px 0; -} - -.comment-form .form-actions > button, -.comment-form .form-actions > input[type=submit] { - margin-right: 0; - margin-left: 0; -} - -.comment-form .form-actions > #request-changes { - margin-left: auto; -} - -.comment-form .form-actions > #close { - margin-left: 0; - margin-right: auto; -} - -.form-actions { - display: flex; - padding-top: 10px; -} - -.main-comment-form > .form-actions { - margin-bottom: 10px; -} - -body button:disabled, -input[type=submit]:disabled { - opacity: 0.4; -} - -body .comment-form .form-actions button { - margin-right:10px; -} - -.details .comment-body { - padding: 19px 0; -} - -blockquote { - display: block; - flex-direction: column; - margin: 8px 0; - padding: 8px 12px; - border-left-width: 5px; - border-left-style: solid; -} - -blockquote p { - margin: 8px 0; -} - -blockquote p:first-child { - margin-top: 0; -} - -blockquote p:last-child { - margin-bottom: 0; -} - -.comment-body a:focus, -.comment-body input:focus, -.comment-body select:focus, -.comment-body textarea:focus { - outline-offset: -1px; -} - -.comment-body hr { - border: 0; - height: 2px; - border-bottom: 2px solid; -} - -.comment-body h1 { - padding-bottom: 0.3em; - line-height: 1.2; - border-bottom-width: 1px; - border-bottom-style: solid; -} - -.comment-body h1, h2, h3 { - font-weight: normal; -} - -.comment-body h1 code, -.comment-body h2 code, -.comment-body h3 code, -.comment-body h4 code, -.comment-body h5 code, -.comment-body h6 code { - font-size: inherit; - line-height: auto; -} - -.comment-body table { - border-collapse: collapse; -} - -.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 { - padding: 5px 10px; -} - -.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"; -} - -.comment-body body.wordWrap pre { - white-space: pre-wrap; -} - -.comment-body .mac code { - font-size: 12px; - line-height: 18px; -} - -.comment-body pre:not(.hljs), -.comment-body pre.hljs code > div { - padding: 16px; - border-radius: 3px; - overflow: auto; -} - -.timestamp, -.timestamp:hover { - color: inherit; -} - -/** Theming */ - -.comment-body pre code { - color: var(--vscode-editor-foreground); -} - - -.vscode-light .comment-body pre:not(.hljs), -.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 { - background-color: rgba(10, 10, 10, 0.4); -} - -.vscode-high-contrast .comment-body pre:not(.hljs), -.vscode-high-contrast .comment-body code > div { - background-color: rgb(0, 0, 0); -} - -.vscode-high-contrast .comment-body h1 { - border: 1px solid rgb(0, 0, 0); -} - -.vscode-high-contrast .comment-container .review-comment-header, .vscode-high-contrast #status-checks { - background: none; - border: 1px solid var(--vscode-panel-border); -} - -.vscode-high-contrast .comment-container .comment-body, .vscode-high-contrast .review-comment-container .review-body { - border: 1px solid var(--vscode-panel-border); -} - -.vscode-light .comment-body table > thead > tr > th { - border-color: rgba(0, 0, 0, 0.69); -} - -.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 { - 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 { - border-color: rgba(255, 255, 255, 0.18); -} - -.review-comment-body .diff-container { - margin-top: 10px; - border: 1px solid var(--vscode-list-inactiveSelectionBackground); -} - -.review-comment-body .diff-container .review-comment-container .comment-container { - padding-top: 0; -} - -.review-comment-body .diff-container .review-comment-container .review-comment-header .avatar-container { - margin-right: 4px; -} - -.review-comment-body .diff-container .review-comment-container .review-comment-header .avatar { - width: 18px; - height: 18px; -} - -.diff .diffHeader { - padding: 6px 12px; - line-height: 1.5; - background: var(--vscode-editorGroupHeader-tabsBackground); -} - -.diff .diffHeader .diffPath:hover { - text-decoration: underline; - color: var(--vscode-textLink-activeForeground); - cursor: pointer; -} - -.win32 .diff .diffLine { - font-family: Consolas, Inconsolata, "Courier New", monospace; -} - -.darwin .diff .diffLine { - font-family: Monaco, Menlo, Inconsolata, "Courier New", monospace; -} - -.linux .diff .diffLine { - font-family: "Droid Sans Mono", Inconsolata, "Courier New", monospace, "Droid Sans Fallback"; -} - -.diff .diffLine.add { - background-color: var(--vscode-diffEditor-insertedTextBackground); -} - -.diff .diffLine.delete { - background-color: var(--vscode-diffEditor-removedTextBackground); -} - -.diff .diffLine .diffTypeSign { - user-select: none; - padding-right: 5px; -} - -.diff .diffLine .lineNumber { - width: 1%; - min-width: 50px; - padding-right: 10px; - padding-left: 10px; - font-size: 12px; - line-height: 20px; - text-align: right; - white-space: nowrap; - vertical-align: top; - box-sizing: border-box; - display: inline-block; - user-select: none; - font-family: var(--vscode-editor-font-family); -} - -.github-checkbox { - pointer-events: none; -} - -.github-checkbox input { - color: rgb(84, 84, 84); - opacity: 0.6; -} - -/* High Contrast Mode */ - -.vscode-high-contrast a:focus { - outline-color: var(--vscode-contrastActiveBorder); -} - -.vscode-high-contrast .title { - border-bottom: 1px solid var(--vscode-contrastBorder); -} - -.vscode-high-contrast button, .vscode-high-contrast input { - outline: none; - background: none !important; - border: 1px solid var(--vscode-contrastBorder); -} - -.vscode-high-contrast button:focus{ - border: 1px solid var(--vscode-contrastActiveBorder); -} - -.vscode-high-contrast button:hover { - border: 1px dotted var(--vscode-contrastActiveBorder); -} - -.vscode-high-contrast .diff .diffLine { - background: none; -} - -.vscode-high-contrast .diff .diffHeader { - background: none; -} - -.vscode-high-contrast .diff-container { - border: 1px solid var(--vscode-contrastBorder); -} - -.vscode-high-contrast .diff .diffLine.add { - border: 1px dashed var(--vscode-diffEditor-insertedTextBorder); -} - -.vscode-high-contrast .diff .diffLine.delete { - border: 1px dashed var(--vscode-diffEditor-removedTextBorder); -} - -@media (max-width: 925px) { - #app { - display: block; - } - - #sidebar { - display: grid; - column-gap: 20px; - grid-template-columns: 50% 50%; - padding: 0; - } - - .section-content { - display: flex; - flex-wrap: wrap; - } - - .section-item { - margin-right: 8px; - } - - body .hidden-focusable { - height: initial; - overflow: initial; - } - - .section-header button { - margin-left: 8px; - display: flex; - } - - .section-item.reviewer { - border: 1px solid var(--vscode-badge-background); - border-radius: 3px; - padding: 2px 6px; - } - - .section-item .avatar { - border-radius: 3px - } - - .section-item .login { - width: auto; - margin-right: 4px; - } -} - -.icon { - width: 1em; - height: 1em; - font-size: 16px; - margin-right: 6px; -} - -.push-right { - margin-left: auto; -} - -.action-bar { - position: absolute; - display: flex; - justify-content: space-between; - z-index: 100; - top: 9px; - right: 9px; -} - -.flex-action-bar { - display: flex; - justify-content: space-between; - z-index: 100; - margin-left: 9px; - min-width: 42px; -} - -.action-bar > button, -.flex-action-bar > button { - margin-left: 4px; - margin-right: 4px; -} - - -.remove-item { - height: 12px; - cursor: pointer; -} - -.title-editing-form { - flex-grow: 1; -} - -.title-editing-form > .form-actions { - margin-left: 0; -} - -::-webkit-scrollbar-corner { - display: none; -} - -.author-link { - font-weight: bolder; - color: var(--vscode-editor-foreground) -} \ No newline at end of file diff --git a/preview-src/merge.tsx b/preview-src/merge.tsx deleted file mode 100644 index 9e60d5d75a..0000000000 --- a/preview-src/merge.tsx +++ /dev/null @@ -1,292 +0,0 @@ -import * as React from 'react'; -import { PullRequest } from './cache'; -import PullRequestContext from './context'; -import { useContext, useReducer, useRef, useState, useEffect, useCallback } from 'react'; -import { GithubItemStateEnum, MergeMethod, PullRequestMergeability } from '../src/github/interface'; -import { checkIcon, deleteIcon, pendingIcon, alertIcon } from './icon'; -import { Avatar, } from './user'; -import { nbsp } from './space'; -import { groupBy } from '../src/common/utils'; - -export const StatusChecks = (pr: PullRequest) => { - if (pr.isIssue) { - return null; - } - const { state, status, mergeable: _mergeable } = pr; - const [showDetails, toggleDetails] = useReducer( - show => !show, - status.statuses.some(s => s.state === 'failure')) as [boolean, () => void]; - - useEffect(() => { - if (status.statuses.some(s => s.state === 'failure')) { - if (!showDetails) { toggleDetails(); } - } else { - if (showDetails) { toggleDetails(); } - } - }, status.statuses); - - const [mergeable, setMergeability] = useState(_mergeable); - const { checkMergeability } = useContext(PullRequestContext); - - useEffect(() => { - const handle = setInterval(async () => { - if (mergeable === PullRequestMergeability.Unknown) { - setMergeability(await checkMergeability()); - } - }, 3000); - return () => clearInterval(handle); - }); - - return
{ - state === GithubItemStateEnum.Merged - ? - <> -
{'Pull request successfully merged'}
- - - : - state === GithubItemStateEnum.Closed - ? - <> -
{'This pull request is closed'}
- - - : - <> - {status.statuses.length - ? <> -
-
- -
{getSummaryLabel(status.statuses)}
- { - showDetails ? 'Hide' : 'Show' - } -
- {showDetails ? - - : null} -
- - : null - } - - - - }
; -}; - -export default StatusChecks; - -export const MergeStatus = ({ mergeable }: Pick) => { - return
- {mergeable === PullRequestMergeability.Mergeable ? checkIcon : - mergeable === PullRequestMergeability.NotMergeable ? deleteIcon : pendingIcon} -
{ - mergeable === PullRequestMergeability.Mergeable - ? 'This branch has no conflicts with the base branch' - : mergeable === PullRequestMergeability.NotMergeable - ? 'This branch has conflicts that must be resolved' - : 'Checking if this branch can be merged...' - }
-
; -}; - -export const ReadyForReview = () => { - const [isBusy, setBusy] = useState(false); - const { readyForReview, updatePR } = useContext(PullRequestContext); - - const markReadyForReview = useCallback( - async () => { - try { - setBusy(true); - await readyForReview(); - updatePR({ isDraft: false }); - } finally { - setBusy(false); - } - }, - [setBusy, readyForReview, updatePR]); - - return
- -
{alertIcon}
-
This pull request is still a work in progress.
- Draft pull requests cannot be merged. -
; -}; - -export const Merge = (pr: PullRequest) => { - const select = useRef(); - const [selectedMethod, selectMethod] = useState(null); - - if (selectedMethod) { - return selectMethod(null)} />; - } - - return
- - {nbsp}using method{nbsp} - -
; -}; - -export const PrActions = (pr: PullRequest) => { - const { hasWritePermission, canEdit, isDraft, mergeable } = pr; - - return isDraft - // Only PR author and users with push rights can mark draft as ready for review - ? canEdit - ? - : null - : mergeable === PullRequestMergeability.Mergeable && hasWritePermission - ? - : null; -}; - -export const DeleteBranch = (pr: PullRequest) => { - const { deleteBranch } = useContext(PullRequestContext); - const [isBusy, setBusy] = useState(false); - - if (pr.head === 'UNKNOWN') { - return
; - } else { - return
-
{ - event.preventDefault(); - - try { - setBusy(true); - const result = await deleteBranch(); - if (result && result.cancelled) { - setBusy(false); - } - } finally { - setBusy(false); - } - } - }> - -
-
; - } -}; - -function ConfirmMerge({ pr, method, cancel }: { pr: PullRequest, method: MergeMethod, cancel: () => void }) { - const { merge, updatePR } = useContext(PullRequestContext); - const [isBusy, setBusy] = useState(false); - - return
{ - event.preventDefault(); - - try { - setBusy(true); - const { title, description }: any = event.target; - const { state } = await merge({ - title: title.value, - description: description.value, - method, - }); - updatePR({ state }); - } finally { - setBusy(false); - } - } - }> - - -
- - - -
-
; -} - -const CommentEventView = (event: CommentEvent) => ; - -const MergedEventView = (event: MergedEvent) => -
-
- {mergeIcon}{nbsp} -
- -
- -
- merged commit{nbsp} - {event.sha.substr(0, 7)}{nbsp} - into {event.mergeRef}{nbsp} -
- -
-
; - -const HeadDeleteEventView = (event: HeadRefDeleteEvent) => -
-
-
- -
- -
- deleted the {event.headRef} branch{nbsp} -
- -
-
; - -// TODO: We should show these, but the pre-React overview page didn't. Add -// support in a separate PR. -const AssignEventView = (event: AssignEvent) => null; diff --git a/preview-src/timestamp.tsx b/preview-src/timestamp.tsx deleted file mode 100644 index dfe55ba28f..0000000000 --- a/preview-src/timestamp.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import * as React from 'react'; - -import { dateFromNow } from '../src/common/utils'; - -export const Timestamp = ({ - date, - href, -}: { - date: Date | string, - href?: string -}) => - href - ? {dateFromNow(date)} - :
{dateFromNow(date)}
; - -export default Timestamp; \ No newline at end of file diff --git a/preview-src/tsconfig.json b/preview-src/tsconfig.json deleted file mode 100644 index 42921665f6..0000000000 --- a/preview-src/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "compilerOptions": { - "outDir": "./dist/", - "module": "commonjs", - "target": "es2017", - "jsx": "react", - "typeRoots": [ - "../node_modules/@types", - "../src/typings/" - ], - "sourceMap": true, - "strict": false, - "noImplicitAny": false, - "noUnusedLocals": true, - "lib": [ - "dom", - "esnext.asynciterable", - "es2017" - ], - }, - "include": [ - "./**/*", - "../src/typings/*" - ] -} \ No newline at end of file diff --git a/preview-src/user.tsx b/preview-src/user.tsx deleted file mode 100644 index 4f660b2192..0000000000 --- a/preview-src/user.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import * as React from 'react'; -import { PullRequest } from './cache'; -import { Icon } from './icon'; - -export const Avatar = ({ for: author }: { for: Partial }) => - - {author.avatarUrl - ? - : } - ; - -export const AuthorLink = ({ for: author, text=author.login }: { for: PullRequest['author'], text?: string }) => - {text}; 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 865cc83c34..0000000000 --- a/resources/icons/check.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/checked_checkbox.png b/resources/icons/checked_checkbox.png deleted file mode 100644 index f8280bdfc3..0000000000 Binary files a/resources/icons/checked_checkbox.png and /dev/null differ 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..445aba2d5c --- /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/cloud-upload.svg b/resources/icons/codicons/cloud-upload.svg new file mode 100644 index 0000000000..12791a5d53 --- /dev/null +++ b/resources/icons/codicons/cloud-upload.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/output.svg b/resources/icons/codicons/output.svg new file mode 100644 index 0000000000..841958c979 --- /dev/null +++ b/resources/icons/codicons/output.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 63d67b4dd6..0000000000 --- a/resources/icons/commit_icon.svg +++ /dev/null @@ -1,5 +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/dark/check.svg b/resources/icons/dark/check.svg deleted file mode 100644 index 865cc83c34..0000000000 --- a/resources/icons/dark/check.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/dark/clean.svg b/resources/icons/dark/clean.svg deleted file mode 100644 index de85d6ba67..0000000000 --- a/resources/icons/dark/clean.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/dark/delete.svg b/resources/icons/dark/delete.svg deleted file mode 100644 index 44ece771f4..0000000000 --- a/resources/icons/dark/delete.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/dark/edit.svg b/resources/icons/dark/edit.svg deleted file mode 100755 index 2dbc4e2095..0000000000 --- a/resources/icons/dark/edit.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/resources/icons/dark/git-pull-request.svg b/resources/icons/dark/git-pull-request.svg deleted file mode 100644 index 1b589729d6..0000000000 --- a/resources/icons/dark/git-pull-request.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - 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/git.svg b/resources/icons/dark/git.svg deleted file mode 100644 index c08b1c2e40..0000000000 --- a/resources/icons/dark/git.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/dark/issue-closed.svg b/resources/icons/dark/issue-closed.svg deleted file mode 100644 index 0dfcdce89c..0000000000 --- a/resources/icons/dark/issue-closed.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/dark/issue_webview.svg b/resources/icons/dark/issue_webview.svg new file mode 100644 index 0000000000..39f5281600 --- /dev/null +++ b/resources/icons/dark/issue_webview.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/resources/icons/dark/issues.svg b/resources/icons/dark/issues.svg deleted file mode 100644 index 96005e9473..0000000000 --- a/resources/icons/dark/issues.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - 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/dark/status-added.svg b/resources/icons/dark/status-added.svg deleted file mode 100644 index cdc40f45f1..0000000000 --- a/resources/icons/dark/status-added.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - A - - \ No newline at end of file diff --git a/resources/icons/dark/status-conflict.svg b/resources/icons/dark/status-conflict.svg deleted file mode 100644 index 53b243c8b9..0000000000 --- a/resources/icons/dark/status-conflict.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - C - - \ No newline at end of file diff --git a/resources/icons/dark/status-copied.svg b/resources/icons/dark/status-copied.svg deleted file mode 100644 index 7bd78c9427..0000000000 --- a/resources/icons/dark/status-copied.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - C - - \ No newline at end of file diff --git a/resources/icons/dark/status-deleted.svg b/resources/icons/dark/status-deleted.svg deleted file mode 100644 index e7596e2e2a..0000000000 --- a/resources/icons/dark/status-deleted.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - D - - \ No newline at end of file diff --git a/resources/icons/dark/status-ignored.svg b/resources/icons/dark/status-ignored.svg deleted file mode 100644 index 85abc367a2..0000000000 --- a/resources/icons/dark/status-ignored.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - I - - \ No newline at end of file diff --git a/resources/icons/dark/status-modified.svg b/resources/icons/dark/status-modified.svg deleted file mode 100644 index d0de37d346..0000000000 --- a/resources/icons/dark/status-modified.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - M - - \ No newline at end of file diff --git a/resources/icons/dark/status-renamed.svg b/resources/icons/dark/status-renamed.svg deleted file mode 100644 index a77fb41179..0000000000 --- a/resources/icons/dark/status-renamed.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - R - - \ No newline at end of file diff --git a/resources/icons/dark/status-untracked.svg b/resources/icons/dark/status-untracked.svg deleted file mode 100644 index c6a48e14f0..0000000000 --- a/resources/icons/dark/status-untracked.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - U - - \ No newline at end of file diff --git a/resources/icons/dark/unstage.svg b/resources/icons/dark/unstage.svg deleted file mode 100644 index 4c5a9c1e3a..0000000000 --- a/resources/icons/dark/unstage.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/delete.svg b/resources/icons/delete.svg deleted file mode 100644 index f8af265cc4..0000000000 --- a/resources/icons/delete.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/diff.svg b/resources/icons/diff.svg deleted file mode 100644 index 17faabf478..0000000000 --- a/resources/icons/diff.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/resources/icons/dot.svg b/resources/icons/dot.svg deleted file mode 100644 index e782b9d0a0..0000000000 --- a/resources/icons/dot.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file 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/issue-closed-red.svg b/resources/icons/issue-closed-red.svg deleted file mode 100644 index 204e5102d4..0000000000 --- a/resources/icons/issue-closed-red.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/issue_webview.svg b/resources/icons/issue_webview.svg new file mode 100644 index 0000000000..97b7e7f497 --- /dev/null +++ b/resources/icons/issue_webview.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/resources/icons/issues-green.svg b/resources/icons/issues-green.svg deleted file mode 100644 index 0a3fdc02e2..0000000000 --- a/resources/icons/issues-green.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/light/check.svg b/resources/icons/light/check.svg deleted file mode 100644 index e1a546660e..0000000000 --- a/resources/icons/light/check.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/light/clean.svg b/resources/icons/light/clean.svg deleted file mode 100644 index b70626957d..0000000000 --- a/resources/icons/light/clean.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/light/delete.svg b/resources/icons/light/delete.svg deleted file mode 100644 index 87b5b74b0d..0000000000 --- a/resources/icons/light/delete.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/light/edit.svg b/resources/icons/light/edit.svg deleted file mode 100755 index ca132521e9..0000000000 --- a/resources/icons/light/edit.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/resources/icons/light/git-pull-request.svg b/resources/icons/light/git-pull-request.svg deleted file mode 100644 index 40226bb5b5..0000000000 --- a/resources/icons/light/git-pull-request.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/light/git.svg b/resources/icons/light/git.svg deleted file mode 100644 index d1049a44d0..0000000000 --- a/resources/icons/light/git.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/light/github.svg b/resources/icons/light/github.svg deleted file mode 100644 index 238ac197ed..0000000000 --- a/resources/icons/light/github.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/light/issue-closed.svg b/resources/icons/light/issue-closed.svg deleted file mode 100644 index 20813a16fc..0000000000 --- a/resources/icons/light/issue-closed.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/light/issues.svg b/resources/icons/light/issues.svg deleted file mode 100644 index 234d8147e4..0000000000 --- a/resources/icons/light/issues.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/light/status-added.svg b/resources/icons/light/status-added.svg deleted file mode 100644 index 587fc08f5f..0000000000 --- a/resources/icons/light/status-added.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - A - - \ No newline at end of file diff --git a/resources/icons/light/status-conflict.svg b/resources/icons/light/status-conflict.svg deleted file mode 100644 index b6088ecd08..0000000000 --- a/resources/icons/light/status-conflict.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - C - - \ No newline at end of file diff --git a/resources/icons/light/status-copied.svg b/resources/icons/light/status-copied.svg deleted file mode 100644 index 151fdb2cdb..0000000000 --- a/resources/icons/light/status-copied.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - C - - \ No newline at end of file diff --git a/resources/icons/light/status-deleted.svg b/resources/icons/light/status-deleted.svg deleted file mode 100644 index 7ed166accf..0000000000 --- a/resources/icons/light/status-deleted.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - D - - \ No newline at end of file diff --git a/resources/icons/light/status-ignored.svg b/resources/icons/light/status-ignored.svg deleted file mode 100644 index 85abc367a2..0000000000 --- a/resources/icons/light/status-ignored.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - I - - \ No newline at end of file diff --git a/resources/icons/light/status-modified.svg b/resources/icons/light/status-modified.svg deleted file mode 100644 index ff338b8141..0000000000 --- a/resources/icons/light/status-modified.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - M - - \ No newline at end of file diff --git a/resources/icons/light/status-renamed.svg b/resources/icons/light/status-renamed.svg deleted file mode 100644 index a77fb41179..0000000000 --- a/resources/icons/light/status-renamed.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - R - - \ No newline at end of file diff --git a/resources/icons/light/status-untracked.svg b/resources/icons/light/status-untracked.svg deleted file mode 100644 index c6a48e14f0..0000000000 --- a/resources/icons/light/status-untracked.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - U - - \ No newline at end of file diff --git a/resources/icons/light/unstage.svg b/resources/icons/light/unstage.svg deleted file mode 100644 index d12a8ee313..0000000000 --- a/resources/icons/light/unstage.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - 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/pull-request.svg b/resources/icons/pull-request.svg deleted file mode 100644 index 6cacc35741..0000000000 --- a/resources/icons/pull-request.svg +++ /dev/null @@ -1,3 +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/thumbs_up.svg b/resources/icons/thumbs_up.svg deleted file mode 100644 index 70bc85f89b..0000000000 --- a/resources/icons/thumbs_up.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/unchecked_checkbox.png b/resources/icons/unchecked_checkbox.png deleted file mode 100644 index 849e857eb9..0000000000 Binary files a/resources/icons/unchecked_checkbox.png and /dev/null differ 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 f72221ebef..5d835ccc52 100644 --- a/scripts/ci/common-setup.yml +++ b/scripts/ci/common-setup.yml @@ -1,14 +1,9 @@ steps: + - task: NodeTool@0 + displayName: Upgrade Node + inputs: + versionSpec: "20.x" -- task: NodeTool@0 - displayName: Upgrade Node - inputs: - versionSpec: '10.x' - -- task: geeklearningio.gl-vsts-tasks-yarn.yarn-installer-task.YarnInstaller@2 - displayName: Install Yarn - inputs: - versionSpec: 1.16.0 - -- script: yarn install - displayName: Install dependencies \ No newline at end of file + - script: npm ci + displayName: Install dependencies + retryCountOnTaskFailure: 3 diff --git a/scripts/prepare-nightly-build.js b/scripts/prepare-nightly-build.js index d53112b967..10ab642291 100644 --- a/scripts/prepare-nightly-build.js +++ b/scripts/prepare-nightly-build.js @@ -1,48 +1,35 @@ -const fs = require('fs'); -const argv = require('minimist')(process.argv.slice(2)); - -const json = JSON.parse(fs.readFileSync('./package.json').toString()); - -// update name, publisher and description -// calculate version -let version = argv['v']; -if (typeof(version) !== 'string') { - const date = new Date(); - const monthMinutes = (date.getDate() - 1) * 24 * 60 + date.getHours() * 60 + date.getMinutes(); - version = `${date.getFullYear()}.${date.getMonth() + 1}.${monthMinutes}`; -} - -const id = argv['i']; -const displayName = argv['n']; -const description = argv['d']; -const publisher = argv['p']; -if (!id || !displayName || !description || !publisher) { - return; -} - -const insiderPackageJson = Object.assign(json, { - name: id, - version: version, - displayName: displayName, - description: description, - publisher: publisher -}); - -fs.writeFileSync('./package.insiders.json', JSON.stringify(insiderPackageJson)); - -const readme = fs.readFileSync('./README.md'); -const previewReadme = ` -# GitHub Pull Request Nightly Build - -This is the nightly build of [GitHub Pull Request extension](https://marketplace.visualstudio.com/items?itemName=GitHub.vscode-pull-request-github) for early feedback and testing. - -The extension can be installed side-by-side with the current GitHub Pull Request extension, use the Extensions Viewlet to disable this version of the extension you do not want to use. - -${readme} -`; - -fs.writeFileSync('./README.insiders.md', previewReadme); - -const constants = fs.readFileSync('./src/constants.ts').toString(); -const insiderConstants = constants.replace(`export const EXTENSION_ID = 'GitHub.vscode-pull-request-github';`, `export const EXTENSION_ID = 'GitHub.vscode-pull-request-github-insiders';`); -fs.writeFileSync('./src/constants.insiders.ts', insiderConstants); +/*--------------------------------------------------------------------------------------------- + * 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 json = JSON.parse(fs.readFileSync('./package.json').toString()); +const stableVersion = json.version.match(/(\d+)\.(\d+)\.(\d+)/); +const major = stableVersion[1]; +const minor = stableVersion[2]; + +function prependZero(number) { + if (number > 99) { + throw 'Unexpected value to prepend with zero'; + } + return `${number < 10 ? '0' : ''}${number}`; +} + +// update name, publisher and description +// calculate version +// 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. +const insiderPackageJson = Object.assign(json, { + version: `${major}.${Number(minor)+1}.${patch}` +}); + +fs.writeFileSync('./package.insiders.json', JSON.stringify(insiderPackageJson)); \ No newline at end of file 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/scripts/preprocess-gql.js b/scripts/preprocess-gql.js index 34264f06a5..7ff2f2b01e 100644 --- a/scripts/preprocess-gql.js +++ b/scripts/preprocess-gql.js @@ -20,11 +20,11 @@ Options: const argv = minimist(process.argv.slice(2), { string: ['in', 'out'], boolean: ['help'], - alias: {h: 'help', i: 'in', o: 'out'}, + alias: { h: 'help', i: 'in', o: 'out' }, unknown: param => { console.error(`Unrecognized command-line argument: ${param}\n`); printUsage(console.error, 1); - } + }, }); if (argv.help) { @@ -39,6 +39,6 @@ if (!inFilename || !outFilename) { printUsage(console.error, 1); } -const querySource = fs.readFileSync(inFilename, {encoding: 'utf8'}); +const querySource = fs.readFileSync(inFilename, { encoding: 'utf8' }); const jsSource = gqlLoader.call({ cacheable() {} }, querySource); -fs.writeFileSync(outFilename, jsSource, {encoding: 'utf8'}); \ No newline at end of file +fs.writeFileSync(outFilename, jsSource, { encoding: 'utf8' }); diff --git a/scripts/preprocess-svg.js b/scripts/preprocess-svg.js index a050ecfbe0..a04963208e 100644 --- a/scripts/preprocess-svg.js +++ b/scripts/preprocess-svg.js @@ -24,11 +24,11 @@ Options: const argv = minimist(process.argv.slice(2), { string: ['in', 'out'], boolean: ['help'], - alias: {h: 'help', i: 'in', o: 'out'}, + alias: { h: 'help', i: 'in', o: 'out' }, unknown: param => { console.error(`Unrecognized command-line argument: ${param}\n`); printUsage(console.error, 1); - } + }, }); if (argv.help) { @@ -47,20 +47,20 @@ const readFile = util.promisify(fs.readFile); const writeFile = util.promisify(fs.writeFile); async function processFile(inFilename, outFilename) { - const originalSource = await readFile(inFilename, {encoding: 'utf8'}); + const originalSource = await readFile(inFilename, { encoding: 'utf8' }); const moduleSource = svgLoader(originalSource); - await writeFile(outFilename, moduleSource, {encoding: 'utf8'}); + await writeFile(outFilename, moduleSource, { encoding: 'utf8' }); } const glob = util.promisify(globCb); const mkdirp = util.promisify(mkdirpCb); async function processDirectory(inDirectory, outDirectory) { - const files = await glob('**/*.svg', {cwd: inDirectory}); + const files = await glob('**/*.svg', { cwd: inDirectory }); return Promise.all( files.map(async subPath => { - const inFilename = path.join(inDirectory, subPath); - const outFilename = path.join(outDirectory, subPath); + const inFilename = path.join(__dirname, inDirectory, subPath); + const outFilename = path.join(__dirname, outDirectory, subPath); await mkdirp(path.dirname(outFilename)); await processFile(inFilename, outFilename); }), @@ -70,4 +70,4 @@ async function processDirectory(inDirectory, outDirectory) { processDirectory(inRoot, outRoot).catch(error => { console.error(error.stack); process.exit(1); -}); \ No newline at end of file +}); diff --git a/src/typings/git-credential-node.d.ts b/src/@types/git-credential-node.d.ts similarity index 100% rename from src/typings/git-credential-node.d.ts rename to src/@types/git-credential-node.d.ts diff --git a/src/typings/git.d.ts b/src/@types/git.d.ts similarity index 51% rename from src/typings/git.d.ts rename to src/@types/git.d.ts index 40c8dfce69..8f25bbd79f 100644 --- a/src/typings/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 } from 'vscode'; +import { Uri, Event, Disposable, ProviderResult, Command, CancellationToken, SourceControlHistoryItem } from 'vscode'; export { ProviderResult } from 'vscode'; export interface Git { @@ -14,6 +14,12 @@ export interface InputBox { value: string; } +export const enum ForcePushMode { + Force, + ForceWithLease, + ForceWithLeaseIfIncludes, +} + export const enum RefType { Head, RemoteHead, @@ -24,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 { @@ -38,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; @@ -46,6 +60,7 @@ export interface Commit { readonly authorName?: string; readonly authorEmail?: string; readonly commitDate?: Date; + readonly shortStat?: CommitShortStat; } export interface Submodule { @@ -61,6 +76,14 @@ export interface Remote { readonly isReadOnly: boolean; } +export interface Worktree { + readonly name: string; + readonly path: string; + readonly ref: string; + readonly main: boolean; + readonly detached: boolean; +} + export const enum Status { INDEX_MODIFIED, INDEX_ADDED, @@ -73,6 +96,8 @@ export const enum Status { UNTRACKED, IGNORED, INTENT_TO_ADD, + INTENT_TO_RENAME, + TYPE_CHANGED, ADDED_BY_US, ADDED_BY_THEM, @@ -96,16 +121,20 @@ export interface Change { readonly status: Status; } +export type RepositoryKind = 'repository' | 'submodule' | 'worktree'; + export interface RepositoryState { readonly HEAD: Branch | undefined; readonly refs: Ref[]; readonly remotes: Remote[]; readonly submodules: Submodule[]; + readonly worktrees: Worktree[]; readonly rebaseCommit: Commit | undefined; readonly mergeChanges: Change[]; readonly indexChanges: Change[]; readonly workingTreeChanges: Change[]; + readonly untrackedChanges: Change[]; readonly onDidChange: Event; } @@ -122,6 +151,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 { @@ -130,13 +169,53 @@ export interface CommitOptions { signoff?: boolean; signCommit?: boolean; empty?: boolean; + noVerify?: boolean; + requireUserConfig?: boolean; + useEditor?: boolean; + verbose?: boolean; + /** + * string - execute the specified command after the commit operation + * undefined - execute the command specified in git.postCommitCommand + * after the commit operation + * null - do not execute any command after the commit operation + */ + postCommitCommand?: string | null; } -export interface BranchQuery { - readonly remote?: boolean; - readonly pattern?: string; - readonly count?: number; +export interface FetchOptions { + remote?: string; + ref?: string; + all?: boolean; + prune?: boolean; + 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 | string[]; + readonly sort?: 'alphabetically' | 'committerdate'; +} + +export interface BranchQuery extends RefQuery { + readonly remote?: boolean; } export interface Repository { @@ -146,9 +225,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 }>; @@ -157,6 +240,8 @@ export interface Repository { show(ref: string, path: string): Promise; getCommit(ref: string): Promise; + add(paths: string[]): Promise; + revert(paths: string[]): Promise; clean(paths: string[]): Promise; apply(patch: string, reverse?: boolean): Promise; @@ -178,10 +263,18 @@ export interface Repository { createBranch(name: string, checkout: boolean, ref?: string): Promise; deleteBranch(name: string, force?: boolean): Promise; getBranch(name: string): Promise; - getBranches(query: BranchQuery): Promise; + getBranches(query: BranchQuery, cancellationToken?: CancellationToken): Promise; + getBranchBase(name: string): Promise; setBranchUpstream(name: string, upstream: string): Promise; - getMergeBase(ref1: string, ref2: string): Promise; + checkIgnore(paths: string[]): Promise>; + + getRefs(query: RefQuery, cancellationToken?: CancellationToken): Promise; + + getMergeBase(ref1: string, ref2: string): Promise; + + tag(name: string, upstream: string): Promise; + deleteTag(name: string): Promise; status(): Promise; checkout(treeish: string): Promise; @@ -190,14 +283,30 @@ export interface Repository { removeRemote(name: string): Promise; renameRemote(name: string, newName: string): Promise; + fetch(options?: FetchOptions): Promise; fetch(remote?: string, ref?: string, depth?: number): Promise; pull(unshallow?: boolean): Promise; - push(remoteName?: string, branchName?: string, setUpstream?: boolean): Promise; + push(remoteName?: string, branchName?: string, setUpstream?: boolean, force?: ForcePushMode): Promise; blame(path: string): Promise; 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; + + createWorktree?(options?: { path?: string; commitish?: string; branch?: string }): Promise; + deleteWorktree?(path: string, options?: { force?: boolean }): Promise; + + migrateChanges?(sourceRepositoryPath: string, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise; + + generateRandomBranchName?(): Promise; + + isBranchProtected?(branch?: Branch): boolean; } export interface RemoteSource { @@ -211,13 +320,76 @@ export interface RemoteSourceProvider { readonly icon?: string; // codicon name readonly supportsQuery?: boolean; getRemoteSources(query?: string): ProviderResult; + getBranches?(url: string): ProviderResult; + publishRepository?(repository: Repository): Promise; +} + +export interface RemoteSourcePublisher { + readonly name: string; + readonly icon?: string; // codicon name + publishRepository(repository: Repository): Promise; +} + +export interface Credentials { + readonly username: string; + readonly password: string; +} + +export interface CredentialsProvider { + getCredentials(host: Uri): ProviderResult; +} + +export interface PostCommitCommandsProvider { + getCommands(repository: Repository): Command[]; +} + +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 { + repository: Repository; + branch?: string; +} + export interface GitAPI { readonly state: APIState; readonly onDidChangeState: Event; + readonly onDidPublish: Event; readonly git: Git; readonly repositories: Repository[]; readonly onDidOpenRepository: Event; @@ -225,8 +397,24 @@ export interface GitAPI { toGitUri(uri: Uri, ref: string): Uri; getRepository(uri: Uri): Repository | null; - init(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 { @@ -237,33 +425,38 @@ export interface GitExtension { /** * Returns a specific API version. * - * Throws error if git extension is disabled. You can listed to the + * Throws error if git extension is disabled. You can listen to the * [GitExtension.onDidChangeEnablement](#GitExtension.onDidChangeEnablement) event * to know when the extension becomes enabled/disabled. * * @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', GitNotFound = 'GitNotFound', CantCreatePipe = 'CantCreatePipe', + PermissionDenied = 'PermissionDenied', CantAccessRemote = 'CantAccessRemote', RepositoryNotFound = 'RepositoryNotFound', RepositoryIsLocked = 'RepositoryIsLocked', @@ -282,4 +475,13 @@ export const enum GitErrorCodes { PatchDoesNotApply = 'PatchDoesNotApply', NoPathFound = 'NoPathFound', UnknownPath = 'UnknownPath', + EmptyCommitMessage = 'EmptyCommitMessage', + BranchFastForwardRejected = 'BranchFastForwardRejected', + BranchNotYetBorn = 'BranchNotYetBorn', + TagConflict = 'TagConflict', + CherryPickEmpty = 'CherryPickEmpty', + CherryPickConflict = 'CherryPickConflict', + WorktreeContainsChanges = 'WorktreeContainsChanges', + WorktreeAlreadyExists = 'WorktreeAlreadyExists', + WorktreeBranchAlreadyUsed = 'WorktreeBranchAlreadyUsed' } diff --git a/src/typings/graphql.d.ts b/src/@types/graphql.d.ts similarity index 100% rename from src/typings/graphql.d.ts rename to src/@types/graphql.d.ts diff --git a/src/typings/jsdom-global.d.ts b/src/@types/jsdom-global.d.ts similarity index 100% rename from src/typings/jsdom-global.d.ts rename to src/@types/jsdom-global.d.ts diff --git a/src/@types/lib.textEncoder.d.ts b/src/@types/lib.textEncoder.d.ts new file mode 100644 index 0000000000..02e1b4890a --- /dev/null +++ b/src/@types/lib.textEncoder.d.ts @@ -0,0 +1,11 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Define TextEncoder + TextDecoder globals for both browser and node runtimes +// +// Proper fix: https://github.com/microsoft/TypeScript/issues/31535 + +declare let TextDecoder: typeof import('util').TextDecoder; +declare let TextEncoder: typeof import('util').TextEncoder; diff --git a/src/typings/ref.d.ts b/src/@types/ref.d.ts similarity index 87% rename from src/typings/ref.d.ts rename to src/@types/ref.d.ts index 1446278035..3400855f10 100644 --- a/src/typings/ref.d.ts +++ b/src/@types/ref.d.ts @@ -5,9 +5,7 @@ /// /// -/// /// -declare module 'js-base64'; declare module 'tunnel'; declare module 'ssh-config'; diff --git a/src/typings/svg-inline-loader.d.ts b/src/@types/svg-inline-loader.d.ts similarity index 100% rename from src/typings/svg-inline-loader.d.ts rename to src/@types/svg-inline-loader.d.ts diff --git a/src/@types/vscode-test-web.d.ts b/src/@types/vscode-test-web.d.ts new file mode 100644 index 0000000000..185bba2287 --- /dev/null +++ b/src/@types/vscode-test-web.d.ts @@ -0,0 +1,61 @@ +#!/usr/bin/env node +export declare type BrowserType = 'chromium' | 'firefox' | 'webkit'; +export declare type VSCodeVersion = 'insiders' | 'stable' | 'sources'; +export interface Options { + /** + * Browser to run the test against: 'chromium' | 'firefox' | 'webkit' + */ + browserType: BrowserType; + /** + * Absolute path to folder that contains one or more extensions (in subfolders). + * Extension folders include a `package.json` extension manifest. + */ + extensionDevelopmentPath?: string; + /** + * Absolute path to the extension tests runner module. + * Can be either a file path or a directory path that contains an `index.js`. + * The module is expected to have a `run` function of the following signature: + * + * ```ts + * function run(): Promise; + * ``` + * + * When running the extension test, the Extension Development Host will call this function + * that runs the test suite. This function should throws an error if any test fails. + */ + extensionTestsPath?: string; + /** + * The VS Code version to use. Valid versions are: + * - `'stable'` : The latest stable build + * - `'insiders'` : The latest insiders build + * - `'sources'`: From sources, served at localhost:8080 by running `yarn web` in the vscode repo + * + * Currently defaults to `insiders`, which is latest stable insiders. + */ + version?: VSCodeVersion; + /** + * Open the dev tools. + */ + devTools?: boolean; + /** + * Do not show the browser. Defaults to `true` if a extensionTestsPath is provided, `false` otherwise. + */ + headless?: boolean; + /** + * Expose browser debugging on this port number, and wait for the debugger to attach before running tests. + */ + waitForDebugger?: number; + /** + * The folder URI to open VSCode on + */ + folderUri?: string; +} +/** + * Runs the tests in a browser. + * + * @param options The options defining browser type, extension and test location. + */ +export declare function runTests(options: Options & { + extensionTestsPath: string; +}): Promise; +export declare function open(options: Options): Promise; 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.chatContextProvider.d.ts b/src/@types/vscode.proposed.chatContextProvider.d.ts new file mode 100644 index 0000000000..00f8a62081 --- /dev/null +++ b/src/@types/vscode.proposed.chatContextProvider.d.ts @@ -0,0 +1,163 @@ +/*--------------------------------------------------------------------------------------------- + * 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 type TabSelector = { uri: DocumentSelector } | { viewType: string }; + + export namespace chat { + + /** + * Register a chat workspace context provider. Workspace context is automatically included in all chat requests. + * + * 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 id Unique identifier for the provider. + * @param provider The chat workspace context provider. + */ + export function registerChatWorkspaceContextProvider(id: string, provider: ChatWorkspaceContextProvider): Disposable; + + /** + * Register a chat explicit context provider. Explicit context items are shown as options when the user explicitly attaches context use the "Attache Context" action in the chat input box. + * + * Explicit context providers should also be statically contributed in package.json using the `chatContext` contribution point. + * + * To ensure your extension is activated when chat context is requested, make sure to include the `onChatContextProvider:` activation event in your `package.json`. + * + * @param id Unique identifier for the provider. + * @param provider The chat explicit context provider. + */ + export function registerChatAttachContextProvider(id: string, provider: ChatAttachContextProvider): Disposable; + + /** + * @deprecated + */ + export function registerChatExplicitContextProvider(id: string, provider: any): Disposable; + + + /** + * Register a chat resource context provider. Resource context is provided for a specific resource. + * Make sure to pass a selector that matches the resource you want to provide context for. + * + * To ensure your extension is activated when chat context is requested, make sure to include the `onChatContextProvider:` activation event in your `package.json`. + * + * @param selector Document selector to filter which resources the provider is called for. + * @param id Unique identifier for the provider. + * @param provider The chat resource context provider. + */ + export function registerChatTabContextProvider(selector: TabSelector, id: string, provider: ChatTabContextProvider): Disposable; + + /** + * @deprecated + */ + export function registerChatResourceContextProvider(selector: DocumentSelector, id: string, provider: any): Disposable; + + } + + export interface ChatContextItem { + /** + * Icon for the context item. + * - If `icon` is not defined, no icon is shown. + * - If `icon` is defined and is a file or folder icon, the icon is derived from {@link resourceUri} if `resourceUri` is defined. + * - Otherwise, `icon` is used. + */ + icon?: ThemeIcon; + /** + * Human readable label for the context item. + * If not set, the label is derived from {@link resourceUri}. + */ + label?: string; + /** + * A resource URI for the context item. + * Used to derive the {@link label} and {@link icon} if they are not set. + */ + resourceUri?: Uri; + /** + * 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 ChatWorkspaceContextProvider { + + /** + * An optional event that should be fired when the workspace chat context has changed. + */ + onDidChangeWorkspaceChatContext?: Event; + + /** + * 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; + } + + export interface ChatAttachContextProvider { + + /** + * 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. + */ + provideAttachChatContext(token: CancellationToken): ProviderResult; + + /** + * If a chat context item is provided without a `value`, this method is called to resolve the `value` for the item. + * + * @param context The context item to resolve. + * @param token A cancellation token. + */ + resolveAttachChatContext(context: T, token: CancellationToken): ProviderResult; + } + + export interface ChatTabContextProvider { + + /** + * 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. + */ + // Can use active editor?\ + // Rename ChatTab to be consistent + provideChatTabContext(options: { tab: Tab }, token: CancellationToken): ProviderResult; + + /** + * If a chat context item is provided without a `value`, this method is called to resolve the `value` for the item. + * + * @param context The context item to resolve. + * @param token A cancellation token. + */ + resolveChatTabContext(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..567c509670 --- /dev/null +++ b/src/@types/vscode.proposed.chatParticipantAdditions.d.ts @@ -0,0 +1,1110 @@ +/*--------------------------------------------------------------------------------------------- + * 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[]); + } + + /** + * An option for a question in a carousel. + */ + export interface ChatQuestionOption { + /** + * Unique identifier for the option. + */ + id: string; + /** + * The display label for the option. + */ + label: string; + /** + * The value returned when this option is selected. + */ + value: unknown; + } + + /** + * The type of question for a chat question carousel. + */ + export enum ChatQuestionType { + /** + * A free-form text input question. + */ + Text = 1, + /** + * A single-select question with radio buttons. + */ + SingleSelect = 2, + /** + * A multi-select question with checkboxes. + */ + MultiSelect = 3 + } + + /** + * A question to be displayed in a question carousel. + */ + export class ChatQuestion { + /** + * Unique identifier for the question. + */ + id: string; + /** + * The type of question: Text for free-form input, SingleSelect for radio buttons, MultiSelect for checkboxes. + */ + type: ChatQuestionType; + /** + * The title/header of the question. + */ + title: string; + /** + * Optional detailed message or description for the question. + */ + message?: string | MarkdownString; + /** + * Options for singleSelect or multiSelect questions. + */ + options?: ChatQuestionOption[]; + /** + * The id(s) of the default selected option(s). + * For SingleSelect, this should be a single option id. + * For MultiSelect, this can be an array of option ids. + */ + defaultValue?: string | string[]; + /** + * Whether to allow free-form text input in addition to predefined options. + * When true, users can provide their own text answer even for SingleSelect or MultiSelect questions. + */ + allowFreeformInput?: boolean; + + constructor( + id: string, + type: ChatQuestionType, + title: string, + options?: { + message?: string | MarkdownString; + options?: ChatQuestionOption[]; + defaultValue?: string | string[]; + allowFreeformInput?: boolean; + } + ); + } + + /** + * A carousel view for presenting multiple questions inline in the chat. + * The UI is displayed but does not block the chat input. + */ + export class ChatResponseQuestionCarouselPart { + /** + * The questions to display in the carousel. + */ + questions: ChatQuestion[]; + /** + * Whether users can skip answering the questions. + */ + allowSkip: boolean; + + constructor(questions: ChatQuestion[], allowSkip?: boolean); + } + + 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; + + /** + * Overrides for how the command is presented in the UI. + * For example, when a `cd && ` prefix is detected, + * the presentation can show only the actual command. + */ + presentationOverrides?: { + commandLine: 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 enum ChatTodoStatus { + NotStarted = 1, + InProgress = 2, + Completed = 3 + } + + export interface ChatTodoToolInvocationData { + todoList: Array<{ + id: number; + title: string; + status: ChatTodoStatus; + }>; + } + + /** + * Generic tool result data that displays input and output in collapsible sections. + */ + export interface ChatSimpleToolResultData { + /** + * The input to display. + */ + input: string; + /** + * The output to display. + */ + output: string; + } + + + export interface ChatToolResourcesInvocationData { + /** + * Array of file URIs or locations to display as a collapsible list + */ + values: Array; + } + + export class ChatSubagentToolInvocationData { + /** + * A description of the subagent's purpose or task. + */ + description?: string; + + /** + * The name of the subagent being invoked. + */ + agentName?: string; + + /** + * The prompt given to the subagent. + */ + prompt?: string; + + /** + * The result text from the subagent after completion. + */ + result?: string; + + constructor(description?: string, agentName?: string, prompt?: string, result?: string); + } + + export class ChatToolInvocationPart { + toolName: string; + toolCallId: string; + isError?: boolean; + invocationMessage?: string | MarkdownString; + originMessage?: string | MarkdownString; + pastTenseMessage?: string | MarkdownString; + isConfirmed?: boolean; + isComplete?: boolean; + toolSpecificData?: ChatTerminalToolInvocationData | ChatMcpToolInvocationData | ChatTodoToolInvocationData | ChatSimpleToolResultData | ChatToolResourcesInvocationData | ChatSubagentToolInvocationData; + subAgentInvocationId?: string; + presentation?: 'hidden' | 'hiddenAfterComplete' | undefined; + + /** + * If this flag is set, this will be treated as an update to any previous tool call with the same id. + */ + enablePartialUpdate?: boolean; + + constructor(toolName: string, toolCallId: string, errorMessage?: string); + } + + /** + * 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); + } + + /** + * Internal type that lists all the proposed chat response parts. This is used to generate `ExtendedChatResponsePart` + * which is the actual type used in this API. This is done so that other proposals can easily add their own response parts + * without having to modify this file. + */ + export interface ExtendedChatResponseParts { + ChatResponsePart: ChatResponsePart; + ChatResponseTextEditPart: ChatResponseTextEditPart; + ChatResponseNotebookEditPart: ChatResponseNotebookEditPart; + ChatResponseWorkspaceEditPart: ChatResponseWorkspaceEditPart; + ChatResponseConfirmationPart: ChatResponseConfirmationPart; + ChatResponseCodeCitationPart: ChatResponseCodeCitationPart; + ChatResponseReferencePart2: ChatResponseReferencePart2; + ChatResponseMovePart: ChatResponseMovePart; + ChatResponseExtensionsPart: ChatResponseExtensionsPart; + ChatResponsePullRequestPart: ChatResponsePullRequestPart; + ChatToolInvocationPart: ChatToolInvocationPart; + ChatResponseMultiDiffPart: ChatResponseMultiDiffPart; + ChatResponseThinkingProgressPart: ChatResponseThinkingProgressPart; + ChatResponseExternalEditPart: ChatResponseExternalEditPart; + ChatResponseQuestionCarouselPart: ChatResponseQuestionCarouselPart; + ChatResponseAutoModeResolutionPart: ChatResponseAutoModeResolutionPart; + } + + export type ExtendedChatResponsePart = ExtendedChatResponseParts[keyof ExtendedChatResponseParts]; + + export class ChatResponseWarningPart { + value: MarkdownString; + constructor(value: string | MarkdownString); + } + + export class ChatResponseInfoPart { + 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 { + /** + * @deprecated use `command` instead + */ + readonly uri?: Uri; + readonly command: Command; + readonly linkTag: string; + readonly title: string; + readonly description: string; + readonly author: string; + constructor(uriOrCommand: Uri | Command, title: string, description: string, author: string, linkTag: string); + } + + /** + * Represents an auto-mode model routing resolution. Displayed as a collapsible + * widget in the chat stream showing which model was selected and why. + */ + export class ChatResponseAutoModeResolutionPart { + /** The model ID that was selected by the router */ + resolvedModel: string; + /** The user-facing display name of the resolved model */ + resolvedModelName: string; + /** The router's classification label */ + predictedLabel: string; + /** Confidence score (0-1) from the router */ + confidence: number; + constructor(resolvedModel: string, resolvedModelName: string, predictedLabel: string, confidence: number); + } + + 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; + + /** + * Show an inline carousel of questions to gather information from the user. + * This is a blocking call that waits for the user to submit or skip the questions. + * @param questions Array of questions to display to the user + * @param allowSkip Whether the user can skip questions without answering + * @returns A promise that resolves with the user's answers, or undefined if skipped + */ + questionCarousel(questions: ChatQuestion[], allowSkip?: boolean): Thenable | undefined>; + + /** + * 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; + + /** + * Push an info banner to this stream. Short-hand for + * `push(new ChatResponseInfoPart(message))`. + * + * @param message An informational message + * @returns This stream. + */ + info(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; + + /** + * Report token usage information for this request. + * This is typically called when the underlying language model provides usage statistics. + * @param usage Token usage information including prompt and completion tokens + */ + usage(usage: ChatResultUsage): 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; + + /** + * The number of tokens reserved for the response. + * This is rendered specially in the UI to indicate that these tokens aren't used but are reserved. + */ + readonly outputBuffer?: number; + + /** + * The number of copilot credits consumed by this request. + */ + readonly copilotCredits?: 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; + } + + 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 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; + 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 { + /** set when the mode a custom agent (not built-in), to be used as identifier */ + readonly uri?: Uri; + readonly name: string; + readonly content: string; + readonly toolReferences?: readonly ChatLanguageModelToolReference[]; + readonly allowedSubagents?: readonly string[]; + readonly metadata?: Record; + /** + * Whether the mode is a builtin mode (e.g. Ask, Edit, Agent) rather than a user or extension-defined custom mode. + */ + readonly isBuiltin?: boolean; + } +} diff --git a/src/@types/vscode.proposed.chatParticipantPrivate.d.ts b/src/@types/vscode.proposed.chatParticipantPrivate.d.ts new file mode 100644 index 0000000000..f0fad8b953 --- /dev/null +++ b/src/@types/vscode.proposed.chatParticipantPrivate.d.ts @@ -0,0 +1,514 @@ +/*--------------------------------------------------------------------------------------------- + * 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 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; + + /** + * The request ID of the parent request that invoked this subagent. + */ + readonly parentRequestId?: string; + + /** + * The permission level for tool auto-approval in this request. + * - `'autoApprove'`: Auto-approve all tool calls and retry on errors. + * - `'autopilot'`: Everything autoApprove does plus continues until the task is done. + */ + readonly permissionLevel?: string; + + /** + * Whether any hooks are enabled for this request. + */ + readonly hasHooksEnabled: boolean; + + /** + * When true, this request was initiated by the system (e.g. a terminal + * command completion notification) rather than by the user typing a + * message. Extensions can use this to render the prompt differently + * and skip billing. + */ + readonly isSystemInitiated?: boolean; + } + + 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 id of the chat request. Used to identity an interaction with any of the chat surfaces. + */ + readonly id?: string; + /** + * 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[]; + + /** + * The identifier of the language model that was used for this request, if known. + */ + readonly modelId?: string; + + /** + * The mode instructions that were active for this request, if any. + */ + readonly modeInstructions2?: ChatRequestModeInstructions; + + /** + * @hidden + */ + constructor(prompt: string, command: string | undefined, references: ChatPromptReference[], participant: string, toolReferences: ChatLanguageModelToolReference[], editedFileEvents: ChatRequestEditedFileEvent[] | undefined, id: string | undefined, modelId: string | undefined, modeInstructions2: ChatRequestModeInstructions | 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; + + /** + * If true, the error is an expected operational condition (e.g. user-actionable + * configuration, network connectivity, missing dependency) and should not be + * logged as a `chatAgentError` telemetry event. The error is still surfaced to + * the user. Throwing an `Error` whose `name` is `'ChatExpectedError'` from a + * chat participant handler will set this flag automatically. + */ + isExpectedError?: 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 type PreToolUsePermissionDecision = 'allow' | 'deny' | 'ask'; + + export interface LanguageModelToolInvocationOptions { + chatRequestId?: string; + chatSessionResource?: Uri; + chatInteractionId?: string; + terminalCommand?: string; + /** + * The working directory URI for the session, if set. + * In the agents window, each session can have its own working directory + * that differs from the current workspace folders. + */ + workingDirectory?: Uri; + /** + * Unique ID for the subagent invocation, used to group tool calls from the same subagent run together. + */ + subAgentInvocationId?: string; + /** + * W3C trace context `traceparent` header value identifying the active distributed + * tracing span. When provided to a tool implementation backed by an MCP server, this + * value is forwarded as `_meta.traceparent` on the JSON-RPC `tools/call` request so + * downstream servers can correlate their spans (MCP SEP-414). + */ + traceparent?: string; + /** + * Optional W3C trace context `tracestate` header value paired with `traceparent`. + */ + tracestate?: string; + /** + * Pre-tool-use hook result, if the hook was already executed by the caller. + * When provided, the tools service will skip executing its own preToolUse hook + * and use this result for permission decisions and input modifications instead. + */ + preToolUseResult?: { + permissionDecision?: PreToolUsePermissionDecision; + permissionDecisionReason?: string; + updatedInput?: object; + }; + } + + export interface LanguageModelToolInvocationPrepareOptions { + /** + * The input that the tool is being invoked with. + */ + input: T; + chatRequestId?: string; + chatSessionResource?: Uri; + chatInteractionId?: string; + /** + * The working directory URI for the session, if set. + * In the agents window, each session can have its own working directory + * that differs from the current workspace folders. + */ + workingDirectory?: Uri; + /** + * If set, tells the tool that it should include confirmation messages. + */ + forceConfirmationReason?: 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; + } + + export namespace window { + /** + * The resource URI of the currently active chat panel session, + * or `undefined` if there is no active chat panel session. + */ + export const activeChatPanelSessionResource: Uri | undefined; + + /** + * An event that fires when the active chat panel session resource changes. + */ + export const onDidChangeActiveChatPanelSessionResource: 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 + + // #region Steering + + export interface ChatContext { + /** + * Set to `true` by the editor to request the language model gracefully + * stop after its next opportunity. When set, it's likely that the editor + * will immediately follow up with a new request in the same conversation. + */ + readonly yieldRequested: boolean; + + /** + * The resource URI identifying the chat session this context belongs to. + * Available when the context is provided for title generation, summarization, + * or other session-scoped operations. Extracted from the session's history entries. + */ + readonly sessionResource?: Uri; + } + + // #endregion + + export interface LanguageModelToolInformation { + /** + * The full reference name of this tool as used in agent definition files. + */ + readonly fullReferenceName?: string; + } + + // #region Quota Sync + + /** + * A snapshot of quota usage for a single category (chat, completions, premium chat). + */ + export interface ChatQuotaSnapshot { + readonly percentRemaining: number; + readonly unlimited: boolean; + readonly hasQuota?: boolean; + readonly resetAt?: number; + readonly usageBasedBilling?: boolean; + readonly entitlement?: number; + readonly quotaRemaining?: number; + } + + /** + * A snapshot of rate limit usage for a category (session or weekly). + */ + export interface ChatRateLimitSnapshot { + readonly percentRemaining: number; + readonly unlimited: boolean; + readonly resetDate?: string; + } + + /** + * Quota snapshot data covering all categories. + * Accepted by {@link chat.updateQuotas} for extension-to-core sync. + */ + export interface ChatQuotaSnapshots { + readonly resetDate?: string; + readonly resetDateHasTime?: boolean; + readonly usageBasedBilling?: boolean; + readonly canUpgradePlan?: boolean; + readonly chat?: ChatQuotaSnapshot; + readonly completions?: ChatQuotaSnapshot; + readonly premiumChat?: ChatQuotaSnapshot; + readonly additionalUsageEnabled?: boolean; + readonly additionalUsageCount?: number; + readonly sessionRateLimit?: ChatRateLimitSnapshot; + readonly weeklyRateLimit?: ChatRateLimitSnapshot; + } + + export namespace chat { + /** + * Push quota snapshot data from the extension to the core workbench. + */ + export function updateQuotas(quotas: ChatQuotaSnapshots): void; + } + + // #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..1a34efa788 --- /dev/null +++ b/src/@types/vscode.proposed.chatSessionsProvider.d.ts @@ -0,0 +1,843 @@ +/*--------------------------------------------------------------------------------------------- + * 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' { + /** + * 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, + + /** + * The chat session needs user input (e.g. an unresolved confirmation). + */ + NeedsInput = 3 + } + + export namespace chat { + /** + * Registers a new {@link ChatSessionItemProvider chat session item provider}. + * + * @deprecated Use {@linkcode createChatSessionItemController} instead. + * + * 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. + * + * 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 refreshHandler The controller's {@link ChatSessionItemController.refreshHandler refresh handler}. + * + * @returns A new controller instance that can be used to manage chat session items for the given chat session type. + */ + export function createChatSessionItemController(chatSessionType: string, refreshHandler: ChatSessionItemControllerRefreshHandler): ChatSessionItemController; + } + + /** + * Provides a list of information about chat sessions. + * + * @deprecated Use {@linkcode ChatSessionItemController} instead. + */ + 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; + + /** + * @deprecated Use {@linkcode ChatSessionItemController.resolveChatSessionItem} instead. + * + * Given a chat session item fill in more data, like {@link ChatSessionItem.timing timing}, + * {@link ChatSessionItem.changes changes}, or {@link ChatSessionItem.badge badge}. + * + * The editor will call this when a chat session item becomes visible in the UI, for example + * when the user scrolls to it or when it is first rendered. + * + * @param item A chat session item currently visible in the UI. Treat this as read-only. + * @param token A cancellation token. + * @returns A new {@link ChatSessionItem} instance (or a thenable that resolves to one) with the + * same `resource` as `item` and any additional properties filled in. When no result is returned, + * the given `item` is left unchanged. + */ + resolveChatSessionItem?: (item: ChatSessionItem, 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 + } + + /** + * Extension callback invoked to refresh the collection of chat session items for a {@linkcode ChatSessionItemController}. + */ + export type ChatSessionItemControllerRefreshHandler = (token: CancellationToken) => Thenable; + + export interface ChatSessionItemControllerNewItemHandlerContext { + // TODO: Use a better type but for now decrease this down to just the prompt and command since that's all we currently need. + // The problem with ChatRequest is that it has a resourceUri which is not good for this code path. + readonly request: { + readonly prompt: string; + readonly command?: string; + }; + + readonly inputState: ChatSessionInputState; + } + + /** + * Extension callback invoked when a new chat session is started. + */ + export type ChatSessionItemControllerNewItemHandler = (context: ChatSessionItemControllerNewItemHandlerContext, token: CancellationToken) => Thenable; + + /** + * Extension callback invoked to get the input state for a chat session. + * + * @param sessionResource The resource of the chat session to get the input state for. `undefined` indicates this is + * for a blank chat editor that is not yet associated with a session. + * @param context Additional context + * @param token Cancellation token. + * + * @return A new chat session input state. This should be created using {@link ChatSessionItemController.createChatSessionInputState}. + */ + export type ChatSessionControllerGetInputState = (sessionResource: Uri | undefined, context: { + /** + * The previous input state for the session. + */ + readonly previousInputState: ChatSessionInputState | undefined; + }, token: CancellationToken) => Thenable | ChatSessionInputState; + + /** + * Extension callback invoked to fork an existing chat session item managed by a {@linkcode ChatSessionItemController}. + * + * The handler should create a new session on the provider's backend and + * return the new {@link ChatSessionItem} representing the forked session. + * + * @param sessionResource The resource of the chat session being forked. + * @param request The request turn that marks the fork point. The forked session includes all turns + * upto this request turn and excludes this request turn itself. If undefined, fork the full session. + * @param token A cancellation token. + * @returns The forked session item. + */ + export type ChatSessionItemControllerForkHandler = (sessionResource: Uri, request: ChatRequestTurn2 | undefined, token: CancellationToken) => Thenable | ChatSessionItem; + + /** + * Manages chat sessions for a specific chat session type + */ + 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 can 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. + */ + readonly refreshHandler: ChatSessionItemControllerRefreshHandler; + + /** + * Fired when an item's archived state changes. + */ + readonly onDidChangeChatSessionItemState: Event; + + /** + * Invoked when a new chat session is started. + * + * This allows the controller to initialize the chat session item with information from the initial request. + * + * The returned chat session is added to the collection and shown in the UI. + */ + newChatSessionItemHandler?: ChatSessionItemControllerNewItemHandler; + + /** + * Invoked when an existing chat session is forked. + * + * When both this handler and {@linkcode ChatSession.forkHandler} are registered, + * this handler takes precedence. + */ + forkHandler?: ChatSessionItemControllerForkHandler; + + /** + * Gets the input state for a chat session. + */ + getChatSessionInputState?: ChatSessionControllerGetInputState; + + /** + * Called to fill in more data on a chat session item, like {@link ChatSessionItem.timing timing}, + * {@link ChatSessionItem.changes changes}, or {@link ChatSessionItem.badge badge}. + * + * The editor will call this when a chat session item becomes visible in the UI, for example + * when the user scrolls to it or when it is first rendered. + * + * The editor will only resolve a chat session item once, unless the item is updated via + * {@link ChatSessionItemCollection.add add} or {@link ChatSessionItemCollection.replace replace}, + * which invalidates the resolve cache. + * + * The handler should update the item in the {@link ChatSessionItemController.items items collection} via + * {@link ChatSessionItemCollection.add add}. The editor picks up the updated item from + * the collection after the returned thenable resolves. + * + * @param item A chat session item currently visible in the UI. + * @param token A cancellation token. + */ + resolveChatSessionItem?: (item: ChatSessionItem, token: CancellationToken) => Thenable; + + /** + * Create a new managed ChatSessionInputState object. + */ + createChatSessionInputState(groups: ChatSessionProviderOptionGroup[]): ChatSessionInputState; + } + + /** + * 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. If two items have the same resource URI, the last one will be used. + */ + 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; + } + + /** + * A chat session show in the UI. + * + * This should be created by calling a {@link ChatSessionItemController.createChatSessionItem createChatSessionItem} + * method on the controller. The item can then be added to the controller's {@link ChatSessionItemController.items items collection} + * to show it in the UI. + */ + 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. + */ + readonly 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; + + /** + * Resource identifier this item was previously known by. When set, host-stored + * per-resource state (archive, pin, read) recorded under that URI is treated as + * also applying to this item. + * + * On first access of state for {@link resource}, the host adopts the entry + * stored under `legacyResource` forward — copying it onto {@link resource} and + * removing the legacy entry. The migration is transparent: no events fire and + * the effective user-visible state is unchanged. + * + * Intended for providers that need to change the URI shape they emit (e.g. during + * a backend or schema migration) without requiring users to re-archive or re-pin + * items. + * + * The legacy URI's scheme must match {@link resource}'s scheme; otherwise the + * field is ignored. Multi-hop migrations are not supported — providers should + * collapse intermediate hops on their side and emit the original URI. + */ + readonly legacyResource?: Uri; + + /** + * Timing information for the chat session + */ + timing?: { + /** + * Timestamp when the session was created in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + */ + readonly 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. + */ + readonly 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. + */ + readonly lastRequestEnded?: number; + + /** + * Session start timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * @deprecated Use `created` and `lastRequestStarted` instead. + */ + readonly startTime?: number; + + /** + * Session end timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * @deprecated Use `lastRequestEnded` instead. + */ + readonly endTime?: number; + }; + + /** + * Statistics about the chat session. + */ + changes?: readonly ChatSessionChangedFile[]; + + /** + * Arbitrary metadata for the chat session. Can be anything, but must be JSON-stringifyable. + * + * To update the metadata you must re-set this property. + */ + metadata?: { readonly [key: string]: any }; + } + + export class ChatSessionChangedFile { + /** + * 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 { + /** + * An optional title for the chat session. + * + * When provided, this title is used as the display name for the session + * (e.g. in the editor tab). When not provided, the title defaults to + * the first user message in the session history. + */ + readonly title?: string; + + /** + * 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. + // TODO: pass in options? + readonly requestHandler: ChatRequestHandler | undefined; + + /** + * Handles a request to fork the session. + * + * The handler should create a new session on the provider's backend and + * return the new {@link ChatSessionItem} representing the forked session. + * + * @deprecated Use {@linkcode ChatSessionItemController.forkHandler} instead. This remains supported for backwards compatibility. + * + * @param sessionResource The resource of the chat session being forked. + * @param request The request turn that marks the fork point. The forked session includes all turns + * upto this request turn and excludes this request turn itself. If undefined, fork the full session. + * @param token A cancellation token. + * @returns The forked session item. + */ + readonly forkHandler?: ChatSessionItemControllerForkHandler; + } + + /** + * 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 { + /** + * @deprecated + * + * Event that the provider can fire to signal that the options for a chat session have changed. + */ + readonly onDidChangeChatSessionOptions?: Event; + + /** + * @deprecated + * + * 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. + * @param context Additional context for the chat session. + * + * @return The {@link ChatSession chat session} associated with the given URI. + */ + provideChatSessionContent(resource: Uri, token: CancellationToken, context: { + readonly inputState: ChatSessionInputState; + }): Thenable | ChatSession; + + /** + * @deprecated + * + * @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; + + /** + * @deprecated + * + * Called as soon as you register (call me once) + */ + 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. + * @param defaultChatParticipant The default {@link ChatParticipant chat participant} used in sessions provided by this provider. + * + * @returns A disposable that unregisters the provider when disposed. + */ + export function registerChatSessionContentProvider(scheme: string, provider: ChatSessionContentProvider, defaultChatParticipant: 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..) + + /** @deprecated This will be removed along with the concept of `untitled-` sessions. */ + readonly isUntitled: boolean; + + /** + * The initial option selections for the session, provided with the first request. + * Contains the options the user selected (or defaults) before the session was created. + * + * @deprecated Use `inputState` instead + */ + readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string | ChatSessionProviderOptionItem }>; + + /** + * The current input state of the chat session. + */ + readonly inputState: ChatSessionInputState; + } + + 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; + + /** + * Optional slash-command alias (without leading `/`) that selects this option + * when the user submits `/`. Does not send a chat request; only + * updates the selection so the next prompt runs with this option active. + * + * Scoped to chat sessions owned by the contributing provider. Names must be + * unique across the provider's groups; on conflict, the first declared wins. + */ + readonly slashCommand?: string; + + /** + * Optional tooltip content shown in a hover panel when the user focuses or + * hovers over this item in the picker. Supports markdown formatting. + */ + readonly tooltip?: string; + + /** + * Optional model metadata for this option item. When present, the picker + * renders a rich hover with model name, pricing, context size, and capabilities + * instead of a plain text tooltip. + */ + readonly modelMetadata?: ChatSessionProviderOptionModelMetadata; + } + + /** + * Metadata describing a language model, used to render rich hover content + * in option group pickers. Fields mirror {@link LanguageModelChatInformation} + * so the core can reuse its standard model hover rendering. + */ + export interface ChatSessionProviderOptionModelMetadata { + readonly name: string; + readonly id: string; + readonly vendor?: string; + readonly version?: string; + readonly family?: string; + readonly tooltip?: string; + readonly pricing?: string; + readonly multiplierNumeric?: number; + readonly inputCost?: number; + readonly outputCost?: number; + readonly cacheCost?: number; + readonly cacheWriteCost?: number; + readonly longContextInputCost?: number; + readonly longContextOutputCost?: number; + readonly longContextCacheCost?: number; + readonly longContextCacheWriteCost?: number; + readonly priceCategory?: string; + readonly maxInputTokens?: number; + readonly maxOutputTokens?: number; + readonly capabilities?: { + readonly vision?: boolean; + readonly toolCalling?: 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 currently selected option for this group. This must be one of the items provided in the `items` array. + */ + readonly selected?: ChatSessionProviderOptionItem; + + /** + * The selectable items within this option group. + */ + readonly items: readonly 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; + + /** + * An icon for the option group shown in UI. + */ + readonly icon?: ThemeIcon; + + /** + * Optional commands. + * + * These commands will be displayed at the bottom of the group. + * + * For extensions using the legacy `commands` API, these commands are passed the sessionResource as the first argument. + * + * For extensions that use the new `provideChatSessionInputState` API, these commands are passed a context object + * `{ inputState: ChatSessionInputState; sessionResource: Uri | undefined }` that they can use to determine which session and options they are being invoked for. + */ + readonly commands?: Command[]; + + /** + * Optional kind that hints how this option group should be presented in the UI. + * + * - `'permissions'`: The group represents tool-approval permissions for the session. + * The editor will not render this group as its own picker. Instead, its items + * replace the built-in items in the chat permission picker for the session, + * and the user's selection is reported back through the standard + * {@link ChatSessionContentProvider.handleChatSessionOptionsChange} flow. + * At most one option group per provider may use this kind; if more than one is + * declared, the first one (in declaration order) is used. The group is invisible + * if the chat permission picker itself is hidden by other `when` clauses. + * + * When omitted, the group is rendered as a standalone picker as usual. + */ + readonly kind?: 'permissions'; + } + + export interface ChatSessionProviderOptions { + /** + * Provider-defined option groups (0-2 groups supported). + * Examples: models picker, sub-agents picker, etc. + */ + readonly optionGroups?: readonly ChatSessionProviderOptionGroup[]; + + /** + * The set of default options used for new chat sessions, provided as key-value pairs. + * + * Keys correspond to option group IDs (e.g., 'models', 'subagents'). + */ + readonly newSessionOptions?: Record; + } + + /** + * Represents the current state of user inputs for a chat session. + */ + export interface ChatSessionInputState { + /** + * Fired when the input state is disposed. + */ + readonly onDidDispose: Event; + + /** + * Fired when the input state is changed by the user. + * + * Move to controller? + */ + readonly onDidChange: Event; + + /** + * The resource associated with this chat session. + * + * This is `undefined` for chat sessions that have not yet started. + */ + readonly sessionResource: Uri | undefined; + + /** + * The groups of options to show in the UI for user input. + * + * To update the groups you must replace the entire `groups` array with a new array. + */ + groups: readonly ChatSessionProviderOptionGroup[]; + } +} diff --git a/src/@types/vscode.proposed.codeActionRanges.d.ts b/src/@types/vscode.proposed.codeActionRanges.d.ts new file mode 100644 index 0000000000..350be2d553 --- /dev/null +++ b/src/@types/vscode.proposed.codeActionRanges.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. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + export interface CodeAction { + /** + * 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. + */ + ranges?: Range[]; + } +} diff --git a/src/@types/vscode.proposed.codiconDecoration.d.ts b/src/@types/vscode.proposed.codiconDecoration.d.ts new file mode 100644 index 0000000000..2a0fc4578b --- /dev/null +++ b/src/@types/vscode.proposed.codiconDecoration.d.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. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + // https://github.com/microsoft/vscode/issues/135591 @alexr00 + + // export interface FileDecorationProvider { + // provideFileDecoration(uri: Uri, token: CancellationToken): ProviderResult; + // } + + /** + * A file decoration represents metadata that can be rendered with a file. + */ + export class FileDecoration2 { + /** + * A very short string that represents this decoration. + */ + badge?: string | ThemeIcon; + + /** + * A human-readable tooltip for this decoration. + */ + tooltip?: string; + + /** + * The color of this decoration. + */ + color?: ThemeColor; + + /** + * A flag expressing that this decoration should be + * propagated to its parents. + */ + propagate?: boolean; + + /** + * Creates a new decoration. + * + * @param badge A letter that represents the decoration. + * @param tooltip The tooltip of the decoration. + * @param color The color of the decoration. + */ + constructor(badge?: string | ThemeIcon, tooltip?: string, color?: ThemeColor); + } +} 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.commentsDraftState.d.ts b/src/@types/vscode.proposed.commentsDraftState.d.ts new file mode 100644 index 0000000000..ee41130910 --- /dev/null +++ b/src/@types/vscode.proposed.commentsDraftState.d.ts @@ -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. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + // https://github.com/microsoft/vscode/issues/171166 + + export enum CommentState { + Published = 0, + Draft = 1 + } + + export interface Comment { + 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.contribCommentEditorActionsMenu.d.ts b/src/@types/vscode.proposed.contribCommentEditorActionsMenu.d.ts new file mode 100644 index 0000000000..9b24bcc32e --- /dev/null +++ b/src/@types/vscode.proposed.contribCommentEditorActionsMenu.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 `comments/comment/editorActions` menu diff --git a/src/@types/vscode.proposed.contribCommentPeekContext.d.ts b/src/@types/vscode.proposed.contribCommentPeekContext.d.ts new file mode 100644 index 0000000000..251df53c3a --- /dev/null +++ b/src/@types/vscode.proposed.contribCommentPeekContext.d.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * 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 comment peek context menus + +// https://github.com/microsoft/vscode/issues/151533 @alexr00 + + diff --git a/src/@types/vscode.proposed.contribCommentThreadAdditionalMenu.d.ts b/src/@types/vscode.proposed.contribCommentThreadAdditionalMenu.d.ts new file mode 100644 index 0000000000..2e71d90a25 --- /dev/null +++ b/src/@types/vscode.proposed.contribCommentThreadAdditionalMenu.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 comment thread additional menus + +// https://github.com/microsoft/vscode/issues/163281 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 new file mode 100644 index 0000000000..e308029d4e --- /dev/null +++ b/src/@types/vscode.proposed.contribShareMenu.d.ts @@ -0,0 +1,7 @@ +/*--------------------------------------------------------------------------------------------- + * 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 `file/share`-submenu contribution point +// https://github.com/microsoft/vscode/issues/176316 diff --git a/src/@types/vscode.proposed.diffCommand.d.ts b/src/@types/vscode.proposed.diffCommand.d.ts new file mode 100644 index 0000000000..84f4328e07 --- /dev/null +++ b/src/@types/vscode.proposed.diffCommand.d.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * 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/84899 + + /** + * The contiguous set of modified lines in a diff. + */ + export interface LineChange { + readonly originalStartLineNumber: number; + readonly originalEndLineNumber: number; + readonly modifiedStartLineNumber: number; + readonly modifiedEndLineNumber: number; + } + + export namespace commands { + + /** + * Registers a diff information command that can be invoked via a keyboard shortcut, + * a menu item, an action, or directly. + * + * Diff information commands are different from ordinary {@link commands.registerCommand commands} as + * they only execute when there is an active diff editor when the command is called, and the diff + * information has been computed. Also, the command handler of an editor command has access to + * the diff information. + * + * @param command A unique identifier for the command. + * @param callback A command handler function with access to the {@link LineChange diff information}. + * @param thisArg The `this` context used when invoking the handler function. + * @return Disposable which unregisters this command on disposal. + */ + export function registerDiffInformationCommand(command: string, callback: (diff: LineChange[], ...args: any[]) => any, thisArg?: any): Disposable; + } +} 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.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 new file mode 100644 index 0000000000..43f4c93599 --- /dev/null +++ b/src/@types/vscode.proposed.quickDiffProvider.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' { + + // https://github.com/microsoft/vscode/issues/169012 + + export namespace window { + 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.tokenInformation.d.ts b/src/@types/vscode.proposed.tokenInformation.d.ts new file mode 100644 index 0000000000..1c783b2bdc --- /dev/null +++ b/src/@types/vscode.proposed.tokenInformation.d.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * 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/91555 + + export enum StandardTokenType { + Other = 0, + Comment = 1, + String = 2, + RegEx = 3 + } + + export interface TokenInformation { + type: StandardTokenType; + range: Range; + } + + export namespace languages { + /** @deprecated */ + export function getTokenInformationAtPosition(document: TextDocument, position: Position): Thenable; + } +} 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 5200180bb4..89d1cfd021 100644 --- a/src/api/api.d.ts +++ b/src/api/api.d.ts @@ -3,15 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Uri, Event, Disposable } from 'vscode'; -import { APIState } from '../typings/git'; +import { CancellationToken, Disposable, Event, Uri } from 'vscode'; +import { APIState, PublishEvent } from '../@types/git'; -export const enum RefType { - Head, - RemoteHead, - Tag +export interface InputBox { + value: string; } +export { RefType } from './api1'; + export interface Ref { readonly type: RefType; readonly name?: string; @@ -53,30 +53,17 @@ 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 interface Worktree { + readonly name: string; + readonly path: string; + readonly ref: string; + readonly main: boolean; + readonly detached: boolean; } -export interface Change { +export { Status } from './api1'; +export interface Change { /** * Returns either `originalUri` or `renameUri`, depending * on whether this change is a rename change. When @@ -90,9 +77,9 @@ export interface Change { export interface RepositoryState { readonly HEAD: Branch | undefined; - readonly refs: Ref[]; readonly remotes: Remote[]; readonly submodules: Submodule[]; + readonly worktrees?: Worktree[]; readonly rebaseCommit: Commit | undefined; readonly mergeChanges: Change[]; @@ -113,10 +100,30 @@ export interface CommitOptions { signoff?: boolean; signCommit?: boolean; empty?: boolean; + noVerify?: boolean; } -export interface Repository { +export interface FetchOptions { + remote?: string; + ref?: string; + all?: boolean; + prune?: boolean; + depth?: number; +} + +export interface RefQuery { + readonly contains?: string; + readonly count?: number; + readonly pattern?: string; + readonly sort?: 'alphabetically' | 'committerdate'; +} +export interface BranchQuery extends RefQuery { + readonly remote?: boolean; +} + +export interface Repository { + readonly inputBox: InputBox; readonly rootUri: Uri; readonly state: RepositoryState; readonly ui: RepositoryUIState; @@ -142,7 +149,7 @@ export interface Repository { * github-pr-owner-number = "Microsoft#vscode-pull-request-github#123" * ``` */ - getConfigs(): Promise<{ key: string; value: string; }[]>; + getConfigs(): Promise<{ key: string; value: string }[]>; /** * Git providers are recommended to implement a minimal key value lookup for git config but you can only provide config for following keys to activate GH PR successfully @@ -156,10 +163,11 @@ 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 }>; - detectObjectType(object: string): Promise<{ mimetype: string, encoding?: string }>; + getObjectDetails(treeish: string, path: string): Promise<{ mode: string; object: string; size: number }>; + detectObjectType(object: string): Promise<{ mimetype: string; encoding?: string }>; buffer(ref: string, path: string): Promise; show(ref: string, path: string): Promise; getCommit(ref: string): Promise; @@ -185,8 +193,12 @@ export interface Repository { createBranch(name: string, checkout: boolean, ref?: string): Promise; deleteBranch(name: string, force?: boolean): Promise; getBranch(name: string): Promise; + getBranches(query: BranchQuery): Promise; + getBranchBase(name: string): Promise; setBranchUpstream(name: string, upstream: string): Promise; - getMergeBase(ref1: string, ref2: string): Promise; + getRefs?(query: RefQuery, cancellationToken?: CancellationToken): Promise; // Optional, because Remote Hub doesn't support this + + getMergeBase(ref1: string, ref2: string): Promise; status(): Promise; checkout(treeish: string): Promise; @@ -195,6 +207,7 @@ export interface Repository { removeRemote(name: string): Promise; renameRemote(name: string, newName: string): Promise; + fetch(options?: FetchOptions): Promise; fetch(remote?: string, ref?: string, depth?: number): Promise; pull(unshallow?: boolean): Promise; push(remoteName?: string, branchName?: string, setUpstream?: boolean): Promise; @@ -203,6 +216,12 @@ export interface Repository { log(options?: LogOptions): Promise; commit(message: string, opts?: CommitOptions): Promise; + add(paths: string[]): Promise; + merge(ref: string): Promise; + mergeAbort(): Promise; + + createWorktree?(options?: { path?: string; commitish?: string; branch?: string }): Promise; + deleteWorktree?(path: string, options?: { force?: boolean }): Promise; } /** @@ -211,43 +230,17 @@ export interface Repository { 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 const enum GitErrorCodes { - BadConfigFile = 'BadConfigFile', - AuthenticationFailed = 'AuthenticationFailed', - NoUserNameConfigured = 'NoUserNameConfigured', - NoUserEmailConfigured = 'NoUserEmailConfigured', - NoRemoteRepositorySpecified = 'NoRemoteRepositorySpecified', - NotAGitRepository = 'NotAGitRepository', - NotAtRepositoryRoot = 'NotAtRepositoryRoot', - Conflict = 'Conflict', - StashConflict = 'StashConflict', - UnmergedChanges = 'UnmergedChanges', - PushRejected = 'PushRejected', - RemoteConnectionError = 'RemoteConnectionError', - DirtyWorkTree = 'DirtyWorkTree', - CantOpenResource = 'CantOpenResource', - GitNotFound = 'GitNotFound', - CantCreatePipe = 'CantCreatePipe', - CantAccessRemote = 'CantAccessRemote', - RepositoryNotFound = 'RepositoryNotFound', - RepositoryIsLocked = 'RepositoryIsLocked', - BranchNotFullyMerged = 'BranchNotFullyMerged', - NoRemoteReference = 'NoRemoteReference', - InvalidBranchName = 'InvalidBranchName', - BranchAlreadyExists = 'BranchAlreadyExists', - NoLocalChanges = 'NoLocalChanges', - NoStashFound = 'NoStashFound', - LocalChangesOverwritten = 'LocalChangesOverwritten', - NoUpstreamBranch = 'NoUpstreamBranch', - IsInSubmodule = 'IsInSubmodule', - WrongCase = 'WrongCase', - CantLockRef = 'CantLockRef', - CantRebaseMultipleBranches = 'CantRebaseMultipleBranches', - PatchDoesNotApply = 'PatchDoesNotApply' +export interface PostCommitCommandsProvider { + getCommands(repository: Repository): Command[]; } +export { GitErrorCodes } from './api1'; + export interface IGit { readonly repositories: Repository[]; readonly onDidOpenRepository: Event; @@ -256,6 +249,39 @@ export interface IGit { // Used by the actual git extension to indicate it has finished initializing state information readonly state?: APIState; readonly onDidChangeState?: Event; + 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, compareBranch?: 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 { @@ -271,4 +297,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 8c93d8fd6a..4eabe95f06 100644 --- a/src/api/api1.ts +++ b/src/api/api1.ts @@ -4,20 +4,111 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { API, IGit, Repository } from './api'; +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 { APIState } from '../typings/git'; +import { RepositoriesManager } from '../github/repositoriesManager'; -export class ApiImpl implements API, IGit, vscode.Disposable { +export const enum RefType { + Head, + RemoteHead, + Tag, +} + +export const enum GitErrorCodes { + BadConfigFile = 'BadConfigFile', + AuthenticationFailed = 'AuthenticationFailed', + NoUserNameConfigured = 'NoUserNameConfigured', + NoUserEmailConfigured = 'NoUserEmailConfigured', + NoRemoteRepositorySpecified = 'NoRemoteRepositorySpecified', + NotAGitRepository = 'NotAGitRepository', + NotAtRepositoryRoot = 'NotAtRepositoryRoot', + Conflict = 'Conflict', + StashConflict = 'StashConflict', + UnmergedChanges = 'UnmergedChanges', + PushRejected = 'PushRejected', + RemoteConnectionError = 'RemoteConnectionError', + DirtyWorkTree = 'DirtyWorkTree', + CantOpenResource = 'CantOpenResource', + GitNotFound = 'GitNotFound', + CantCreatePipe = 'CantCreatePipe', + CantAccessRemote = 'CantAccessRemote', + RepositoryNotFound = 'RepositoryNotFound', + RepositoryIsLocked = 'RepositoryIsLocked', + BranchNotFullyMerged = 'BranchNotFullyMerged', + NoRemoteReference = 'NoRemoteReference', + InvalidBranchName = 'InvalidBranchName', + BranchAlreadyExists = 'BranchAlreadyExists', + NoLocalChanges = 'NoLocalChanges', + NoStashFound = 'NoStashFound', + LocalChangesOverwritten = 'LocalChangesOverwritten', + NoUpstreamBranch = 'NoUpstreamBranch', + IsInSubmodule = 'IsInSubmodule', + WrongCase = 'WrongCase', + CantLockRef = 'CantLockRef', + CantRebaseMultipleBranches = 'CantRebaseMultipleBranches', + PatchDoesNotApply = 'PatchDoesNotApply', +} + +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[] = []; - this._providers.forEach(provider => { - if (provider.repositories) { - ret.push(...provider.repositories); + this._providers.forEach(({ repositories }) => { + if (repositories) { + ret.push(...repositories); } }); @@ -25,15 +116,17 @@ export class ApiImpl implements API, IGit, vscode.Disposable { } public get state(): APIState | undefined { - let state: APIState | undefined; + if (this._providers.size === 0) { + return undefined; + } - this._providers.forEach(provider => { - if (provider.state) { - state = provider.state; + for (const [, { state }] of this._providers) { + if (state !== 'initialized') { + return 'uninitialized'; } - }); + } - return state; + return 'initialized'; } private _onDidOpenRepository = new vscode.EventEmitter(); @@ -42,59 +135,136 @@ export class ApiImpl implements API, IGit, vscode.Disposable { readonly onDidCloseRepository: vscode.Event = this._onDidCloseRepository.event; private _onDidChangeState = new vscode.EventEmitter(); readonly onDidChangeState: vscode.Event = this._onDidChangeState.event; + 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; + }, 0); + vscode.commands.executeCommand('setContext', 'gitHubOpenRepositoryCount', reposCount); } registerGitProvider(provider: IGit): vscode.Disposable { - const handler = this._nextHandle(); - this._providers.set(handler, 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 => this._onDidOpenRepository.fire(e))); + 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._register(provider.onDidPublish(e => this._onDidPublish.fire(e))); } + this._updateReposContext(); provider.repositories.forEach(repository => { this._onDidOpenRepository.fire(repository); }); return { dispose: () => { - if (provider && provider.repositories) { - provider.repositories.forEach(repository => { - this._onDidCloseRepository.fire(repository); - }); + const repos = provider?.repositories; + if (repos && repos.length > 0) { + repos.forEach(r => this._onDidCloseRepository.fire(r)); } - this._providers.delete(handler); - } + this._providers.delete(handle); + }, }; } getGitProvider(uri: vscode.Uri): IGit | undefined { - const foldersMap = TernarySearchTree.forPaths(); + const foldersMap = TernarySearchTree.forUris(); this._providers.forEach(provider => { - if (provider.repositories) { - const repositories = provider.repositories; - - for (const repository of repositories) { - foldersMap.set(repository.rootUri.toString(), provider); + const repos = provider.repositories; + if (repos && repos.length > 0) { + for (const repository of repos) { + foldersMap.set(repository.rootUri, provider); } } }); - return foldersMap.findSubstr(uri.toString()); + return foldersMap.findSubstr(uri); + } + + registerPostCommitCommandsProvider(provider: PostCommitCommandsProvider): vscode.Disposable { + const disposables = Array.from(this._providers.values()).map(gitProvider => { + if (gitProvider.registerPostCommitCommandsProvider) { + return gitProvider.registerPostCommitCommandsProvider(provider); + } + return { dispose: () => { } }; + }); + return { + dispose: () => disposables.forEach(disposable => disposable.dispose()) + }; } private _nextHandle(): number { - return ApiImpl._handlePool++; + return GitApiImpl._handlePool++; + } + + 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; } - dispose() { - this._disposables.forEach(disposable => disposable.dispose()); + 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 + }; + } + } } -} \ No newline at end of file +} diff --git a/src/authentication/configuration.ts b/src/authentication/configuration.ts index ba7b4cceb5..0f48622c76 100644 --- a/src/authentication/configuration.ts +++ b/src/authentication/configuration.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 * as vscode from 'vscode'; export interface IHostConfiguration { @@ -15,8 +20,13 @@ export const HostHelper = class { return vscode.Uri.parse(testEnv); } - const result = await vscode.window.showInformationMessage(`The 'GITHUB_TEST_SERVER' environment variable is set to '${testEnv}'. Use this as the GitHub API endpoint?`, { modal: true }, 'Yes'); - if (result === 'Yes') { + const yes = vscode.l10n.t('Yes'); + const result = await vscode.window.showInformationMessage( + vscode.l10n.t('The \'GITHUB_TEST_SERVER\' environment variable is set to \'{0}\'. Use this as the GitHub API endpoint?', testEnv), + { modal: true }, + yes, + ); + if (result === yes) { USE_TEST_SERVER = true; return vscode.Uri.parse(testEnv); } diff --git a/src/authentication/githubServer.ts b/src/authentication/githubServer.ts index e06877c626..78f192c177 100644 --- a/src/authentication/githubServer.ts +++ b/src/authentication/githubServer.ts @@ -1,41 +1,125 @@ -import * as https from 'https'; +/*--------------------------------------------------------------------------------------------- + * 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 Logger from '../common/logger'; -import { agent } from '../common/net'; import { HostHelper } from './configuration'; +import { GitHubServerType } from '../common/authentication'; +import Logger from '../common/logger'; +import { ITelemetry } from '../common/telemetry'; +import { agent } from '../env/node/net'; +import { getEnterpriseUri } from '../github/utils'; export class GitHubManager { - private _servers: Map = new Map().set('github.com', true); + private static readonly _githubDotComServers = new Set().add('github.com').add('ssh.github.com'); + 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 static readonly _reportedEnterpriseVersions = new Set(); + 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][]); + + constructor(private readonly _telemetry?: ITelemetry) { } + + public static isGithubDotCom(host: string): boolean { + return this._githubDotComServers.has(host); + } - public async isGitHub(host: vscode.Uri): Promise { + public static isNeverGitHub(host: string): boolean { + return this._neverGitHubServers.has(host); + } + + private reportEnterpriseVersion(version: string): void { + if (GitHubManager._reportedEnterpriseVersions.has(version)) { + return; + } + GitHubManager._reportedEnterpriseVersions.add(version); + Logger.appendLine(`GitHub Enterprise version: ${version}`, 'GitHubServer'); + /* __GDPR__ + "github.enterprise.version" : { + "version" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this._telemetry?.sendTelemetryEvent('github.enterprise.version', { version }); + } + + public async isGitHub(host: vscode.Uri): Promise { if (host === null) { - return false; + return GitHubServerType.None; } + const authority = host.authority.toLowerCase(); - if (this._servers.has(host.authority)) { - return !!this._servers.get(host.authority); + // .wiki/.git repos are not supported + if (host.path.endsWith('.wiki') || authority.match(/gist[.]github[.]com/)) { + return GitHubServerType.None; } - const options = await GitHubManager.getOptions(host, 'HEAD', '/rate_limit'); - return new Promise((resolve, _) => { - const get = https.request(options, res => { - const ret = res.headers['x-github-request-id']; - resolve(ret !== undefined); - }); + 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()) && (!matchingKnownServer || (this._knownServers.get(matchingKnownServer) === GitHubServerType.None))) { + return GitHubServerType.Enterprise; + } + + if (matchingKnownServer) { + return this._knownServers.get(matchingKnownServer) ?? GitHubServerType.None; + } - get.end(); - get.on('error', err => { - Logger.appendLine(`No response from host ${host}: ${err.message}`, 'GitHubServer'); - resolve(false); + const [uri, options] = await GitHubManager.getOptions(host, 'HEAD', '/rate_limit'); + + let isGitHub = GitHubServerType.None; + try { + const response = await fetch(uri.toString(), options); + const otherGitHubHeaders: string[] = []; + response.headers.forEach((_value, header) => { + otherGitHubHeaders.push(header); }); - }).then(isGitHub => { - Logger.debug(`Host ${host} is associated with GitHub: ${isGitHub}`, 'GitHubServer'); - this._servers.set(host.authority, isGitHub); + Logger.debug(`All headers: ${otherGitHubHeaders.join(', ')}`, 'GitHubServer'); + const gitHubHeader = response.headers.get('x-github-request-id'); + const gitHubEnterpriseHeader = response.headers.get('x-github-enterprise-version'); + if (gitHubEnterpriseHeader) { + this.reportEnterpriseVersion(gitHubEnterpriseHeader); + } + if (!gitHubHeader && !gitHubEnterpriseHeader) { + const [uriFallBack] = await GitHubManager.getOptions(host, 'HEAD', '/status'); + const response = await fetch(uriFallBack.toString()); + const responseText = await response.text(); + if (responseText.startsWith('GitHub lives!')) { + // We've made it this far so it's not github.com + // It's very likely enterprise. + isGitHub = GitHubServerType.Enterprise; + } else { + // Check if we got an enterprise-looking needs auth response: + // { message: 'Must authenticate to access this API.', documentation_url: 'https://docs.github.com/enterprise/3.3/rest'} + Logger.appendLine(`Received fallback response from the server: ${responseText}`, 'GitHubServer'); + const parsedResponse = JSON.parse(responseText); + if (parsedResponse.documentation_url && (parsedResponse.documentation_url as string).startsWith('https://docs.github.com/enterprise')) { + isGitHub = GitHubServerType.Enterprise; + } + } + } else { + isGitHub = ((gitHubHeader !== undefined) && (gitHubHeader !== null)) ? (gitHubEnterpriseHeader ? GitHubServerType.Enterprise : GitHubServerType.GitHubDotCom) : GitHubServerType.None; + } + return isGitHub; + } catch (ex) { + Logger.warn(`No response from host ${host}: ${ex.message}`, 'GitHubServer'); return isGitHub; - }); + } finally { + Logger.debug(`Host ${host} is associated with GitHub: ${isGitHub}`, 'GitHubServer'); + this._knownServers.set(authority, isGitHub); + } } - public static async getOptions(hostUri: vscode.Uri, method: string = 'GET', path: string, token?: string) { + public static async getOptions( + hostUri: vscode.Uri, + method: string = 'GET', + path: string, + token?: string, + ): Promise<[vscode.Uri, RequestInit]> { const headers: { 'user-agent': string; authorization?: string; @@ -46,13 +130,18 @@ export class GitHubManager { headers.authorization = `token ${token}`; } - return { - host: (await HostHelper.getApiHost(hostUri)).authority, + const uri = vscode.Uri.joinPath(await HostHelper.getApiHost(hostUri), HostHelper.getApiPath(hostUri, path)); + const requestInit = { + hostname: uri.authority, port: 443, method, - path: HostHelper.getApiPath(hostUri, path), headers, - agent, + agent }; + + return [ + uri, + requestInit as RequestInit, + ]; } } diff --git a/src/commands.ts b/src/commands.ts index 7de4b9a58e..79f4303e59 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -4,628 +4,2379 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; -import * as vscode from 'vscode'; import * as pathLib from 'path'; -import { ReviewManager } from './view/reviewManager'; -import { PullRequestOverviewPanel } from './github/pullRequestOverview'; -import { fromReviewUri, ReviewUriParams, asImageDataURI, EMPTY_IMAGE_URI } from './common/uri'; -import { GitFileChangeNode, InMemFileChangeNode } from './view/treeNodes/fileChangeNode'; -import { CommitNode } from './view/treeNodes/commitNode'; -import { PRNode } from './view/treeNodes/pullRequestNode'; -import { PullRequest } from './github/interface'; -import { formatError } from './common/utils'; -import { GitChangeType } from './common/file'; -import { getDiffLineByPosition, getZeroBased } from './common/diffPositionMapping'; -import { DiffChangeType } from './common/diffHunk'; -import { DescriptionNode } from './view/treeNodes/descriptionNode'; -import { writeFile, unlink } from 'fs'; +import * as vscode from 'vscode'; +import { Repository } from './api/api'; +import { GitErrorCodes } from './api/api1'; +import { CommentReply, findActiveHandler, resolveCommentHandler } from './commentHandlerResolver'; +import { commands } from './common/executeCommands'; import Logger from './common/logger'; -import { GitErrorCodes } from './api/api'; -import { IComment } from './common/comment'; -import { GHPRComment, TemporaryComment } from './github/prComment'; +import { FILE_LIST_LAYOUT, HIDE_VIEWED_FILES, PR_SETTINGS_NAMESPACE } from './common/settingKeys'; +import { editQuery } from './common/settingsUtils'; +import { ITelemetry } from './common/telemetry'; +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 { addAttestationCommit } from './github/attestationCommit'; +import { CrossChatSessionWithPR } from './github/copilotApi'; +import { CopilotRemoteAgentManager, SessionIdForPr } from './github/copilotRemoteAgent'; +import { guessExtensionFromMime, pickFilesForUpload, placeholdersForNames, runFileUploads, runPendingUploads } from './github/fileUpload'; import { FolderRepositoryManager } from './github/folderRepositoryManager'; +import { GitHubRepository } from './github/githubRepository'; +import { Issue } from './github/interface'; +import { IssueModel } from './github/issueModel'; +import { IssueOverviewPanel } from './github/issueOverview'; +import { GHPRComment, GHPRCommentThread, TemporaryComment } from './github/prComment'; import { PullRequestModel } from './github/pullRequestModel'; -import { resolveCommentHandler, CommentReply } from './commentHandlerResolver'; -import { ITelemetry } from './common/telemetry'; -import { CredentialStore } from './github/credentials'; +import { PullRequestOverviewPanel } from './github/pullRequestOverview'; +import { chooseItem } from './github/quickPicks'; import { RepositoriesManager } from './github/repositoriesManager'; +import { codespacesPrLink, getIssuesUrl, getPullsUrl, isInCodespaces, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput, vscodeDevPrLink } from './github/utils'; +import { BaseContext, OverviewContext } from './github/views'; +import { checkoutPRInWorktree } from './github/worktree'; +import { IssueChatContextItem } from './lm/issueContextProvider'; +import { PRChatContextItem } from './lm/pullRequestContextProvider'; +import { isNotificationTreeItem, NotificationTreeItem } from './notifications/notificationItem'; +import { NotificationsManager } from './notifications/notificationsManager'; +import { CreatePullRequestDataModel } from './view/createPullRequestDataModel'; 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 { + FileChangeNode, + GitFileChangeNode, + InMemFileChangeNode, + openFileCommand, + 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) { - vscode.window.showErrorMessage('Unable to find current pull request.'); + vscode.window.showErrorMessage(vscode.l10n.t('Unable to find current pull request.')); 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; } } -async function chooseItem(activePullRequests: T[], propertyGetter: (itemValue: T) => string): Promise { - if (activePullRequests.length === 1) { - return activePullRequests[0]; +export async function openDescription( + telemetry: ITelemetry, + issueModel: IssueModel, + descriptionNode: PRNode | RepositoryChangesNode | undefined, + folderManager: FolderRepositoryManager, + revealNode: boolean, + preserveFocus: boolean = true, +) { + 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))?.itemValue; } -export function registerCommands(context: vscode.ExtensionContext, reposManager: RepositoriesManager, reviewManagers: ReviewManager[], telemetry: ITelemetry, credentialStore: CredentialStore, tree: PullRequestsTreeDataProvider) { +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)); + } - context.subscriptions.push(vscode.commands.registerCommand('auth.signout', async () => { - credentialStore.logout(); - })); + /** __GDPR__ + "pr.openInGitHub" : {} + */ + telemetry.sendTelemetryEvent('pr.openInGitHub'); +} - context.subscriptions.push(vscode.commands.registerCommand('pr.openPullRequestInGitHub', async (e: PRNode | DescriptionNode | PullRequestModel) => { - if (!e) { - const activePullRequests: PullRequestModel[] = reposManager.folderManagers.map(folderManager => folderManager.activePullRequest!).filter(activePR => !!activePR); +export async function closeAllPrAndReviewEditors() { + const tabs = vscode.window.tabGroups; + const editors = tabs.all.map(group => group.tabs).flat(); - if (activePullRequests.length >= 1) { - const result = await chooseItem(activePullRequests, (itemValue) => itemValue.html_url); - if (result) { - vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(result.html_url)); - } - } - } else if (e instanceof PRNode || e instanceof DescriptionNode) { - vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(e.pullRequestModel.html_url)); - } else { - vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(e.html_url)); + 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); } + } +} - /* __GDPR__ - "pr.openInGitHub" : {} - */ - telemetry.sendTelemetryEvent('pr.openInGitHub'); - })); +function isCrossChatSessionWithPR(value: any): value is CrossChatSessionWithPR { + const asCrossChatSessionWithPR = value as Partial; + return !!asCrossChatSessionWithPR.pullRequestDetails; +} - 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; +export function registerCommands( + context: vscode.ExtensionContext, + reposManager: RepositoriesManager, + reviewsManager: ReviewsManager, + telemetry: ITelemetry, + copilotRemoteAgentManager: CopilotRemoteAgentManager, + notificationManager: NotificationsManager, + prsTreeModel: PrsTreeModel, + tree: PullRequestsTreeDataProvider +) { + const logId = 'RegisterCommands'; + + PullRequestOverviewPanel.registerGlobalCommands(context, telemetry); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'pr.openPullRequestOnGitHub', + async (e: PRNode | RepositoryChangesNode | PullRequestModel | undefined) => { + if (!e) { + const activePullRequests: PullRequestModel[] = reposManager.folderManagers + .map(folderManager => folderManager.activePullRequest!) + .filter(activePR => !!activePR); + + if (activePullRequests.length >= 1) { + const result = await chooseItem( + activePullRequests, + itemValue => ({ label: itemValue.html_url }), + ); + if (result) { + openPullRequestOnGitHub(result, telemetry); + } + } + } else { + openPullRequestOnGitHub(e, telemetry); + } + }, + ), + ); + context.subscriptions.push( + vscode.commands.registerCommand( + 'notification.openOnGitHub', + async (e: NotificationTreeItem | undefined) => { + if (e) { + openPullRequestOnGitHub(e, telemetry); + } + }, + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'pr.openAllDiffs', + async () => { + const activePullRequestsWithFolderManager = reposManager.folderManagers + .filter(folderManager => folderManager.activePullRequest) + .map(folderManager => { + return (({ activePr: folderManager.activePullRequest!, folderManager })); + }); + + const activePullRequestAndFolderManager = activePullRequestsWithFolderManager.length >= 1 + ? ( + await chooseItem( + activePullRequestsWithFolderManager, + itemValue => ({ label: itemValue.activePr.html_url }), + ) + ) + : activePullRequestsWithFolderManager[0]; + + if (!activePullRequestAndFolderManager) { + return; + } + + const { folderManager } = activePullRequestAndFolderManager; + const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager); + + if (!reviewManager) { + return; + } + + reviewManager.reviewModel.localFileChanges + .forEach(localFileChange => localFileChange.openDiff(folderManager, { preview: false })); } + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'pr.addAttestationCommit', + async (target?: PullRequestModel | PRNode | RepositoryChangesNode) => { + let pr: PullRequestModel | undefined; + let folderManager: FolderRepositoryManager | undefined; + + if (target instanceof PullRequestModel) { + pr = target; + } else if (target && (target as PRNode).pullRequestModel) { + pr = (target as PRNode).pullRequestModel; + } else if (target && (target as RepositoryChangesNode).pullRequestModel) { + pr = (target as RepositoryChangesNode).pullRequestModel; + } + + if (pr) { + folderManager = reposManager.getManagerForIssueModel(pr) ?? reposManager.folderManagers.find(m => m.activePullRequest?.equals(pr!)); + } - const { indexChanges, workingTreeChanges } = folderManager.repository.state; + if (!pr || !folderManager) { + const activePullRequestsWithFolderManager = reposManager.folderManagers + .filter(m => m.activePullRequest) + .map(m => ({ activePr: m.activePullRequest!, folderManager: m })); - if (!indexChanges.length) { - if (workingTreeChanges.length) { - const stageAll = await vscode.window.showWarningMessage('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 { + if (activePullRequestsWithFolderManager.length === 0) { + vscode.window.showErrorMessage(vscode.l10n.t('No active pull request to add an attestation commit to.')); return; } - } else { - vscode.window.showInformationMessage('There are no changes to suggest.'); + + const picked = activePullRequestsWithFolderManager.length === 1 + ? activePullRequestsWithFolderManager[0] + : await chooseItem(activePullRequestsWithFolderManager, item => ({ label: item.activePr.html_url })); + + if (!picked) { + return; + } + pr = picked.activePr; + folderManager = picked.folderManager; + } + + await addAttestationCommit(folderManager, pr); + }, + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.openFileOnGitHub', async (e: GitFileChangeNode | RemoteFileChangeNode) => { + if (e instanceof RemoteFileChangeNode) { + const choice = await vscode.window.showInformationMessage( + vscode.l10n.t('{0} can\'t be opened locally. Do you want to open it on GitHub?', e.changeModel.fileName), + vscode.l10n.t('Open'), + ); + if (!choice) { return; } } + if (e.changeModel.blobUrl) { + return vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(e.changeModel.blobUrl)); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.copyCommitHash', (e: CommitNode) => { + vscode.env.clipboard.writeText(e.sha); + }), + ); + + 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 asTempStorageURI(e.changeModel.parentFilePath, folderManager.repository); + vscode.commands.executeCommand('vscode.open', imageDataURI || e.changeModel.parentFilePath); + } + }), + ); - const diff = await folderManager.repository.diff(true); + context.subscriptions.push( + vscode.commands.registerCommand('pr.openModifiedFile', (e: GitFileChangeNode | undefined) => { + let uri: vscode.Uri | undefined; + const tab = vscode.window.tabGroups.activeTabGroup.activeTab; - let suggestEditMessage = ''; - if (e && e.inputBox && e.inputBox.value) { - suggestEditMessage = `${e.inputBox.value}\n`; - e.inputBox.value = ''; + if (e) { + uri = e.changeModel.filePath; + } else { + if (tab?.input instanceof vscode.TabInputTextDiff) { + uri = tab.input.modified; + } + } + if (uri) { + vscode.commands.executeCommand('vscode.open', uri, tab?.group.viewColumn); } + }), + ); - const suggestEditText = `${suggestEditMessage}\`\`\`diff\n${diff}\n\`\`\``; - await folderManager.activePullRequest.createIssueComment(suggestEditText); + 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]); + } + } + } - // Reset HEAD and then apply reverse diff - await vscode.commands.executeCommand('git.unstageAll'); + context.subscriptions.push( + vscode.commands.registerCommand( + 'pr.openDiffView', + (fileChangeNode: GitFileChangeNode | InMemFileChangeNode | undefined) => { + return openDiffView(fileChangeNode); + }, + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'pr.openDiffViewFromEditor', + (uri: vscode.Uri) => { + return openDiffView(uri); + }, + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.deleteLocalBranch', async (e: PRNode) => { + const folderManager = reposManager.getManagerForIssueModel(e.pullRequestModel); + if (!folderManager) { + return; + } + const pullRequestModel = ensurePR(folderManager, e); + const DELETE_BRANCH_FORCE = 'Delete Unmerged Branch'; + let error = null; + + try { + await folderManager.deleteLocalPullRequest(pullRequestModel); + } catch (e) { + if (e.gitErrorCode === GitErrorCodes.BranchNotFullyMerged) { + const action = await vscode.window.showErrorMessage( + vscode.l10n.t('The local branch \'{0}\' is not fully merged. Are you sure you want to delete it?', pullRequestModel.localBranchName ?? 'unknown branch'), + DELETE_BRANCH_FORCE, + ); + + if (action !== DELETE_BRANCH_FORCE) { + return; + } - const tempFilePath = pathLib.join(folderManager.repository.rootUri.path, '.git', `${folderManager.activePullRequest.number}.diff`); - writeFile(tempFilePath, diff, {}, async (writeError) => { - if (writeError) { - throw writeError; + try { + await folderManager.deleteLocalPullRequest(pullRequestModel, true); + } catch (e) { + error = e; + } + } else { + error = e; } + } - try { - await folderManager.repository.apply(tempFilePath, true); - - unlink(tempFilePath, (err) => { - if (err) { - throw err; - } - }); - } catch (err) { - Logger.appendLine(`Applying patch failed: ${err}`); - vscode.window.showErrorMessage(`Applying patch failed: ${formatError(err)}`); + if (error) { + /* __GDPR__ + "pr.deleteLocalPullRequest.failure" : {} + */ + telemetry.sendTelemetryErrorEvent('pr.deleteLocalPullRequest.failure'); + await vscode.window.showErrorMessage(`Deleting local pull request branch failed: ${error}`); + } else { + /* __GDPR__ + "pr.deleteLocalPullRequest.success" : {} + */ + telemetry.sendTelemetryEvent('pr.deleteLocalPullRequest.success'); + } + }), + ); + + function chooseReviewManager(repoPath?: string) { + if (repoPath) { + const uri = vscode.Uri.file(repoPath).toString(); + for (const mgr of reviewsManager.reviewManagers) { + if (mgr.repository.rootUri.toString() === uri) { + return mgr; } - }); - } catch (err) { - Logger.appendLine(`Applying patch failed: ${err}`); - vscode.window.showErrorMessage(`Applying patch failed: ${formatError(err)}`); + } } - })); + return chooseItem( + 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 }, + ); + } - context.subscriptions.push(vscode.commands.registerCommand('pr.openFileInGitHub', (e: GitFileChangeNode) => { - vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(e.blobUrl!)); - })); + function isSourceControl(x: any): x is Repository { + return !!x?.rootUri; + } - context.subscriptions.push(vscode.commands.registerCommand('pr.copyCommitHash', (e: CommitNode) => { - vscode.env.clipboard.writeText(e.sha); - })); + context.subscriptions.push( + vscode.commands.registerCommand( + 'pr.create', + async (args?: { repoPath: string; compareBranch: string } | Repository) => { + // The arguments this is called with are either from the SCM view, or manually passed. + if (isSourceControl(args)) { + (await chooseReviewManager(args.rootUri.fsPath))?.createPullRequest(); + } else { + (await chooseReviewManager(args?.repoPath))?.createPullRequest(args?.compareBranch); + } + }, + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'pr.pushAndCreate', + 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(); + } + if (create) { + reviewManager.createPullRequest(); + } + } + } + }, + ), + ); + + context.subscriptions.push( + 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.', logId); + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request was selected to checkout, please try again.')); + } - 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.parentFilePath, folderManager.repository); - vscode.commands.executeCommand('vscode.open', imageDataURI || e.parentFilePath); - } - })); + let pullRequestModel: PullRequestModel; + let repository: Repository | undefined; - context.subscriptions.push(vscode.commands.registerCommand('pr.openModifiedFile', (e: GitFileChangeNode) => { - vscode.commands.executeCommand('vscode.open', e.filePath); - })); + if (pr instanceof PRNode || pr instanceof RepositoryChangesNode) { + pullRequestModel = pr.pullRequestModel; + repository = pr.repository; + } else { + pullRequestModel = pr; + } - context.subscriptions.push(vscode.commands.registerCommand('pr.openDiffView', async (fileChangeNode: GitFileChangeNode | InMemFileChangeNode) => { - const folderManager = reposManager.getManagerForIssueModel(fileChangeNode.pullRequest); - if (!folderManager) { - return; - } + // 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 parentFilePath = fileChangeNode.parentFilePath; - const filePath = fileChangeNode.filePath; - const fileName = fileChangeNode.fileName; - const opts = fileChangeNode.opts; + const fromDescriptionPage = pr instanceof PullRequestModel; + return reviewsManager.switchToPr(folderManager, pullRequestModel, repository, fromDescriptionPage); - fileChangeNode.reveal(fileChangeNode, { select: true, focus: true }); + })); - let parentURI = await asImageDataURI(parentFilePath, folderManager.repository) || parentFilePath; - let headURI = await asImageDataURI(filePath, folderManager.repository) || filePath; - if (parentURI.scheme === 'data' || headURI.scheme === 'data') { - if (fileChangeNode.status === GitChangeType.ADD) { - parentURI = EMPTY_IMAGE_URI; - } - if (fileChangeNode.status === GitChangeType.DELETE) { - headURI = EMPTY_IMAGE_URI; - } + const resolvePr = async (context: BaseContext | undefined): Promise<{ folderManager: FolderRepositoryManager, pr: PullRequestModel } | undefined> => { + if (!context) { + return undefined; } - vscode.commands.executeCommand('vscode.diff', parentURI, headURI, fileName, opts); - })); - - context.subscriptions.push(vscode.commands.registerCommand('pr.deleteLocalBranch', async (e: PRNode) => { - const folderManager = reposManager.getManagerForIssueModel(e.pullRequestModel); + const folderManager = reposManager.getManagerForRepository(context.owner, context.repo) ?? reposManager.folderManagers[0]; if (!folderManager) { - return; + return undefined; + } + + const pr = await folderManager.resolvePullRequest(context.owner, context.repo, context.number, true); + if (!pr) { + return undefined; } - const pullRequestModel = ensurePR(folderManager, e); - const DELETE_BRANCH_FORCE = 'delete branch (even if not merged)'; - let error = null; + return { folderManager, pr }; + }; + + const applyPullRequestChanges = async (task: vscode.Progress<{ message?: string; increment?: number; }>, folderManager: FolderRepositoryManager, pullRequest: PullRequestModel): Promise => { + let patch: string | undefined; try { - await folderManager.deleteLocalPullRequest(pullRequestModel); - } catch (e) { - if (e.gitErrorCode === GitErrorCodes.BranchNotFullyMerged) { - const action = await vscode.window.showErrorMessage(`The branch '${pullRequestModel.localBranchName}' is not fully merged, are you sure you want to delete it? `, DELETE_BRANCH_FORCE); + patch = await pullRequest.getPatch(); - if (action !== DELETE_BRANCH_FORCE) { - return; - } + if (!patch.trim()) { + vscode.window.showErrorMessage(vscode.l10n.t('No patch data available for pull request #{0}', pullRequest.number.toString())); + return; + } - try { - await folderManager.deleteLocalPullRequest(pullRequestModel, true); - } catch (e) { - error = e; + 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')); } - } else { - error = e; } } + }; + + /** + * 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; + } - if (error) { - /* __GDPR__ - "pr.deleteLocalPullRequest.failure" : { - "message" : { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth" } - } - */ - telemetry.sendTelemetryErrorEvent('pr.deleteLocalPullRequest.failure', { - message: error - }); - await vscode.window.showErrorMessage(`Deleting local pull request branch failed: ${error}`); - } else { - /* __GDPR__ - "pr.deleteLocalPullRequest.success" : {} - */ - telemetry.sendTelemetryEvent('pr.deleteLocalPullRequest.success'); - // fire and forget - vscode.commands.executeCommand('pr.refreshList'); + /** + * 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 chooseReviewManager() { - return chooseItem(reviewManagers, (itemValue) => pathLib.basename(itemValue.repository.rootUri.fsPath)); + function contextHasPath(ctx: BaseContext | { path: string } | undefined): ctx is { path: string } { + const contextAsPath: Partial<{ path: string }> = (ctx as { path: string }); + return !!contextAsPath.path; } - context.subscriptions.push(vscode.commands.registerCommand('pr.create', async () => { - (await chooseReviewManager())?.createPullRequest(); - })); + 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.createDraft', async () => { - (await chooseReviewManager())?.createPullRequest(true); - })); + type ChatCommandArgs = { path: string } | [{ path: string } | undefined, SessionMetadata | undefined] | undefined; - context.subscriptions.push(vscode.commands.registerCommand('pr.pick', async (pr: PRNode | DescriptionNode | PullRequestModel) => { - let pullRequestModel: PullRequestModel; + function parseChatCommandArgs(ctxOrArgs: ChatCommandArgs, metadataArg?: SessionMetadata): { ctx: { path: string } | undefined; metadata: SessionMetadata | undefined } { + if (Array.isArray(ctxOrArgs)) { + return { ctx: ctxOrArgs[0], metadata: ctxOrArgs[1] }; + } + return { ctx: ctxOrArgs, metadata: metadataArg }; + } - if (pr instanceof PRNode || pr instanceof DescriptionNode) { - pullRequestModel = pr.pullRequestModel; - } else { - pullRequestModel = pr; + async function resolvePrFromChat(ctx: { path: string }, metadata: SessionMetadata | undefined): Promise<{ folderManager: FolderRepositoryManager; pullRequest: PullRequestModel; prNumber: number } | undefined> { + const prNumber = prNumberFromUriPath(ctx.path); + if (!prNumber) { + return undefined; } + const result = getFolderManagerFromMetadata(metadata); + if (!result) { + return undefined; + } + const { folderManager, githubRepo } = result; + const pullRequest = await folderManager.fetchById(githubRepo, prNumber); + if (!pullRequest) { + return undefined; + } + return { folderManager, pullRequest, prNumber }; + } - const fromDescriptionPage = pr instanceof PullRequestModel; - /* __GDPR__ - "pr.checkout" : { - "fromDescriptionPage" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - telemetry.sendTelemetryEvent('pr.checkout', { fromDescription: fromDescriptionPage.toString() }); + context.subscriptions.push(vscode.commands.registerCommand('pr.checkoutFromChat', async (ctxOrArgs: ChatCommandArgs, metadataArg?: SessionMetadata) => { + const { ctx, metadata } = parseChatCommandArgs(ctxOrArgs, metadataArg); + if (!ctx) { + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request context provided for checkout.')); + } - return vscode.window.withProgress({ - location: vscode.ProgressLocation.SourceControl, - title: `Switching to Pull Request #${pullRequestModel.number}`, - }, async (progress, token) => { - await ReviewManager.getReviewManagerForRepository(reviewManagers, pullRequestModel.githubRepository)?.switch(pullRequestModel); - }); + const resolved = await resolvePrFromChat(ctx, metadata); + if (!resolved) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to find pull request from chat context.')); + } + + return reviewsManager.switchToPr(resolved.folderManager, resolved.pullRequest, resolved.folderManager.repository, true); })); - context.subscriptions.push(vscode.commands.registerCommand('pr.merge', async (pr?: PRNode) => { - const folderManager = reposManager.getManagerForIssueModel(pr?.pullRequestModel); - if (!folderManager) { - return; + context.subscriptions.push(vscode.commands.registerCommand('pr.checkoutFromDescription', async (ctx: BaseContext | undefined) => { + if (!ctx) { + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request context provided for checkout.')); } - const pullRequest = ensurePR(folderManager, pr); - return vscode.window.showWarningMessage(`Are you sure you want to merge this pull request on GitHub?`, { modal: true }, 'Yes').then(async value => { - let newPR; - if (value === 'Yes') { - try { - newPR = await folderManager.mergePullRequest(pullRequest); - return newPR; - } catch (e) { - vscode.window.showErrorMessage(`Unable to merge pull request. ${formatError(e)}`); - return newPR; - } - } - }); + 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.readyForReview', async (pr?: PRNode) => { - const folderManager = reposManager.getManagerForIssueModel(pr?.pullRequestModel); - if (!folderManager) { - return; + context.subscriptions.push(vscode.commands.registerCommand('pr.applyChangesFromChat', async (ctxOrArgs: ChatCommandArgs, metadataArg?: SessionMetadata) => { + const { ctx, metadata } = parseChatCommandArgs(ctxOrArgs, metadataArg); + if (!ctx) { + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request context provided for applying changes.')); } - const pullRequest = ensurePR(folderManager, pr); - return vscode.window.showWarningMessage(`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; - } - } - }); + const resolved = await resolvePrFromChat(ctx, metadata); + if (!resolved) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to find pull request from chat context.')); + } + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t('Applying changes from pull request #{0}', resolved.prNumber.toString()), + cancellable: false + }, + async (task) => { + task.report({ increment: 30 }); + return applyPullRequestChanges(task, resolved.folderManager, resolved.pullRequest); + }); })); - context.subscriptions.push(vscode.commands.registerCommand('pr.close', async (pr?: PRNode | PullRequestModel, message?: string) => { - const pullRequestModel = pr instanceof PullRequestModel ? pr : pr?.pullRequestModel; - const folderManager = reposManager.getManagerForIssueModel(pullRequestModel); - if (!folderManager) { - return; + context.subscriptions.push(vscode.commands.registerCommand('pr.applyChangesFromDescription', async (ctx: BaseContext | undefined) => { + if (!ctx) { + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request context provided for applying changes.')); } - const pullRequest = ensurePR(folderManager, pr); - return vscode.window.showWarningMessage(`Are you sure you want to close this pull request on GitHub? This will close the pull request without merging.`, { modal: true }, 'Yes', '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(); + 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); } - - _onDidUpdatePR.fire(); - }); + ); })); - context.subscriptions.push(vscode.commands.registerCommand('pr.openDescription', async (argument: DescriptionNode | PullRequestModel) => { - const pullRequestModel = argument instanceof DescriptionNode ? argument.pullRequestModel : argument; - const folderManager = reposManager.getManagerForIssueModel(pullRequestModel); - if (!folderManager) { + 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 BaseContext); + 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; } - let descriptionNode: DescriptionNode; - if (!(argument instanceof DescriptionNode)) { - // the command is triggerred from command palette or status bar, which means we are already in checkout mode. - const rootNodes = await ReviewManager.getReviewManagerForFolderManager(reviewManagers, folderManager)!.changesInPrDataProvider.getChildren(); - descriptionNode = rootNodes[0] as DescriptionNode; - } else { - descriptionNode = argument; + 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 pullRequest = ensurePR(folderManager, pullRequestModel); - descriptionNode.reveal(descriptionNode, { select: true, focus: true }); - // Create and show a new webview - PullRequestOverviewPanel.createOrShow(context.extensionPath, folderManager, pullRequest, descriptionNode); - - /* __GDPR__ - "pr.openDescription" : {} - */ - telemetry.sendTelemetryEvent('pr.openDescription'); })); - context.subscriptions.push(vscode.commands.registerCommand('pr.refreshDescription', async () => { - if (PullRequestOverviewPanel.currentPanel) { - PullRequestOverviewPanel.refresh(); + 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.')); } - })); - context.subscriptions.push(vscode.commands.registerCommand('pr.openDescriptionToTheSide', async (descriptionNode: DescriptionNode) => { - const folderManager = reposManager.getManagerForIssueModel(descriptionNode.pullRequestModel); - if (!folderManager) { - return; + let pullRequestModel: PullRequestModel; + + if (pr instanceof PRNode || pr instanceof RepositoryChangesNode) { + pullRequestModel = pr.pullRequestModel; + } else { + pullRequestModel = pr; } - const pr = descriptionNode.pullRequestModel; - const pullRequest = ensurePR(folderManager, pr); - descriptionNode.reveal(descriptionNode, { select: true, focus: true }); - // Create and show a new webview - PullRequestOverviewPanel.createOrShow(context.extensionPath, folderManager, pullRequest, descriptionNode, true); - /* __GDPR__ - "pr.openDescriptionToTheSide" : {} - */ - telemetry.sendTelemetryEvent('pr.openDescriptionToTheSide'); - })); + if (requiresHead && !pullRequestModel.head) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to checkout pull request: missing head branch information.')); + } - context.subscriptions.push(vscode.commands.registerCommand('pr.viewChanges', async (fileChange: GitFileChangeNode) => { - if (fileChange.status === GitChangeType.DELETE || fileChange.status === GitChangeType.ADD) { - // create an empty `review` uri without any path/commit info. - const emptyFileUri = fileChange.parentFilePath.with({ - query: JSON.stringify({ - path: null, - commit: null, - }) - }); + 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.pickOnCodespaces', async (pr: PRNode | RepositoryChangesNode | PullRequestModel) => + pickPullRequest(pr, codespacesPrLink, true) + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.pickInWorktree', async (pr: PRNode | PullRequestModel | unknown) => { + if (pr === undefined) { + Logger.error('Unexpectedly received undefined when picking a PR for worktree checkout.', logId); + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request was selected to checkout, please try again.')); + } - return fileChange.status === GitChangeType.DELETE - ? vscode.commands.executeCommand('vscode.diff', fileChange.parentFilePath, emptyFileUri, `${fileChange.fileName}`, { preserveFocus: true }) - : vscode.commands.executeCommand('vscode.diff', emptyFileUri, fileChange.parentFilePath, `${fileChange.fileName}`, { preserveFocus: true }); - } - - // Show the file change in a diff view. - const { path, ref, commit, rootPath } = fromReviewUri(fileChange.filePath); - const previousCommit = `${commit}^`; - const query: ReviewUriParams = { - path: path, - ref: ref, - commit: previousCommit, - base: true, - isOutdated: true, - rootPath - }; - const previousFileUri = fileChange.filePath.with({ query: JSON.stringify(query) }); - - const options: vscode.TextDocumentShowOptions = { - preserveFocus: true - }; - - if (fileChange.comments && fileChange.comments.length) { - const sortedOutdatedComments = fileChange.comments.filter(comment => comment.position === undefined).sort((a, b) => { - return a.originalPosition! - b.originalPosition!; - }); + let pullRequestModel: PullRequestModel; + let repository: Repository | undefined; - if (sortedOutdatedComments.length) { - const diffLine = getDiffLineByPosition(fileChange.diffHunks, sortedOutdatedComments[0].originalPosition!); + if (pr instanceof PRNode) { + pullRequestModel = pr.pullRequestModel; + repository = pr.repository; + } else if (pr instanceof PullRequestModel) { + pullRequestModel = pr; + } else { + Logger.error('Unexpectedly received unknown type when picking a PR for worktree checkout.', logId); + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request was selected to checkout, please try again.')); + } - if (diffLine) { - const lineNumber = Math.max(getZeroBased(diffLine.type === DiffChangeType.Delete ? diffLine.oldLineNumber : diffLine.newLineNumber), 0); - options.selection = new vscode.Range(lineNumber, 0, lineNumber, 0); - } + // 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.')); } - } - return vscode.commands.executeCommand('vscode.diff', previousFileUri, fileChange.filePath, `${fileChange.fileName} from ${(commit || '').substr(0, 8)}`, options); + return checkoutPRInWorktree(telemetry, folderManager, pullRequestModel, repository); + }), + ); + + context.subscriptions.push(vscode.commands.registerCommand('pr.pickInWorktreeFromDescription', async (ctx: BaseContext | undefined) => { + if (!ctx) { + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request context provided for checkout.')); + } + const resolved = await resolvePr(ctx); + if (!resolved) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to resolve pull request for checkout.')); + } + return checkoutPRInWorktree(telemetry, resolved.folderManager, resolved.pr, undefined); })); - context.subscriptions.push(vscode.commands.registerCommand('pr.signin', async () => { - await reposManager.authenticate(); + context.subscriptions.push(vscode.commands.registerCommand('pr.checkoutOnVscodeDevFromDescription', async (context: BaseContext | 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.deleteLocalBranchesNRemotes', async () => { - for (const folderManager of reposManager.folderManagers) { - await folderManager.deleteLocalBranchesNRemotes(); + context.subscriptions.push(vscode.commands.registerCommand('pr.checkoutOnCodespacesFromDescription', async (context: BaseContext | 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.signinAndRefreshList', async () => { - if (await reposManager.authenticate()) { - vscode.commands.executeCommand('pr.refreshList'); + 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.configureRemotes', async () => { - const { name, publisher } = require('../package.json') as { name: string, publisher: string }; - const extensionId = `${publisher}.${name}`; + context.subscriptions.push( + vscode.commands.registerCommand('pr.exit', async (pr: PRNode | RepositoryChangesNode | PullRequestModel | undefined) => { + let pullRequestModel: PullRequestModel | undefined; + + 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 => ({ label: `${itemValue.number}: ${itemValue.title}` }), + { placeHolder: vscode.l10n.t('Choose the pull request to exit') }); + } else { + pullRequestModel = pr; + } - return vscode.commands.executeCommand('workbench.action.openSettings', `@ext:${extensionId} remotes`); - })); + if (!pullRequestModel) { + return; + } - context.subscriptions.push(vscode.commands.registerCommand('pr.startReview', async (reply: CommentReply) => { - /* __GDPR__ - "pr.startReview" : {} + const fromDescriptionPage = pr instanceof PullRequestModel; + /* __GDPR__ + "pr.exit" : { + "fromDescriptionPage" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } */ - telemetry.sendTelemetryEvent('pr.startReview'); - const handler = resolveCommentHandler(reply.thread); + telemetry.sendTelemetryEvent('pr.exit', { fromDescription: fromDescriptionPage.toString() }); + + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.SourceControl, + title: vscode.l10n.t('Exiting Pull Request'), + }, + async () => { + const branch = await pullRequestModel!.githubRepository.getDefaultBranch(); + const manager = reposManager.getManagerForIssueModel(pullRequestModel); + if (manager) { + const prBranch = manager.repository.state.HEAD?.name; + await manager.checkoutDefaultBranch(branch, pullRequestModel); + if (prBranch) { + await manager.cleanupAfterPullRequest(prBranch, pullRequestModel!); + } + } + }, + ); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.merge', async (pr?: PRNode) => { + const folderManager = reposManager.getManagerForIssueModel(pr?.pullRequestModel); + if (!folderManager) { + return; + } + const pullRequest = ensurePR(folderManager, pr); + // TODO check is codespaces - if (handler) { - handler.startReview(reply.thread, reply.text); + const isCrossRepository = + pullRequest.base && + pullRequest.head && + !pullRequest.base.repositoryCloneUrl.equals(pullRequest.head.repositoryCloneUrl); + + const showMergeOnGitHub = isCrossRepository && isInCodespaces(); + if (showMergeOnGitHub) { + return openPullRequestOnGitHub(pullRequest, telemetry); + } + + const yes = vscode.l10n.t('Yes'); + return vscode.window + .showWarningMessage( + vscode.l10n.t('Are you sure you want to merge this pull request on GitHub?'), + { modal: true }, + yes, + ) + .then(async value => { + let newPR; + if (value === yes) { + try { + newPR = await pullRequest.merge(folderManager.repository); + return newPR; + } catch (e) { + vscode.window.showErrorMessage(`Unable to merge pull request. ${formatError(e)}`); + return newPR; + } + } + }); + }), + ); + + context.subscriptions.push( + 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); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.markAllCopilotNotificationsAsRead', node => { + if (node instanceof CategoryTreeNode && node.isCopilot && node.repo) { + prsTreeModel.clearAllCopilotNotifications(node.repo.owner, node.repo.repositoryName); + } + }), + ); + + 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; + } } - })); - context.subscriptions.push(vscode.commands.registerCommand('pr.finishReview', async (reply: CommentReply) => { - /* __GDPR__ - "pr.finishReview" : {} - */ - telemetry.sendTelemetryEvent('pr.finishReview'); - const handler = resolveCommentHandler(reply.thread); + if (!issueModel) { + Logger.appendLine('No pull request found.', logId); + return; + } - if (handler) { - await handler.finishReview(reply.thread, reply.text); + 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; + } + + descriptionNode = reviewManager.changesInPrDataProvider.getDescriptionNode(folderManager); } - })); - context.subscriptions.push(vscode.commands.registerCommand('pr.deleteReview', async (reply: CommentReply) => { - /* __GDPR__ - "pr.deleteReview" : {} + const revealDescription = !(argument instanceof PRNode); + + await openDescription(telemetry, issueModel, descriptionNode, folderManager, revealDescription, !(argument instanceof RepositoryChangesNode)); + } + + 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; + } + + 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())); + } + + 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( + vscode.commands.registerCommand('pr.refreshDescription', async () => { + const panel = PullRequestOverviewPanel.getActivePanel(); + if (panel) { + panel.refreshPanel(); + } + }), + ); + + context.subscriptions.push(vscode.commands.registerCommand('pr.focusDescriptionInput', + async () => { + const panel = PullRequestOverviewPanel.getActivePanel(); + if (panel) { + panel.scrollToPendingReview(); + } + } + )); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.openDescriptionToTheSide', async (descriptionNode: RepositoryChangesNode) => { + const folderManager = reposManager.getManagerForIssueModel(descriptionNode.pullRequestModel); + if (!folderManager) { + return; + } + 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(telemetry, context.extensionUri, folderManager, identity, pullRequest, true); + + /* __GDPR__ + "pr.openDescriptionToTheSide" : {} + */ + telemetry.sendTelemetryEvent('pr.openDescriptionToTheSide'); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.showDiffSinceLastReview', async (descriptionNode: RepositoryChangesNode) => { + descriptionNode.pullRequestModel.showChangesSinceReview = true; + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.showDiffAll', async (descriptionNode: RepositoryChangesNode) => { + descriptionNode.pullRequestModel.showChangesSinceReview = false; + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.signin', async () => { + await reposManager.authenticate(); + }), + ); + + 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); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.deleteLocalBranchesNRemotes', async () => { + for (const folderManager of reposManager.folderManagers) { + await folderManager.deleteLocalBranchesNRemotes(); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.signinAndRefreshList', async () => { + if (await reposManager.authenticate()) { + vscode.commands.executeCommand('pr.refreshList'); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.configureRemotes', async () => { + return vscode.commands.executeCommand('workbench.action.openSettings', `@ext:${EXTENSION_ID} remotes`); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.startReview', async (reply: CommentReply) => { + /* __GDPR__ + "pr.startReview" : {} */ - telemetry.sendTelemetryEvent('pr.deleteReview'); - const shouldDelete = await vscode.window.showWarningMessage('Delete this review and all associated comments?', { modal: true }, 'Delete'); - if (shouldDelete) { + telemetry.sendTelemetryEvent('pr.startReview'); const handler = resolveCommentHandler(reply.thread); if (handler) { - await handler.deleteReview(); + handler.startReview(reply.thread, reply.text); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.openReview', async (thread: GHPRCommentThread) => { + /* __GDPR__ + "pr.openReview" : {} + */ + telemetry.sendTelemetryEvent('pr.openReview'); + const handler = resolveCommentHandler(thread); + + if (handler) { + await handler.openReview(thread); } + }), + ); + + function threadAndText(commentLike: CommentReply | GHPRCommentThread | GHPRComment | any): { thread: GHPRCommentThread, text: string } { + let thread: GHPRCommentThread; + let text: string = ''; + if (commentLike instanceof GHPRComment) { + thread = commentLike.parent; + } else if (CommentReply.is(commentLike)) { + thread = commentLike.thread; + } else if (GHPRCommentThread.is(commentLike?.thread)) { + thread = commentLike.thread; + } else { + thread = commentLike; + } + return { thread, text }; + } + + const resolve = async (commentLike: CommentReply | GHPRCommentThread | GHPRComment | undefined, resolve: boolean, focusReply?: boolean) => { + if (resolve) { + /* __GDPR__ + "pr.resolveReviewThread" : {} + */ + telemetry.sendTelemetryEvent('pr.resolveReviewThread'); + } else { + /* __GDPR__ + "pr.unresolveReviewThread" : {} + */ + telemetry.sendTelemetryEvent('pr.unresolveReviewThread'); + } - if (!reply.thread.comments.length) { - reply.thread.dispose(); + 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; } - })); - context.subscriptions.push(vscode.commands.registerCommand('pr.createComment', async (reply: CommentReply) => { - /* __GDPR__ - "pr.createComment" : {} - */ - telemetry.sendTelemetryEvent('pr.createComment'); - const handler = resolveCommentHandler(reply.thread); + const { thread, text } = threadAndText(commentLike); + + const handler = resolveCommentHandler(thread); if (handler) { - handler.createOrReplyComment(reply.thread, reply.text); + 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.editComment', async (comment: GHPRComment | TemporaryComment) => { - /* __GDPR__ + 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, + ); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.createComment', async (reply: CommentReply) => { + /* __GDPR__ + "pr.createComment" : {} + */ + telemetry.sendTelemetryEvent('pr.createComment'); + const handler = resolveCommentHandler(reply.thread); + + if (handler) { + handler.createOrReplyComment(reply.thread, reply.text, false); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.createSingleComment', async (reply: CommentReply) => { + /* __GDPR__ + "pr.createSingleComment" : {} + */ + telemetry.sendTelemetryEvent('pr.createSingleComment'); + const handler = resolveCommentHandler(reply.thread); + + if (handler) { + handler.createOrReplyComment(reply.thread, reply.text, true); + } + }), + ); + + context.subscriptions.push( + 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) && (visible.document.uri.query === '')); + if (!commentEditor) { + 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(position, ` +\`\`\`suggestion +${contents} +\`\`\``); + }); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.uploadFile', async (reply: CommentReply | GHPRComment | undefined) => { + /* __GDPR__ + "pr.uploadFile" : {} + */ + telemetry.sendTelemetryEvent('pr.uploadFile'); + + 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) { + return; + } + const thread = potentialThread; + + const commentEditor = vscode.window.activeTextEditor?.document.uri.scheme === Schemes.Comment ? vscode.window.activeTextEditor + : vscode.window.visibleTextEditors.find(visible => (visible.document.uri.scheme === Schemes.Comment) && (visible.document.uri.query === '')); + if (!commentEditor) { + Logger.error('No comment editor visible for uploading a file.', logId); + vscode.window.showErrorMessage(vscode.l10n.t('No available comment editor to upload a file in.')); + return; + } + const commentEditorUri = commentEditor.document.uri.toString(); + + const folderManager = reposManager.getManagerForFile(thread.uri); + const githubRepository = folderManager?.activePullRequest?.githubRepository + ?? folderManager?.gitHubRepositories[0]; + if (!githubRepository) { + vscode.window.showErrorMessage(vscode.l10n.t('Cannot upload files: no GitHub repository found for this comment.')); + return; + } + + const uploads = await pickFilesForUpload(); + if (!uploads) { + return; + } + + // Insert placeholders at the current cursor position + const placeholdersText = uploads.map(u => u.placeholder).join('\n'); + const cursor = commentEditor.selection.end; + const before = commentEditor.document.getText(new vscode.Range(new vscode.Position(0, 0), cursor)); + const separator = before.length > 0 && !before.endsWith('\n') ? '\n' : ''; + await commentEditor.edit(editBuilder => { + editBuilder.insert(cursor, `${separator}${placeholdersText}\n`); + }); + + const replacePlaceholder = async (placeholder: string, replacement: string) => { + const editor = vscode.window.visibleTextEditors.find(e => e.document.uri.toString() === commentEditorUri); + if (!editor) { + return; + } + const text = editor.document.getText(); + const idx = text.indexOf(placeholder); + if (idx < 0) { + return; + } + const start = editor.document.positionAt(idx); + const end = editor.document.positionAt(idx + placeholder.length); + await editor.edit(editBuilder => { + editBuilder.replace(new vscode.Range(start, end), replacement); + }); + }; + + runFileUploads( + githubRepository, + uploads, + logId, + (placeholder, _name, markdown) => replacePlaceholder(placeholder, markdown), + (placeholder, name, error) => { + vscode.window.showErrorMessage(vscode.l10n.t('Failed to upload {0}: {1}', name, error)); + return replacePlaceholder(placeholder, ''); + }, + ); + }) + ); + + context.subscriptions.push( + vscode.languages.registerDocumentPasteEditProvider( + { scheme: Schemes.Comment }, + { + async provideDocumentPasteEdits(document, ranges, dataTransfer, _context, token) { + const files: { name: string; getBytes: () => Thenable }[] = []; + let counter = 0; + for (const [mime, item] of dataTransfer) { + const file = item.asFile(); + if (!file) { + continue; + } + const name = file.name || `pasted-file-${++counter}${guessExtensionFromMime(mime)}`; + files.push({ name, getBytes: () => file.data() }); + } + if (files.length === 0 || token.isCancellationRequested) { + return; + } + + const potentialThread = findActiveHandler()?.commentController.activeCommentThread as vscode.CommentThread2 as GHPRCommentThread | undefined; + if (!potentialThread) { + return; + } + const folderManager = reposManager.getManagerForFile(potentialThread.uri); + const githubRepository = folderManager?.activePullRequest?.githubRepository + ?? folderManager?.gitHubRepositories[0]; + if (!githubRepository) { + return; + } + + const placeholders = placeholdersForNames(files.map(f => f.name)); + const placeholdersText = placeholders.map(p => p.placeholder).join('\n'); + + const documentUri = document.uri.toString(); + const replacePlaceholder = async (placeholder: string, replacement: string) => { + const editor = vscode.window.visibleTextEditors.find(e => e.document.uri.toString() === documentUri); + if (!editor) { + return; + } + const text = editor.document.getText(); + const idx = text.indexOf(placeholder); + if (idx < 0) { + return; + } + const start = editor.document.positionAt(idx); + const end = editor.document.positionAt(idx + placeholder.length); + await editor.edit(editBuilder => { + editBuilder.replace(new vscode.Range(start, end), replacement); + }); + }; + + runPendingUploads( + githubRepository, + files.map((f, i) => ({ + name: placeholders[i].name, + placeholder: placeholders[i].placeholder, + getBytes: f.getBytes, + })), + logId, + (placeholder, _name, markdown) => replacePlaceholder(placeholder, markdown), + (placeholder, name, error) => { + vscode.window.showErrorMessage(vscode.l10n.t('Failed to upload {0}: {1}', name, error)); + return replacePlaceholder(placeholder, ''); + }, + ); + + const edit = new vscode.DocumentPasteEdit( + placeholdersText, + vscode.l10n.t('Upload as GitHub attachment'), + vscode.DocumentDropOrPasteEditKind.Empty.append('github', 'attachment'), + ); + return [edit]; + }, + }, + { + providedPasteEditKinds: [vscode.DocumentDropOrPasteEditKind.Empty.append('github', 'attachment')], + pasteMimeTypes: ['files', 'image/*'], + }, + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.editComment', async (comment: GHPRComment | TemporaryComment) => { + /* __GDPR__ "pr.editComment" : {} */ - telemetry.sendTelemetryEvent('pr.editComment'); - comment.startEdit(); - })); + telemetry.sendTelemetryEvent('pr.editComment'); + comment.startEdit(); + }), + ); - context.subscriptions.push(vscode.commands.registerCommand('pr.cancelEditComment', async (comment: GHPRComment | TemporaryComment) => { - /* __GDPR__ + context.subscriptions.push( + vscode.commands.registerCommand('pr.editQuery', (query: CategoryTreeNode) => { + /* __GDPR__ + "pr.editQuery" : {} + */ + telemetry.sendTelemetryEvent('pr.editQuery'); + if (query.label === undefined) { + return; + } + return editQuery(PR_SETTINGS_NAMESPACE, query.label); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.cancelEditComment', async (comment: GHPRComment | TemporaryComment) => { + /* __GDPR__ "pr.cancelEditComment" : {} */ - telemetry.sendTelemetryEvent('pr.cancelEditComment'); - comment.cancelEdit(); - })); + telemetry.sendTelemetryEvent('pr.cancelEditComment'); + comment.cancelEdit(); + }), + ); - context.subscriptions.push(vscode.commands.registerCommand('pr.saveComment', async (comment: GHPRComment | TemporaryComment) => { - /* __GDPR__ + context.subscriptions.push( + vscode.commands.registerCommand('pr.saveComment', async (comment: GHPRComment | TemporaryComment) => { + /* __GDPR__ "pr.saveComment" : {} */ - telemetry.sendTelemetryEvent('pr.saveComment'); - const handler = resolveCommentHandler(comment.parent); + telemetry.sendTelemetryEvent('pr.saveComment'); + const handler = resolveCommentHandler(comment.parent); - if (handler) { - await handler.editComment(comment.parent, comment); - } - })); + if (handler) { + await handler.editComment(comment.parent, comment); + } + }), + ); - context.subscriptions.push(vscode.commands.registerCommand('pr.deleteComment', async (comment: GHPRComment | TemporaryComment) => { - /* __GDPR__ + context.subscriptions.push( + vscode.commands.registerCommand('pr.deleteComment', async (comment: GHPRComment | TemporaryComment) => { + /* __GDPR__ "pr.deleteComment" : {} */ - telemetry.sendTelemetryEvent('pr.deleteComment'); + telemetry.sendTelemetryEvent('pr.deleteComment'); + const deleteOption = vscode.l10n.t('Delete'); + const shouldDelete = await vscode.window.showWarningMessage(vscode.l10n.t('Are you sure you want to delete this comment?'), { modal: true }, deleteOption); - const shouldDelete = await vscode.window.showWarningMessage('Delete comment?', { modal: true }, 'Delete'); + if (shouldDelete === deleteOption) { + const handler = resolveCommentHandler(comment.parent); - if (shouldDelete === 'Delete') { - const handler = resolveCommentHandler(comment.parent); - - if (handler) { - await handler.deleteComment(comment.parent, comment); + if (handler) { + await handler.deleteComment(comment.parent, comment); + } + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('review.openFile', (value: GitFileChangeNode | vscode.Uri) => { + const command = value instanceof GitFileChangeNode ? value.openFileCommand() : openFileCommand(value); + vscode.commands.executeCommand(command.command, ...(command.arguments ?? [])); + }), + ); + + context.subscriptions.push( + 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.registerCommand('review.openFile', (value: GitFileChangeNode | vscode.Uri) => { - const uri = value instanceof GitFileChangeNode ? value.filePath : value; + 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 }); + } - if (value instanceof GitFileChangeNode) { - value.reveal(value, { select: true, focus: 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 activeTextEditor = vscode.window.activeTextEditor; - const opts: vscode.TextDocumentShowOptions = { - preserveFocus: true, - viewColumn: vscode.ViewColumn.Active - }; + 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)); + }); - // Check if active text editor has same path as other editor. we cannot compare via - // URI.toString() here because the schemas can be different. Instead we just go by path. - if (activeTextEditor && activeTextEditor.document.uri.path === uri.path) { - opts.selection = activeTextEditor.selection; + 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); - vscode.commands.executeCommand('vscode.open', uri, opts); })); - context.subscriptions.push(vscode.commands.registerCommand('pr.openChangedFile', (value: GitFileChangeNode) => { - const openDiff = vscode.workspace.getConfiguration().get('git.openDiffOnClick'); - if (openDiff) { - return vscode.commands.executeCommand('pr.openDiffView', value); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.refreshChanges', _ => { + 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(PR_SETTINGS_NAMESPACE).update(FILE_LIST_LAYOUT, 'tree', true); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.setFileListLayoutAsFlat', _ => { + 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); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.refreshPullRequest', (prNode: PRNode) => { + const folderManager = reposManager.getManagerForIssueModel(prNode.pullRequestModel); + if (folderManager && prNode.pullRequestModel.equals(folderManager?.activePullRequest)) { + ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager)?.updateComments(); + } + + PullRequestOverviewPanel.refresh( + prNode.pullRequestModel.remote.owner, + prNode.pullRequestModel.remote.repositoryName, + prNode.pullRequestModel.number, + ); + }), + ); + + 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 vscode.commands.executeCommand('review.openFile', value); + return manager?.activePullRequest; } - })); + }; + + context.subscriptions.push( + vscode.commands.registerCommand('pr.markFileAsViewed', async (treeNode: FileChangeNode | vscode.Uri | undefined) => { + try { + if (treeNode === undefined) { + // Use the active editor to enable keybindings + treeNode = vscode.window.activeTextEditor?.document.uri; + } + + if (treeNode instanceof FileChangeNode) { + 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. + const tab = vscode.window.tabGroups.activeTabGroup.activeTab; + if (tab) { + let compareUri: vscode.Uri | undefined = undefined; + if (tab.input instanceof vscode.TabInputTextDiff) { + compareUri = tab.input.modified; + } else if (tab.input instanceof vscode.TabInputText) { + compareUri = tab.input.uri; + } + if (compareUri && treeNode.toString() === compareUri.toString()) { + vscode.window.tabGroups.close(tab); + } + } + + const manager = reposManager.getManagerForFile(treeNode); + const pullRequest = findPrFromUri(manager, treeNode); + await pullRequest?.markFiles([treeNode.path], true, 'viewed'); + manager?.setFileViewedContext(); + } + } catch (e) { + vscode.window.showErrorMessage(`Marked file as viewed failed: ${e}`); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.unmarkFileAsViewed', async (treeNode: FileChangeNode | vscode.Uri | undefined) => { + try { + if (treeNode === undefined) { + // Use the active editor to enable keybindings + treeNode = vscode.window.activeTextEditor?.document.uri; + } + + if (treeNode instanceof FileChangeNode) { + treeNode.unmarkFileAsViewed(false); + } else if (treeNode) { + const manager = reposManager.getManagerForFile(treeNode); + const pullRequest = findPrFromUri(manager, treeNode); + await pullRequest?.markFiles([treeNode.path], true, 'unviewed'); + manager?.setFileViewedContext(); + } + } catch (e) { + vscode.window.showErrorMessage(`Marked file as not viewed failed: ${e}`); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.resetViewedFiles', async () => { + try { + return reposManager.folderManagers.map(async (manager) => { + await manager.activePullRequest?.unmarkAllFilesAsViewed(); + manager.setFileViewedContext(); + }); + } catch (e) { + vscode.window.showErrorMessage(`Marked file as not viewed failed: ${e}`); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.collapseAllComments', () => { + return vscode.commands.executeCommand('workbench.action.collapseAllComments'); + })); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.copyCommentLink', (comment) => { + if (comment instanceof GHPRComment) { + return vscode.env.clipboard.writeText(comment.rawComment.htmlUrl); + } + })); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.copyVscodeDevPrLink', async (params: BaseContext | 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: BaseContext | 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 }[] = []; + 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 => ({ 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; + } + + // 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 })); + }); - context.subscriptions.push(vscode.commands.registerCommand('pr.refreshChanges', _ => { - reviewManagers.forEach(reviewManager => { - reviewManager.updateComments(); - PullRequestOverviewPanel.refresh(); - reviewManager.changesInPrDataProvider.refresh(); + 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); + } + } + 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(); + } + })); + + function chooseRepoToOpen() { + const githubRepositories: GitHubRepository[] = []; + reposManager.folderManagers.forEach(manager => { + githubRepositories.push(...(manager.gitHubRepositories)); }); - })); + return chooseItem( + githubRepositories, + itemValue => ({ label: `${itemValue.remote.owner}/${itemValue.remote.repositoryName}` }), + { placeHolder: vscode.l10n.t('Which GitHub repository do you want to open?') } + ); + } + context.subscriptions.push( + vscode.commands.registerCommand('pr.openPullsWebsite', async () => { + const githubRepo = await chooseRepoToOpen(); + if (githubRepo) { + vscode.env.openExternal(getPullsUrl(githubRepo)); + } + })); + + context.subscriptions.push( + vscode.commands.registerCommand('issues.openIssuesWebsite', async () => { + const githubRepo = await chooseRepoToOpen(); + if (githubRepo) { + vscode.env.openExternal(getIssuesUrl(githubRepo)); + } + })); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.applySuggestion', async (comment: GHPRComment) => { + /* __GDPR__ + "pr.applySuggestion" : {} + */ + telemetry.sendTelemetryEvent('pr.applySuggestion'); + + const handler = resolveCommentHandler(comment.parent); + + if (handler instanceof ReviewCommentController) { + 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 firstComment = isThread ? comment.comments[0] : comment; + commentThread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed; + let commentBody: string; + let filePath: string | undefined; + if (firstComment instanceof GHPRComment) { + commentBody = firstComment.rawComment.body; + filePath = firstComment.rawComment.path; + } else { + commentBody = firstComment.body instanceof vscode.MarkdownString ? firstComment.body.value : firstComment.body; + filePath = undefined; + } + const range = commentThread.range; + let message: string; + if (filePath && range) { + const startLine = range.start.line + 1; + const endLine = range.end.line + 1; + const lineRef = startLine === endLine + ? vscode.l10n.t('line {0}', startLine) + : vscode.l10n.t('lines {0}-{1}', startLine, endLine); + message = vscode.l10n.t('There is a code review comment for file {0} at {1}:\n{2}', filePath, lineRef, commentBody); + } else if (filePath) { + message = vscode.l10n.t('There is a code review comment for file {0}:\n{1}', filePath, commentBody); + } else { + message = 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)`); + })); + + async function goToNextPrevDiff(diffs: vscode.LineChange[], next: boolean) { + 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.')); + } + + 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 editorUri = editor.document.uri; + 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 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); + + // 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 && 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(reviewsManager.reviewManagers, folderManager); + if (!reviewManager) { + return vscode.window.showErrorMessage(vscode.l10n.t('Cannot find active pull request.')); + } + + if (!reviewManager.reviewModel.hasLocalFileChanges || (reviewManager.reviewModel.localFileChanges.length === 0)) { + return vscode.window.showWarningMessage(vscode.l10n.t('Pull request data is not yet complete, please try again in a moment.')); + } - context.subscriptions.push(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(); + for (let i = 0; i < reviewManager.reviewModel.localFileChanges.length; i++) { + 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) { + 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; + } + } } + // No further files in PR. + const goInCircle = next ? vscode.l10n.t('Go to first diff') : vscode.l10n.t('Go to last diff'); + return vscode.window.showInformationMessage(vscode.l10n.t('There are no more diffs in this pull request.'), goInCircle).then(result => { + if (result === goInCircle) { + return reviewManager.reviewModel.localFileChanges[next ? 0 : reviewManager.reviewModel.localFileChanges.length - 1].openDiff(folderManager); + } + }); + } + + context.subscriptions.push( + vscode.commands.registerDiffInformationCommand('pr.goToNextDiffInPr', async (diffs: vscode.LineChange[]) => { + goToNextPrevDiff(diffs, true); + })); + context.subscriptions.push( + vscode.commands.registerDiffInformationCommand('pr.goToPreviousDiffInPr', async (diffs: vscode.LineChange[]) => { + goToNextPrevDiff(diffs, false); + })); - PullRequestOverviewPanel.refresh(); - tree.refresh(prNode); + 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); + } + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.generateTitleAndDescription', async (args: { rootUri: vscode.Uri; baseBranch: string; compareBranch: string }) => { + if (!args?.rootUri || !args?.baseBranch || !args?.compareBranch) { + Logger.error('Missing required arguments for pr.generateTitleAndDescription', logId); + return undefined; + } + + const folderManager = reposManager.getManagerForFile(args.rootUri); + if (!folderManager) { + Logger.error('Unable to find a repository for the provided rootUri.', logId); + return undefined; + } + + const origin = await folderManager.getOrigin(); + const defaults = await folderManager.getPullRequestDefaults(); + + const model = new CreatePullRequestDataModel( + folderManager, + defaults.owner, + args.baseBranch, + origin.remote.owner, + args.compareBranch, + origin.remote.repositoryName, + ); + + try { + const { commitMessages, patches } = await model.getCommitsAndPatches(); + const issues = await model.findIssueContext(commitMessages); + const template = await folderManager.getPullRequestTemplateBody(defaults.owner); + + const provider = folderManager.getTitleAndDescriptionProvider(); + if (!provider) { + Logger.error('No title and description provider available.', logId); + return undefined; + } + + const tokenSource = new vscode.CancellationTokenSource(); + const result = await provider.provider.provideTitleAndDescription( + { commitMessages, patches, issues, template, compareBranch: args.compareBranch }, + tokenSource.token, + ); + + /* __GDPR__ + "pr.generatedTitleAndDescription" : { + "providerTitle" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "source" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + telemetry.sendTelemetryEvent('pr.generatedTitleAndDescription', { providerTitle: provider?.title, source: 'command' }); + + tokenSource.dispose(); + + return result ? { title: result.title, description: result.description } : undefined; + } catch (e) { + Logger.error(`Error generating title and description: ${formatError(e)}`, logId); + return undefined; + } finally { + model.dispose(); + } + }), + ); } diff --git a/src/commentHandlerResolver.ts b/src/commentHandlerResolver.ts index 7c30c53fd8..8ce5803c09 100644 --- a/src/commentHandlerResolver.ts +++ b/src/commentHandlerResolver.ts @@ -5,20 +5,23 @@ 'use strict'; import * as vscode from 'vscode'; -import { GHPRCommentThread, GHPRComment, TemporaryComment } from './github/prComment'; +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): Promise; + createOrReplyComment(thread: GHPRCommentThread, input: string, isSingleComment: boolean): Promise; editComment(thread: GHPRCommentThread, comment: GHPRComment | TemporaryComment): Promise; deleteComment(thread: GHPRCommentThread, comment: GHPRComment | TemporaryComment): Promise; startReview(thread: GHPRCommentThread, input: string): Promise; - finishReview(thread: GHPRCommentThread, input: string): Promise; - deleteReview(): Promise; + openReview(thread: GHPRCommentThread): Promise; + + resolveReviewThread(thread: GHPRCommentThread, input?: string): Promise; + unresolveReviewThread(thread: GHPRCommentThread, input?: string): Promise; } export interface CommentReply { @@ -26,10 +29,16 @@ export interface CommentReply { text: string; } -const commentHandlers = new Map(); +export namespace CommentReply { + export function is(commentReply: any): commentReply is CommentReply { + return commentReply && commentReply.thread && (commentReply.text !== undefined); + } +} + +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) { @@ -37,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); } } - - Logger.appendLine(`Unable to find handler for comment thread ${commentThread.threadId}`); + 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; -} \ No newline at end of file +} + +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 c9177810bb..99c385dd4f 100644 --- a/src/common/async.ts +++ b/src/common/async.ts @@ -27,7 +27,7 @@ export function throttle(fn: () => Promise): () => Promise { current = fn(); - const clear = () => current = undefined; + const clear = () => (current = undefined); done(current).then(clear, clear); return current; @@ -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 0436612283..92a81972d9 100644 --- a/src/common/authentication.ts +++ b/src/common/authentication.ts @@ -1,7 +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 * as vscode from 'vscode'; + +export enum GitHubServerType { + None, + GitHubDotCom, + Enterprise +} + +export enum AuthProvider { + github = 'github', + 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?.includes('Resource protected by organization SAML enforcement.'); +} diff --git a/src/common/comment.ts b/src/common/comment.ts index 5155fe8cc5..3f9559d973 100644 --- a/src/common/comment.ts +++ b/src/common/comment.ts @@ -4,18 +4,44 @@ *--------------------------------------------------------------------------------------------*/ 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 interface Reaction { - label: string; - count: number; - icon?: vscode.Uri; - viewerHasReacted: boolean; +export enum DiffSide { + LEFT = 'LEFT', + RIGHT = 'RIGHT', +} + +export enum ViewedState { + DISMISSED = 'DISMISSED', + VIEWED = 'VIEWED', + UNVIEWED = 'UNVIEWED' +} + +export enum SubjectType { + LINE = 'LINE', + FILE = 'FILE' +} + +export interface IReviewThread { + id: string; + prReviewDatabaseId?: number; + isResolved: boolean; + viewerCanResolve: boolean; + viewerCanUnresolve: boolean; + path: string; + diffSide: DiffSide; + startLine: number; + endLine: number; + originalStartLine: number; + originalEndLine: number; + isOutdated: boolean; + comments: IComment[]; + subjectType: SubjectType; } export interface IComment { - absolutePosition?: number; bodyHTML?: string; diffHunks?: DiffHunk[]; canEdit?: boolean; @@ -31,10 +57,27 @@ export interface IComment { originalCommitId?: string; user?: IAccount; body: string; + specialDisplayBodyPostfix?: string; createdAt: string; htmlUrl: string; isDraft?: boolean; inReplyToId?: number; graphNodeId: string; reactions?: Reaction[]; + isResolved?: boolean; + isOutdated?: boolean; + /** + * The GraphQL node ID of the review thread this comment belongs to, if any. + * Populated when the comment is parsed from a review thread. + */ + threadId?: string; } + +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/commentingRanges.ts b/src/common/commentingRanges.ts index eecb08fa1e..f2083ccedf 100644 --- a/src/common/commentingRanges.ts +++ b/src/common/commentingRanges.ts @@ -4,8 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { DiffHunk, DiffChangeType } from './diffHunk'; +import { DiffChangeType, DiffHunk } from './diffHunk'; import { getZeroBased } from './diffPositionMapping'; +import Logger from './logger'; /** * For the base file, the only commentable areas are deleted lines. For the modified file, @@ -13,7 +14,11 @@ import { getZeroBased } from './diffPositionMapping'; * @param diffHunks The diff hunks of the file * @param isBase Whether the commenting ranges are calculated for the base or modified file */ -export function getCommentingRanges(diffHunks: DiffHunk[], isBase: boolean): vscode.Range[] { +export function getCommentingRanges(diffHunks: DiffHunk[], isBase: boolean, logId: string = 'GetCommentingRanges'): vscode.Range[] { + if (diffHunks.length === 0) { + Logger.debug('No commenting ranges: File contains no diffs.', logId); + } + const ranges: vscode.Range[] = []; for (let i = 0; i < diffHunks.length; i++) { @@ -44,15 +49,20 @@ export function getCommentingRanges(diffHunks: DiffHunk[], isBase: boolean): vsc ranges.push(new vscode.Range(startingLine, 0, endingLine, 0)); startingLine = undefined; endingLine = undefined; + } else if (ranges.length === 0) { + Logger.debug('No commenting ranges: Diff is in base and none of the diff hunks could be added.', logId); } } else { if (diffHunk.newLineNumber) { startingLine = getZeroBased(diffHunk.newLineNumber); length = getZeroBased(diffHunk.newLength); ranges.push(new vscode.Range(startingLine, 0, startingLine + length, 0)); + } else { + Logger.debug('No commenting ranges: Diff is not base and newLineNumber is undefined.', logId); } } } + Logger.debug(`Found ${ranges.length} commenting ranges.`, logId); return ranges; -} \ 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 bc141e2ea4..d7b9429fa1 100644 --- a/src/common/diffHunk.ts +++ b/src/common/diffHunk.ts @@ -7,43 +7,42 @@ * 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 { GitChangeType, SlimFileChange, InMemFileChange } from './file'; -import { Repository } from '../api/api'; +import { GitChangeType, InMemFileChange, SlimFileChange } from './file'; import { IRawFileChange } from '../github/interface'; export enum DiffChangeType { Context, Add, Delete, - Control + Control, } 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( public type: DiffChangeType, - public oldLineNumber: number, /* 1 based */ - public newLineNumber: number, /* 1 based */ + public oldLineNumber: number /* 1 based */, + public newLineNumber: number /* 1 based */, public positionInHunk: number, - private _raw: string, - public endwithLineBreak: boolean = true + public readonly raw: string, + public endwithLineBreak: boolean = true, ) { } } export function getDiffChangeType(text: string) { const c = text[0]; switch (c) { - case ' ': return DiffChangeType.Context; - case '+': return DiffChangeType.Add; - case '-': return DiffChangeType.Delete; - default: return DiffChangeType.Control; + case ' ': + return DiffChangeType.Context; + case '+': + return DiffChangeType.Add; + case '-': + return DiffChangeType.Delete; + default: + return DiffChangeType.Control; } } @@ -55,11 +54,11 @@ export class DiffHunk { public oldLength: number, public newLineNumber: number, public newLength: number, - public positionInHunk: number + public positionInHunk: number, ) { } } -export const DIFF_HUNK_HEADER = /@@ \-(\d+)(,(\d+))?( \+(\d+)(,(\d+)?)?)? @@/; +export const DIFF_HUNK_HEADER = /^@@ \-(\d+)(,(\d+))?( \+(\d+)(,(\d+)?)?)? @@/; export function countCarriageReturns(text: string): number { let count = 0; @@ -94,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; @@ -115,11 +114,11 @@ export function* parseDiffHunk(diffHunkPatch: string): IterableIterator { + 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) { const left = originalContent.split(/\r?\n/); const diffHunkReader = parseDiffHunk(patch); let diffHunkIter = diffHunkReader.next(); - const diffHunks = []; + const diffHunks: DiffHunk[] = []; - const right = []; + const right: string[] = []; let lastCommonLine = 0; + let lastDiffLineEndsWithNewline = true; while (!diffHunkIter.done) { - const diffHunk = diffHunkIter.value; + const diffHunk: DiffHunk = diffHunkIter.value; diffHunks.push(diffHunk); const oriStartLine = diffHunk.oldLineNumber; @@ -226,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(''); } } @@ -252,39 +329,43 @@ export function getGitChangeType(status: string): GitChangeType { } } -export async function parseDiff(reviews: IRawFileChange[], repository: Repository, parentCommit: string): Promise<(InMemFileChange | SlimFileChange)[]> { +export async function parseDiff( + reviews: IRawFileChange[], + parentCommit: string, +): Promise<(InMemFileChange | SlimFileChange)[]> { const fileChanges: (InMemFileChange | SlimFileChange)[] = []; for (let i = 0; i < reviews.length; i++) { const review = reviews[i]; const gitChangeType = getGitChangeType(review.status); - if (!review.patch) { - fileChanges.push(new SlimFileChange(parentCommit, review.blob_url, gitChangeType, review.filename, review.previous_filename)); + 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( + new SlimFileChange( + parentCommit, + review.blob_url, + gitChangeType, + review.filename, + review.previous_filename, + ), + ); continue; } - let originalFileExist = false; - - switch (gitChangeType) { - case GitChangeType.DELETE: - case GitChangeType.MODIFY: - try { - await repository.getObjectDetails(parentCommit, review.filename); - originalFileExist = true; - } catch (err) { /* noop */ } - break; - case GitChangeType.RENAME: - try { - await repository.getObjectDetails(parentCommit, review.previous_filename!); - originalFileExist = true; - } catch (err) { /* noop */ } - break; - } - - const diffHunks = parsePatch(review.patch); - const isPartial = !originalFileExist && gitChangeType !== GitChangeType.ADD; - fileChanges.push(new InMemFileChange(parentCommit, gitChangeType, review.filename, review.previous_filename, review.patch, diffHunks, isPartial, review.blob_url)); + const diffHunks = review.patch ? parsePatch(review.patch) : undefined; + fileChanges.push( + new InMemFileChange( + parentCommit, + gitChangeType, + review.filename, + review.previous_filename, + review.patch ?? '', + diffHunks, + review.blob_url + ), + ); } return fileChanges; diff --git a/src/common/diffPositionMapping.ts b/src/common/diffPositionMapping.ts index 3a76eff24d..ddb8657d68 100644 --- a/src/common/diffPositionMapping.ts +++ b/src/common/diffPositionMapping.ts @@ -3,8 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { DiffLine, DiffHunk, parseDiffHunk, DiffChangeType } from './diffHunk'; -import { IComment } from './comment'; +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 @@ -19,50 +18,6 @@ export function getZeroBased(line: number): number { return line - 1; } -/** - * Returns the absolute position of a comment in a file. If the comment is outdated, returns -1. - * - * For the base file, only comments that are on deleted lines should be displayed. For the modified file, - * all other comments should be shown. Check the line type to avoid duplicating comments across these files. - * @param comment The comment - * @param fileDiffHunks The diff hunks of the file - * @param isBase Whether the file, if a diff, is the base or modified - */ -export function getAbsolutePosition(comment: IComment, fileDiffHunks: DiffHunk[], isBase: boolean): number { - let commentAbsolutePosition = -1; - // Ignore outdated comments - if (comment.position !== null) { - const diffLine = getDiffLineByPosition(fileDiffHunks, comment.position!); - - if (diffLine) { - if (isBase && diffLine.type === DiffChangeType.Delete) { - commentAbsolutePosition = diffLine.oldLineNumber; - } - - if (!isBase && diffLine.type !== DiffChangeType.Delete) { - commentAbsolutePosition = diffLine.newLineNumber; - } - } - } - - return commentAbsolutePosition; -} - -export function getLastDiffLine(prPatch: string): DiffLine | undefined { - let lastDiffLine = undefined; - const prDiffReader = parseDiffHunk(prPatch); - let prDiffIter = prDiffReader.next(); - - while (!prDiffIter.done) { - const diffHunk = prDiffIter.value; - lastDiffLine = diffHunk.diffLines[diffHunk.diffLines.length - 1]; - - prDiffIter = prDiffReader.next(); - } - - return lastDiffLine; -} - export function getDiffLineByPosition(diffHunks: DiffHunk[], diffLineNumber: number): DiffLine | undefined { for (let i = 0; i < diffHunks.length; i++) { const diffHunk = diffHunks[i]; @@ -76,58 +31,35 @@ export function getDiffLineByPosition(diffHunks: DiffHunk[], diffLineNumber: num return undefined; } -export function mapHeadLineToDiffHunkPosition(diffHunks: DiffHunk[], localDiff: string, line: number, isBase: boolean = false): number { - const localDiffReader = parseDiffHunk(localDiff); - let localDiffIter = localDiffReader.next(); - let lineInPRDiff = line; - - while (!localDiffIter.done) { - const diffHunk = localDiffIter.value; - if (diffHunk.oldLineNumber > line) { - break; - } else { - lineInPRDiff += diffHunk.oldLength - diffHunk.newLength; - } - - localDiffIter = localDiffReader.next(); - } - - const positionInDiffHunk = -1; - - for (let i = 0; i < diffHunks.length; i++) { - const diffHunk = diffHunks[i]; - - for (let j = 0; j < diffHunk.diffLines.length; j++) { - if (isBase) { - if (diffHunk.diffLines[j].oldLineNumber === lineInPRDiff) { - return diffHunk.diffLines[j].positionInHunk; - } - } else { - if (diffHunk.diffLines[j].newLineNumber === lineInPRDiff) { - return diffHunk.diffLines[j].positionInHunk; - } - } - } - } - - return positionInDiffHunk; -} - -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(); let delta = 0; while (!diffIter.done) { - const diffHunk = diffIter.value; + const diffHunk: DiffHunk = diffIter.value; if (diffHunk.oldLineNumber > line) { // 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(); @@ -136,27 +68,35 @@ export function mapOldPositionToNew(patch: string, line: number): number { return line + delta; } -export function mapCommentsToHead(diffHunks: DiffHunk[], localDiff: string, comments: IComment[]) { - for (let i = 0; i < comments.length; i++) { - const comment = comments[i]; +export function mapNewPositionToOld(patch: string, line: number): number { + const diffReader = parseDiffHunk(patch); + let diffIter = diffReader.next(); - // Ignore outdated comments - if (comment.position === null || comment.position === undefined) { - continue; - } + let delta = 0; + while (!diffIter.done) { + const diffHunk: DiffHunk = diffIter.value; - // Diff line is null when the original line the comment was on has been removed - const diffLine = getDiffLineByPosition(diffHunks, comment.position); - if (diffLine) { - // Ignore comments which are on deletions - if (diffLine.type === DiffChangeType.Delete) { - continue; + if (diffHunk.newLineNumber > line) { + // No-op + } else if (diffHunk.newLineNumber + diffHunk.newLength - 1 < line) { + delta += diffHunk.oldLength - diffHunk.newLength; + } else { + // 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; + } } - - const newPosition = mapOldPositionToNew(localDiff, diffLine.newLineNumber); - comment.absolutePosition = newPosition; + return line + delta; } + + diffIter = diffReader.next(); } - return comments; + return line + delta; } \ No newline at end of file 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 new file mode 100644 index 0000000000..54dc5800d3 --- /dev/null +++ b/src/common/executeCommands.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * 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 namespace contexts { + 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 SHOW_CHAT = 'workbench.panel.chat'; + 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); + } + + export function focusView(viewId: string) { + return executeCommand(`${viewId}.focus`); + } + + 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 6ed95c42ad..13d201cfcd 100644 --- a/src/common/file.ts +++ b/src/common/file.ts @@ -13,30 +13,34 @@ export enum GitChangeType { RENAME, TYPE, UNKNOWN, - UNMERGED + UNMERGED, } -export class InMemFileChange { +export interface SimpleFileChange { + readonly status: GitChangeType; + readonly fileName: string; + readonly blobUrl: string | undefined; + readonly diffHunks?: DiffHunk[]; +} +export class InMemFileChange implements SimpleFileChange { constructor( public readonly baseCommit: string, public readonly status: GitChangeType, public readonly fileName: string, public readonly previousFileName: string | undefined, public readonly patch: string, - public readonly diffHunks: DiffHunk[], - - public readonly isPartial: boolean, - public readonly blobUrl: string + public readonly diffHunks: DiffHunk[] | undefined, + public readonly blobUrl: string, ) { } } -export class SlimFileChange { +export class SlimFileChange implements SimpleFileChange { constructor( public readonly baseCommit: string, public readonly blobUrl: string, public readonly status: GitChangeType, public readonly fileName: string, - public readonly previousFileName: string | undefined + public readonly previousFileName: string | undefined, ) { } -} \ No newline at end of file +} diff --git a/src/common/gitUtils.ts b/src/common/gitUtils.ts new file mode 100644 index 0000000000..471b8a25ea --- /dev/null +++ b/src/common/gitUtils.ts @@ -0,0 +1,400 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; + +/** + * Removes `Co-authored-by:` trailer lines from a commit message body. + * + * These lines are typically added at the bottom of a commit message (for example by + * the Copilot CLI or `git commit --trailer`) and are not useful when the commit body + * is reused as the default PR description. + */ +export function stripCoAuthoredByTrailers(body: string): string { + if (!body) { + return body; + } + const lines = body.split('\n'); + const filtered = lines.filter(line => !/^\s*Co-authored-by:\s/i.test(line)); + // Remove any trailing blank lines that may have been left behind once the + // trailer block is gone, but preserve internal blank lines / paragraph breaks. + let end = filtered.length; + while (end > 0 && filtered[end - 1].trim() === '') { + end--; + } + return filtered.slice(0, end).join('\n'); +} + +/** + * 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 + */ +export function unwrapCommitMessageBody(body: string): string { + if (!body) { + return body; + } + + // Pattern to detect list item markers at the start of a line and capture the marker + const LIST_ITEM_PATTERN = /^(?[ \t]*)(?[*+\-]|\d+\.)(?[ \t]+)/; + // 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}`; + }; + + // Get the content indent for a list item (position where actual content starts) + const getListItemContentIndent = (line: string): number => { + const match = line.match(LIST_ITEM_PATTERN); + if (!match?.groups) { + return 0; + } + // Content indent = leading whitespace + marker + space after marker + return match.groups.leadingWhitespace.length + match.groups.marker.length + match.groups.markerTrailingWhitespace.length; + }; + + const lines = body.split('\n'); + const result: string[] = []; + let i = 0; + let inFencedBlock = false; + // Stack stores { markerIndent, contentIndent } for each nesting level + const listStack: { markerIndent: number; contentIndent: number }[] = []; + + // Find the active list context for a given line indent + // Returns the content indent if the line is within an active list context + const getActiveListContentIndent = (lineIndent: number): number | undefined => { + for (let idx = listStack.length - 1; idx >= 0; idx--) { + const { markerIndent, contentIndent } = listStack[idx]; + // A line is part of a list item if it has at least 1 space indent + // (but less than contentIndent + 4 which would be a code block) + if (lineIndent >= 1 && lineIndent >= markerIndent) { + listStack.length = idx + 1; + return contentIndent; + } + listStack.pop(); + } + return undefined; + }; + + const shouldJoinListContinuation = (lineIndex: number, contentIndent: 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); + // Need at least 1 space to be a continuation + if (currentIndent < 1) { + return false; + } + + // 4+ spaces beyond content indent is an indented code block + if (currentIndent >= contentIndent + 4) { + return false; + } + + return true; + }; + + while (i < lines.length) { + const line = lines[i]; + + // Preserve blank lines but don't clear list context + // (multi-paragraph lists are allowed in GitHub markdown) + if (line.trim() === '') { + result.push(line); + i++; + 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 listItemMatch = line.match(LIST_ITEM_PATTERN); + + if (listItemMatch?.groups) { + const markerIndent = listItemMatch.groups.leadingWhitespace.length; + const contentIndent = getListItemContentIndent(line); + + // Pop list levels that are at or beyond this indent + while (listStack.length && markerIndent <= listStack[listStack.length - 1].markerIndent) { + listStack.pop(); + } + + listStack.push({ markerIndent, contentIndent }); + result.push(line); + i++; + continue; + } + + // Handle non-indented lines that should be joined to a previous list item + // This happens when commit messages are wrapped at 72 characters + // Check this BEFORE calling getActiveListContentIndent which would clear the stack + if (listStack.length > 0 && lineIndent === 0 && !LIST_ITEM_PATTERN.test(line)) { + const isBlockquote = BLOCKQUOTE_PATTERN.test(line); + if (!isBlockquote) { + const baseIndex = result.length - 1; + const baseLine = baseIndex >= 0 ? result[baseIndex] : ''; + const previousLineIsBlank = baseLine.trim() === ''; + + if (!previousLineIsBlank && baseIndex >= 0) { + // Join this line and any following non-list-item lines with the previous list item + let joinedLine = baseLine; + let currentIndex = i; + + while (currentIndex < lines.length) { + const currentLine = lines[currentIndex]; + const trimmed = currentLine.trim(); + + // Stop at blank lines + if (!trimmed) { + break; + } + + // Stop at list items + if (LIST_ITEM_PATTERN.test(currentLine)) { + break; + } + + // Stop at blockquotes or fences + if (BLOCKQUOTE_PATTERN.test(currentLine) || FENCE_PATTERN.test(currentLine)) { + break; + } + + // Stop at indented code blocks + const currentLineIndent = getLeadingWhitespaceLength(currentLine); + if (currentLineIndent >= 4) { + break; + } + + // Stop if previous line has hard line break + if (hasHardLineBreak(joinedLine)) { + break; + } + + joinedLine = appendWithSpace(joinedLine, trimmed); + currentIndex++; + } + + if (currentIndex > i) { + result[baseIndex] = joinedLine; + i = currentIndex; + continue; + } + } + } + } + + const activeContentIndent = getActiveListContentIndent(lineIndent); + const codeIndentThreshold = activeContentIndent !== undefined ? activeContentIndent + 4 : 4; + const isBlockquote = BLOCKQUOTE_PATTERN.test(line); + const isIndentedCode = lineIndent >= codeIndentThreshold; + + if (isBlockquote || isIndentedCode) { + result.push(line); + i++; + continue; + } + + // Handle list item continuations + if (activeContentIndent !== undefined && lineIndent >= 1) { + const baseIndex = result.length - 1; + // Only try to join with previous line if it's not blank + // Multi-paragraph lists have blank lines that should be preserved + const baseLine = baseIndex >= 0 ? result[baseIndex] : ''; + const previousLineIsBlank = baseLine.trim() === ''; + + if (!previousLineIsBlank && baseIndex >= 0) { + let joinedLine = baseLine; + let appended = false; + let currentIndex = i; + + while ( + currentIndex < lines.length && + shouldJoinListContinuation(currentIndex, activeContentIndent, joinedLine) + ) { + const continuationText = lines[currentIndex].trim(); + if (continuationText) { + joinedLine = appendWithSpace(joinedLine, continuationText); + appended = true; + } + currentIndex++; + } + + if (appended) { + result[baseIndex] = joinedLine; + i = currentIndex; + continue; + } + } + + // For multi-paragraph continuations or standalone indented lines, + // preserve indentation but unwrap consecutive continuation lines + let joinedLine = line; + i++; + + while (i < lines.length) { + const nextLine = lines[i]; + + if (nextLine.trim() === '') { + break; + } + + if (FENCE_PATTERN.test(nextLine)) { + break; + } + + if (LIST_ITEM_PATTERN.test(nextLine)) { + break; + } + + if (BLOCKQUOTE_PATTERN.test(nextLine)) { + break; + } + + const nextIndent = getLeadingWhitespaceLength(nextLine); + // Check for code block + if (nextIndent >= activeContentIndent + 4) { + break; + } + + // Must have at least 1 space to be a continuation + if (nextIndent < 1) { + break; + } + + // Check for hard line break + if (hasHardLineBreak(joinedLine)) { + break; + } + + // Join this line - preserve the original indentation for the first line + joinedLine = appendWithSpace(joinedLine, nextLine.trim()); + i++; + } + + result.push(joinedLine); + 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 = appendWithSpace(joinedLine, nextLine.trim()); + i++; + } + + result.push(joinedLine); + } + + return result.join('\n'); +} + +/** + * 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 0dc33c6c56..a3803173ab 100644 --- a/src/common/githubRef.ts +++ b/src/common/githubRef.ts @@ -4,15 +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 - ) { + constructor(public ref: string, public label: string, public sha: string, repositoryCloneUrl: 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 28c1ad77de..024cd584d2 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -1,72 +1,74 @@ -import * as vscode from 'vscode'; - -const enum LogLevel { - Info, - Debug, - Off -} - -const SETTINGS_NAMESPACE = 'githubPullRequests'; -const LOG_LEVEL_SETTING = 'logLevel'; - -class Log { - private _outputChannel: vscode.OutputChannel; - private _logLevel: LogLevel; - private _disposable: vscode.Disposable; - - constructor() { - this._outputChannel = vscode.window.createOutputChannel('GitHub Pull Request'); - this._disposable = vscode.workspace.onDidChangeConfiguration(() => { - this.getLogLevel(); - }); - this.getLogLevel(); - } - - public appendLine(message: string, component?: string) { - switch (this._logLevel) { - case LogLevel.Off: - return; - case LogLevel.Debug: - const hrtime = process.hrtime(); - const timeStamp = `${hrtime[0]}s ${Math.floor(hrtime[1] / 1000000)}ms`; - const info = component ? `${component}> ${message}` : `${message}`; - this._outputChannel.appendLine(`[Debug ${timeStamp}] ${info}`); - return; - case LogLevel.Info: - default: - this._outputChannel.appendLine(`[Info] ` + (component ? `${component}> ${message}` : `${message}`)); - return; - } - } - - public debug(message: string, component: string) { - if (this._logLevel === LogLevel.Debug) { - this.appendLine(message, component); - } - } - - public dispose() { - if (this._disposable) { - this._disposable.dispose(); - } - } - - private getLogLevel() { - const logLevel = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get(LOG_LEVEL_SETTING); - switch (logLevel) { - case 'debug': - this._logLevel = LogLevel.Debug; - break; - case 'off': - this._logLevel = LogLevel.Off; - break; - case 'info': - default: - this._logLevel = LogLevel.Info; - break; - } - } -} - -const Logger = new Log(); -export default Logger; \ 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 * as vscode from 'vscode'; +import { Disposable } from './lifecycle'; + +export const PR_TREE = 'PullRequestTree'; + +interface Stringish { + toString: () => string; +} + +class Log extends Disposable { + private readonly _outputChannel: vscode.LogOutputChannel; + private readonly _activePerfMarkers: Map = new Map(); + + constructor() { + 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._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._activePerfMarkers.delete(marker); + } + + 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 | Error | Stringish | Object, component: string) { + this._outputChannel.trace(this.logString(message, component)); + } + + public debug(message: string | Error | Stringish | Object, component: string) { + this._outputChannel.debug(this.logString(message, component)); + } + + public appendLine(message: string | Error | Stringish | Object, component: string) { + this._outputChannel.info(this.logString(message, component)); + } + + public warn(message: string | Error | Stringish | Object, component?: string) { + this._outputChannel.warn(this.logString(message, component)); + } + + public error(message: string | Error | Stringish | Object, component: string) { + this._outputChannel.error(this.logString(message, component)); + } +} + +const Logger = new Log(); +export default Logger; diff --git a/src/common/protocol.ts b/src/common/protocol.ts index b78abc4650..cac2471f14 100644 --- a/src/common/protocol.ts +++ b/src/common/protocol.ts @@ -5,18 +5,19 @@ import * as vscode from 'vscode'; import Logger from './logger'; +import { resolve } from '../env/node/ssh'; -import { resolve } from './ssh'; export enum ProtocolType { Local, HTTP, SSH, GIT, - OTHER + OTHER, } export class Protocol { + private static readonly ID = 'Protocol'; public type: ProtocolType = ProtocolType.OTHER; public host: string = ''; @@ -29,10 +30,9 @@ export class Protocol { } public readonly url: vscode.Uri; - constructor( - uriString: string, - ) { + constructor(uriString: string) { if (this.parseSshProtocol(uriString)) { + this.url = vscode.Uri.from({ scheme: 'ssh', authority: this.host, path: `/${this.nameWithOwner}` }); return; } @@ -46,8 +46,10 @@ export class Protocol { this.owner = this.getOwnerName(this.url.path) || ''; } } catch (e) { - Logger.appendLine(`Failed to parse '${uriString}'`); - vscode.window.showWarningMessage(`Unable to parse remote '${uriString}'. Please check that it is correctly formatted.`); + 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) + ); } } @@ -69,7 +71,9 @@ export class Protocol { private parseSshProtocol(uriString: string): boolean { const sshConfig = resolve(uriString); - if (!sshConfig) { return false; } + if (!sshConfig) { + return false; + } const { Hostname, HostName, path } = sshConfig; this.host = HostName || Hostname; this.owner = this.getOwnerName(path) || ''; @@ -83,7 +87,6 @@ export class Protocol { const matches = /^(?:.*:?@)?([^:]*)(?::.*)?$/.exec(authority); if (matches && matches.length >= 2) { - // normalize to fix #903. // www.github.com will redirect anyways, so this is safe in this specific case, but potentially not in others. return matches[1].toLocaleLowerCase() === 'www.github.com' ? 'github.com' : matches[1]; @@ -135,7 +138,9 @@ export class Protocol { } try { - return vscode.Uri.parse(`${scheme}://${this.host.toLocaleLowerCase()}/${this.nameWithOwner.toLocaleLowerCase()}`); + return vscode.Uri.parse( + `${scheme}://${this.host.toLocaleLowerCase()}/${this.nameWithOwner.toLocaleLowerCase()}`, + ); } catch (e) { return; } @@ -169,7 +174,7 @@ export class Protocol { return; } - update(change: { type?: ProtocolType; host?: string; owner?: string; repositoryName?: string; }): Protocol { + update(change: { type?: ProtocolType; host?: string; owner?: string; repositoryName?: string }): Protocol { if (change.type) { this.type = change.type; } @@ -202,4 +207,4 @@ export class Protocol { return normalizeUri.toString().toLocaleLowerCase() === otherNormalizeUri.toString().toLocaleLowerCase(); } -} \ No newline at end of file +} diff --git a/src/common/remote.ts b/src/common/remote.ts index 046bee0d0b..c5283ca606 100644 --- a/src/common/remote.ts +++ b/src/common/remote.ts @@ -3,8 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +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 { @@ -22,6 +25,14 @@ export class Remote { return `${normalizedUri!.scheme}://${normalizedUri!.authority}`; } + public get authProviderId(): AuthProvider { + return this.host === getEnterpriseUri()?.authority ? AuthProvider.githubEnterprise : AuthProvider.github; + } + + public get isEnterprise(): boolean { + return isEnterprise(this.authProviderId); + } + constructor( public readonly remoteName: string, public readonly url: string, @@ -32,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; } @@ -46,14 +57,14 @@ export class Remote { } } -export function parseRemote(remoteName: string, url: string | undefined, originalProtocol?: Protocol): Remote | null { +export function parseRemote(remoteName: string, url: string, originalProtocol?: Protocol): Remote | null { if (!url) { return null; } const gitProtocol = new Protocol(url); if (originalProtocol) { gitProtocol.update({ - type: originalProtocol.type + type: originalProtocol.type, }); } @@ -64,8 +75,117 @@ export function parseRemote(remoteName: string, url: string | undefined, origina 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[] { - return repository.state.remotes - .map(r => parseRemote(r.name, r.fetchUrl || r.pushUrl)) - .filter(r => !!r) as Remote[]; + const remotes: Remote[] = []; + for (const r of repository.state.remotes) { + const urls: string[] = []; + if (r.fetchUrl) { + urls.push(r.fetchUrl); + } + if (r.pushUrl && r.pushUrl !== r.fetchUrl) { + urls.push(r.pushUrl); + } + urls.forEach(url => { + const remote = parseRemote(r.name, url); + if (remote) { + remotes.push(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); + } + + constructor( + remoteName: string, + url: string, + gitProtocol: Protocol, + public readonly githubServerType: GitHubServerType + ) { + super(remoteName, url, gitProtocol); + } } \ No newline at end of file diff --git a/src/common/resources.ts b/src/common/resources.ts deleted file mode 100644 index 4167f1014a..0000000000 --- a/src/common/resources.ts +++ /dev/null @@ -1,95 +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 * as path from 'path'; -import { GitChangeType } from './file'; -import { GitFileChangeNode, RemoteFileChangeNode, InMemFileChangeNode } from '../view/treeNodes/fileChangeNode'; - -export class Resource { - static icons: any; - - static initialize(context: vscode.ExtensionContext) { - Resource.icons = { - light: { - Modified: context.asAbsolutePath(path.join('resources', 'icons', 'light', 'status-modified.svg')), - Added: context.asAbsolutePath(path.join('resources', 'icons', 'light', 'status-added.svg')), - Deleted: context.asAbsolutePath(path.join('resources', 'icons', 'light', 'status-deleted.svg')), - Renamed: context.asAbsolutePath(path.join('resources', 'icons', 'light', 'status-renamed.svg')), - Copied: context.asAbsolutePath(path.join('resources', 'icons', 'light', 'status-copied.svg')), - Untracked: context.asAbsolutePath(path.join('resources', 'icons', 'light', 'status-untrackedt.svg')), - Ignored: context.asAbsolutePath(path.join('resources', 'icons', 'light', 'status-ignored.svg')), - Conflict: context.asAbsolutePath(path.join('resources', 'icons', 'light', 'status-conflict.svg')), - Comment: context.asAbsolutePath(path.join('resources', 'icons', 'comment.svg')), - Avatar: context.asAbsolutePath(path.join('resources', 'icons', 'light', 'github.svg')), - Description: context.asAbsolutePath(path.join('resources', 'icons', 'light', 'git-pull-request.svg')), - Issues: context.asAbsolutePath(path.join('resources', 'icons', 'light', 'issues.svg')), - IssueClosed: context.asAbsolutePath(path.join('resources', 'icons', 'light', 'issue-closed.svg')), - Check: context.asAbsolutePath(path.join('resources', 'icons', 'light', 'check.svg')), - Edit: context.asAbsolutePath(path.join('resources', 'icons', 'light', 'edit.svg')), - }, - dark: { - Modified: context.asAbsolutePath(path.join('resources', 'icons', 'dark', 'status-modified.svg')), - Added: context.asAbsolutePath(path.join('resources', 'icons', 'dark', 'status-added.svg')), - Deleted: context.asAbsolutePath(path.join('resources', 'icons', 'dark', 'status-deleted.svg')), - Renamed: context.asAbsolutePath(path.join('resources', 'icons', 'dark', 'status-renamed.svg')), - Copied: context.asAbsolutePath(path.join('resources', 'icons', 'dark', 'status-copied.svg')), - Untracked: context.asAbsolutePath(path.join('resources', 'icons', 'dark', 'status-untracked.svg')), - Ignored: context.asAbsolutePath(path.join('resources', 'icons', 'dark', 'status-ignored.svg')), - Conflict: context.asAbsolutePath(path.join('resources', 'icons', 'dark', 'status-conflict.svg')), - Comment: context.asAbsolutePath(path.join('resources', 'icons', 'comment.svg')), - Avatar: context.asAbsolutePath(path.join('resources', 'icons', 'dark', 'github.svg')), - Description: context.asAbsolutePath(path.join('resources', 'icons', 'dark', 'git-pull-request.svg')), - Issues: context.asAbsolutePath(path.join('resources', 'icons', 'dark', 'issues.svg')), - IssueClosed: context.asAbsolutePath(path.join('resources', 'icons', 'dark', 'issue-closed.svg')), - Check: context.asAbsolutePath(path.join('resources', 'icons', 'dark', 'check.svg')), - Edit: context.asAbsolutePath(path.join('resources', 'icons', 'dark', 'edit.svg')) - }, - 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')), - } - }; - } - - static getFileStatusUri(element: GitFileChangeNode | RemoteFileChangeNode | InMemFileChangeNode): vscode.Uri | { light: vscode.Uri, dark: vscode.Uri } { - let iconUri: vscode.Uri; - let iconDarkUri: vscode.Uri; - - switch (element.status) { - case GitChangeType.ADD: - iconUri = vscode.Uri.file(Resource.icons.light.Added); - iconDarkUri = vscode.Uri.file(Resource.icons.dark.Added); - break; - case GitChangeType.COPY: - iconUri = vscode.Uri.file(Resource.icons.light.Copied); - iconDarkUri = vscode.Uri.file(Resource.icons.dark.Copied); - break; - case GitChangeType.DELETE: - iconUri = vscode.Uri.file(Resource.icons.light.Deleted); - iconDarkUri = vscode.Uri.file(Resource.icons.dark.Deleted); - break; - case GitChangeType.MODIFY: - iconUri = vscode.Uri.file(Resource.icons.light.Modified); - iconDarkUri = vscode.Uri.file(Resource.icons.dark.Modified); - break; - case GitChangeType.RENAME: - iconUri = vscode.Uri.file(Resource.icons.light.Renamed); - iconDarkUri = vscode.Uri.file(Resource.icons.dark.Renamed); - break; - } - - return { - light: iconUri!, - dark: iconDarkUri! - }; - } -} diff --git a/src/common/settingKeys.ts b/src/common/settingKeys.ts new file mode 100644 index 0000000000..0f5e64399b --- /dev/null +++ b/src/common/settingKeys.ts @@ -0,0 +1,116 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +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 = '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 SELECT_WORKTREE = 'selectWorktree'; +export const DELETE_BRANCH_AFTER_MERGE = 'deleteBranchAfterMerge'; +export const ENABLE_ATTESTATION_COMMITS = 'enableAttestationCommits'; +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 AUTO_REPO_DETECTION = 'autoRepositoryDetection'; +export type AutoRepoDetectionVariants = 'workspace' | true; + +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'; + +export const PULL_REQUEST_AVATAR_DISPLAY = 'pullRequestAvatarDisplay'; +export type IssueAvatarDisplay = 'author' | 'assignee' | 'state' | 'generic'; +export type PullRequestAvatarDisplay = 'author' | 'state' | 'generic'; + +// 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/telemetry.ts b/src/common/telemetry.ts index b20d465863..77944654a1 100644 --- a/src/common/telemetry.ts +++ b/src/common/telemetry.ts @@ -4,20 +4,23 @@ *--------------------------------------------------------------------------------------------*/ export interface ITelemetry { - sendTelemetryEvent(eventName: string, properties?: { - [key: string]: string; - }, measurements?: { - [key: string]: number; - }): void; - sendTelemetryErrorEvent(eventName: string, properties?: { - [key: string]: string; - }, measurements?: { - [key: string]: number; - }): void; - sendTelemetryException(error: Error, properties?: { - [key: string]: string; - }, measurements?: { - [key: string]: number; - }): void; + sendTelemetryEvent( + eventName: string, + properties?: { + [key: string]: string; + }, + measurements?: { + [key: string]: number; + }, + ): void; + sendTelemetryErrorEvent( + eventName: string, + properties?: { + [key: string]: string; + }, + measurements?: { + [key: string]: number; + }, + ): void; dispose(): Promise; -} \ No newline at end of file +} diff --git a/src/common/temporaryState.ts b/src/common/temporaryState.ts new file mode 100644 index 0000000000..3ae38b71ce --- /dev/null +++ b/src/common/temporaryState.ts @@ -0,0 +1,143 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 readonly _storageUri: vscode.Uri) { + super(() => disposeAll(this.disposables)); + } + + private get path(): vscode.Uri { + return vscode.Uri.joinPath(this._storageUri, this.SUBPATH); + } + + 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); + } + } + + private async writeState(subpath: string, filename: string, contents: Uint8Array, persistInSession: boolean, 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); + } + + if (subpath) { + filePath = vscode.Uri.joinPath(filePath, subpath); + } + await vscode.workspace.fs.createDirectory(filePath); + const file = vscode.Uri.joinPath(filePath, filename); + await vscode.workspace.fs.writeFile(file, contents); + + const dispose = { + dispose: () => { + 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, 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) { + // Ignore FileNotFound errors - the temp directory may not exist yet on first run. + if (!(e instanceof vscode.FileSystemError) || e.code !== 'FileNotFound') { + Logger.appendLine(`Error in initialization: ${e.message}`, TemporaryState.ID); + } + } + try { + await vscode.workspace.fs.createDirectory(tempState.path); + } catch (e) { + Logger.appendLine(`Error in initialization: ${e.message}`, TemporaryState.ID); + } + context.subscriptions.push(tempState); + return tempState; + } + } + + 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.readState(subpath, filename, repositoryUri); + } +} \ No newline at end of file diff --git a/src/common/timelineEvent.ts b/src/common/timelineEvent.ts index e037abb802..a72848cfc8 100644 --- a/src/common/timelineEvent.ts +++ b/src/common/timelineEvent.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IComment } from './comment'; -import { IAccount } from '../github/interface'; +import { IAccount, IActor, Reaction } from '../github/interface'; export enum EventType { Committed, @@ -12,12 +12,22 @@ export enum EventType { Subscribed, Commented, Reviewed, + NewCommitsSinceReview, Labeled, Milestoned, Assigned, + Unassigned, HeadRefDeleted, Merged, - Other + CrossReferenced, + Closed, + Reopened, + BaseRefChanged, + CopilotStarted, + CopilotFinished, + CopilotFinishedError, + CopilotReviewStarted, + Other, } export interface Committer { @@ -28,19 +38,37 @@ export interface Committer { export interface CommentEvent { id: number; + graphNodeId: string; htmlUrl: string; body: string; bodyHTML?: string; - user: IAccount; - event: EventType; + user?: IAccount; + event: EventType.Commented; canEdit?: boolean; canDelete?: boolean; createdAt: string; + reactions?: Reaction[]; +} + +export interface ReviewResolveInfo { + threadId: string; + canResolve: boolean; + canUnresolve: boolean; + isResolved: boolean; } +export type ReviewStateValue = 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED' | 'PENDING' | 'REQUESTED'; + export interface ReviewEvent { id: number; - event: EventType; + /** + * Map of review thread GraphQL node id to its resolve info, for all + * review threads belonging to this review. A single review can have + * comments on multiple threads, so resolve actions must look up the + * thread by the comment's `threadId`. + */ + reviewThreads?: { [threadId: string]: ReviewResolveInfo }; + event: EventType.Reviewed; comments: IComment[]; submittedAt: string; body: string; @@ -48,68 +76,158 @@ export interface ReviewEvent { htmlUrl: string; user: IAccount; authorAssociation: string; - state: 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED' | 'PENDING' | 'REQUESTED'; + state?: ReviewStateValue; + reactions?: Reaction[]; } export interface CommitEvent { - id: number; + id: string; author: IAccount; - event: EventType; + event: EventType.Committed; sha: string; htmlUrl: string; message: string; bodyHTML?: string; + committedDate: Date; + verification?: CommitVerification; + status?: 'EXPECTED' | 'ERROR' | 'FAILURE' | 'PENDING' | 'SUCCESS'; +} + +export interface CommitVerification { + verified: boolean; + state: string; + wasSignedByGitHub?: boolean; + signer?: { + login: string; + name?: string; + avatarUrl?: string; + }; + keyId?: string; + keyFingerprint?: string; + email?: string; +} + +export interface NewCommitsSinceReviewEvent { + id: string; + event: EventType.NewCommitsSinceReview; } export interface MergedEvent { - id: number; + id: string; graphNodeId: string; - user: IAccount; + user: IActor; createdAt: string; mergeRef: string; sha: string; commitUrl: string; - event: EventType; + event: EventType.Merged; url: string; } export interface AssignEvent { id: number; - event: EventType; - user: IAccount; - actor: IAccount; + event: EventType.Assigned; + 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; - actor: IAccount; + event: EventType.HeadRefDeleted; + actor: IActor; createdAt: string; headRef: string; } -export type TimelineEvent = CommitEvent | ReviewEvent | CommentEvent | 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 function isReviewEvent(event: TimelineEvent): event is ReviewEvent { - return event.event === EventType.Reviewed; +export interface ReopenedEvent { + id: string; + event: EventType.Reopened; + actor: IActor; + createdAt: string; } -export function isCommitEvent(event: TimelineEvent): event is CommitEvent { - return event.event === EventType.Committed; +export interface BaseRefChangedEvent { + id: string; + event: EventType.BaseRefChanged; + actor: IActor; + createdAt: string; + currentRefName: string; + previousRefName: string; } -export function isCommentEvent(event: TimelineEvent): event is CommentEvent { - return event.event === EventType.Commented; +export interface SessionPullInfo { + id: number; + host: string; + owner: string; + repo: string; + pullNumber: number; } -export function isMergedEvent(event: TimelineEvent): event is MergedEvent { - return event.event === EventType.Merged; +export interface SessionLinkInfo extends SessionPullInfo { + sessionIndex: number; + openToTheSide?: boolean; } -export function isAssignEvent(event: TimelineEvent): event is AssignEvent { - return event.event === EventType.Assigned; +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 function isHeadDeleteEvent(event: TimelineEvent): event is HeadRefDeleteEvent { - return event.event === EventType.HeadRefDeleted; -} \ No newline at end of file +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 78f751ca44..dfe954370e 100644 --- a/src/common/uri.ts +++ b/src/common/uri.ts @@ -5,11 +5,19 @@ 'use strict'; -import { Uri, UriHandler, EventEmitter } from 'vscode'; +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 { 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'; -import { Repository } from '../api/api'; -import * as pathUtils from 'path'; export interface ReviewUriParams { path: string; @@ -20,8 +28,8 @@ export interface ReviewUriParams { rootPath: string; } -export function fromReviewUri(uri: Uri): ReviewUriParams { - return JSON.parse(uri.query); +export function fromReviewUri(query: string): ReviewUriParams { + return JSON.parse(query); } export interface PRUriParams { @@ -32,38 +40,153 @@ export interface PRUriParams { prNumber: number; status: GitChangeType; remoteName: string; + previousFileName?: string; } -export function fromPRUri(uri: Uri): PRUriParams | undefined { +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; + 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) { } +} + +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; } -const ImageMimetypes = [ - 'image/png', - 'image/gif', - 'image/jpeg', - 'image/webp', - 'image/tiff', - 'image/bmp' +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 = [ + '.aac', + '.avi', + '.bmp', + '.flv', + '.gif', + '.ico', + '.jpe', + '.jpeg', + '.jpg', + '.m1v', + '.m2a', + '.m2v', + '.m3a', + '.mid', + '.midi', + '.mk3d', + '.mks', + '.mkv', + '.mov', + '.movie', + '.mp2', + '.mp2a', + '.mp3', + '.mp4', + '.mp4a', + '.mp4v', + '.mpe', + '.mpeg', + '.mpg', + '.mpg4', + '.mpga', + '.oga', + '.ogg', + '.opus', + '.ogv', + '.png', + '.psd', + '.qt', + '.spx', + '.svg', + '.tga', + '.tif', + '.tiff', + '.wav', + '.webm', + '.webp', + '.wma', + '.wmv', + '.woff' ]; // a 1x1 pixel transparent gif, from http://png-pixel.com/ -export const EMPTY_IMAGE_URI = Uri.parse(`data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==`); +export const EMPTY_IMAGE_URI = vscode.Uri.parse( + `data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==`, +); -export async function asImageDataURI(uri: Uri, repository: Repository): Promise { +export async function asTempStorageURI(uri: vscode.Uri, repository: Repository): Promise { try { - const { commit, baseCommit, headCommit, isBase } = JSON.parse(uri.query); - const ref = uri.scheme === 'review' ? commit : - isBase ? baseCommit : headCommit; - const { size, object } = await repository.getObjectDetails(ref, uri.fsPath); + 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 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') { @@ -71,22 +194,224 @@ export async function asImageDataURI(uri: Uri, repository: Repository): Promise< } if (ImageMimetypes.indexOf(mimetype) > -1) { - const contents = await repository.buffer(ref, uri.fsPath); - return Uri.parse(`data:${mimetype};label:${pathUtils.basename(uri.fsPath)};description:${ref};size:${size};base64,${contents.toString('base64')}`); + const contents = await repository.buffer(ref, absolutePath); + return TemporaryState.write(pathUtils.dirname(path), pathUtils.basename(path), contents, false, repository.rootUri); } } catch (err) { return; } } -export function toReviewUri(uri: Uri, filePath: string | undefined, ref: string | undefined, commit: string, isOutdated: boolean, options: GitUriOptions, rootUri: Uri): Uri { +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, + ref: string | undefined, + commit: string, + isOutdated: boolean, + options: GitUriOptions, + rootUri: vscode.Uri, +): vscode.Uri { const params: ReviewUriParams = { path: filePath ? filePath : uri.path, ref, commit: commit, base: options.base, isOutdated, - rootPath: rootUri.path + rootPath: rootUri.path, }; let path = uri.path; @@ -96,37 +421,52 @@ export function toReviewUri(uri: Uri, filePath: string | undefined, ref: string } return uri.with({ - scheme: 'review', + scheme: Schemes.Review, path, - query: JSON.stringify(params) + query: JSON.stringify(params), }); } export interface FileChangeNodeUriParams { prNumber: number; fileName: string; + previousFileName?: string; status?: GitChangeType; } -export function toResourceUri(uri: Uri, prNumber: number, fileName: string, status: GitChangeType) { - const params = { - prNumber: prNumber, - fileName: fileName, - status: status +export function toResourceUri(uri: vscode.Uri, prNumber: number, fileName: string, status: GitChangeType, previousFileName?: string) { + const params: FileChangeNodeUriParams = { + prNumber, + fileName, + status, + previousFileName }; return uri.with({ - query: JSON.stringify(params) + scheme: Schemes.FileChange, + query: JSON.stringify(params), }); } -export function fromFileChangeNodeUri(uri: Uri): FileChangeNodeUriParams | undefined { +export function fromFileChangeNodeUri(uri: vscode.Uri): FileChangeNodeUriParams | undefined { + if (uri.query === '') { + return undefined; + } try { return JSON.parse(uri.query) as FileChangeNodeUriParams; } catch (e) { } } -export function toPRUri(uri: Uri, pullRequestModel: PullRequestModel, baseCommit: string, headCommit: string, fileName: string, base: boolean, status: GitChangeType): Uri { +export function toPRUri( + uri: vscode.Uri, + pullRequestModel: PullRequestModel, + baseCommit: string, + headCommit: string, + fileName: string, + base: boolean, + status: GitChangeType, + previousFileName?: string +): vscode.Uri { const params: PRUriParams = { baseCommit: baseCommit, headCommit: headCommit, @@ -134,22 +474,360 @@ export function toPRUri(uri: Uri, pullRequestModel: PullRequestModel, baseCommit fileName: fileName, prNumber: pullRequestModel.number, status: status, - remoteName: pullRequestModel.githubRepository.remote.remoteName + remoteName: pullRequestModel.githubRepository.remote.remoteName, + previousFileName }; const path = uri.path; return uri.with({ - scheme: 'pr', + scheme: Schemes.Pr, path, + query: JSON.stringify(params), + }); +} + +export function createPRNodeIdentifier(pullRequest: PullRequestModel | { remote: string, prNumber: number } | string) { + let identifier: string; + if (pullRequest instanceof PullRequestModel) { + identifier = `${pullRequest.remote.url}:${pullRequest.number}`; + } else if (typeof pullRequest === 'string') { + identifier = pullRequest; + } else { + identifier = `${pullRequest.remote}:${pullRequest.prNumber}`; + } + 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, 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}`); + + return uri.with({ + scheme: Schemes.PRNode, query: JSON.stringify(params) }); } -class UriEventHandler extends EventEmitter implements UriHandler { - public handleUri(uri: Uri) { - this.fire(uri); +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 { + // Check if the query uses the simplified format: uri=https://github.com/owner/repo/issues/number + const queryParams = new URLSearchParams(uri.query); + const uriParam = queryParams.get('uri'); + if (uriParam) { + const issueUrlRegex = /^https?:\/\/github\.com\/(?[^\/]+)\/(?[^\/]+)\/issues\/(?\d+)$/; + const match = issueUrlRegex.exec(uriParam); + if (match && match.groups) { + const { owner, repo, issueNumber } = match.groups; + const params = { + owner, + repo, + issueNumber: parseInt(issueNumber, 10) + }; + if (!validateOpenWebviewParams(params.owner, params.repo, params.issueNumber.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.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 const handler = new UriEventHandler; \ No newline at end of file +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', // File content for a checked out PR + Pr = 'pr', // File content from GitHub for non-checkout PR + PRNode = 'prnode', + 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 + 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 + CheckRunLog = 'checkrunlog' // Check run log content +} + +export function resolvePath(from: vscode.Uri, to: string) { + if (from.scheme === Schemes.File) { + return pathUtils.resolve(from.fsPath, to); + } else { + return pathUtils.posix.resolve(from.path, to); + } +} 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 d3228ab4e3..10ee3c7005 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -4,9 +4,52 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; -import { Event, Disposable } from 'vscode'; import { sep } from 'path'; -import moment = require('moment'); +import dayjs from 'dayjs'; +import * as relativeTime from 'dayjs/plugin/relativeTime'; +import * as updateLocale from 'dayjs/plugin/updateLocale'; +import type { Disposable, Event, ExtensionContext, Uri } from 'vscode'; +import { combinedDisposable } from './lifecycle'; +// TODO: localization for webview needed + +dayjs.extend(relativeTime.default, { + thresholds: [ + { l: 's', r: 44, d: 'second' }, + { l: 'm', r: 89 }, + { l: 'mm', r: 44, d: 'minute' }, + { l: 'h', r: 89 }, + { l: 'hh', r: 21, d: 'hour' }, + { l: 'd', r: 35 }, + { l: 'dd', r: 6, d: 'day' }, + { l: 'w', r: 7 }, + { l: 'ww', r: 3, d: 'week' }, + { l: 'M', r: 4 }, + { l: 'MM', r: 10, d: 'month' }, + { l: 'y', r: 17 }, + { l: 'yy', d: 'year' }, + ], +}); + +dayjs.extend(updateLocale.default); +dayjs.updateLocale('en', { + relativeTime: { + future: 'in %s', + past: '%s ago', + s: 'seconds', + m: 'a minute', + mm: '%d minutes', + h: 'an hour', + hh: '%d hours', + d: 'a day', + dd: '%d days', + w: 'a week', + ww: '%d weeks', + M: 'a month', + MM: '%d months', + y: 'a year', + yy: '%d years', + }, +}); export function uniqBy(arr: T[], fn: (el: T) => string): T[] { const seen = Object.create(null); @@ -23,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)))); @@ -49,15 +79,20 @@ export function anyEvent(...events: Event[]): Event { } export function filterEvent(event: Event, filter: (e: T) => boolean): Event { - return (listener, thisArgs = null, disposables?) => event(e => filter(e) && listener.call(thisArgs, e), 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?) => { - const result = event(e => { - result.dispose(); - return listener.call(thisArgs, e); - }, null, disposables); + return (listener, thisArgs = null, disposables?: Disposable[]) => { + const result = event( + e => { + result.dispose(); + return listener.call(thisArgs, e); + }, + null, + disposables, + ); return result; }; @@ -67,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); @@ -93,19 +128,25 @@ export function groupBy(arr: T[], fn: (el: T) => string): { [key: string]: T[ }, Object.create(null)); } +export class UnreachableCaseError extends Error { + constructor(val: never) { + super(`Unreachable case: ${val}`); + } +} + interface HookError extends Error { - errors: any; + errors: (string | { message: string })[]; } function isHookError(e: Error): e is HookError { - return !!(e).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; } @@ -124,7 +165,9 @@ export function formatError(e: HookError | any): string { if (e.gitErrorCode) { // known git errors, we should display detailed git error messages. - return e.message + '. Please check git output for more details'; + return `${e.message}. Please check git output for more details`; + } else if (e.stderr) { + return `${e.stderr}. Please check git output for more details`; } return 'Error'; } @@ -132,17 +175,23 @@ export function formatError(e: HookError | any): string { let errorMessage = e.message; let furtherInfo: string | undefined; 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})`; - }).join(', '); + furtherInfo = e.errors + .map(error => { + 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) { - furtherInfo = e.errors.map((error: any) => { - if (typeof error === 'string') { - return error; - } else { - return error.message; - } - }).join(', '); + return e.errors + .map((error) => { + if (typeof error === 'string') { + return error; + } else { + return error.message; + } + }) + .join(', '); } if (furtherInfo) { errorMessage = `${errorMessage}: ${furtherInfo}`; @@ -151,80 +200,334 @@ 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) => { + const sub = event(e => { + sub.dispose(); + resolve(e); + }); + }); } -const passthrough = (value: any, resolve: (value?: any) => void) => resolve(value); - -/** - * Return a promise that resolves with the next emitted event, or with some future - * event as decided by an adapter. - * - * If specified, the adapter is a function that will be called with - * `(event, resolve, reject)`. It will be called once per event until it resolves or - * rejects. - * - * The default adapter is the passthrough function `(value, resolve) => resolve(value)`. - * - * @param {Event} event the event - * @param {PromiseAdapter?} adapter controls resolution of the returned promise - * @returns {Promise} a promise that resolves or rejects as specified by the adapter - */ -export async function promiseFromEvent( - event: Event, - adapter: PromiseAdapter = passthrough): Promise { - let subscription: Disposable; - return new Promise((resolve, reject) => - subscription = event((value: T) => { - try { - Promise.resolve(adapter(value, resolve, reject)) - .catch(reject); - } catch (error) { - reject(error); - } - }) - ).then( - (result: U) => { - subscription.dispose(); - return result; - }, - error => { - subscription.dispose(); - throw error; - } - ); +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 duration = moment.duration(moment().diff(date)); + const djs = dayjs(date); - if (duration.asMonths() < 1) { - return moment(date).fromNow(); - } else if (duration.asYears() < 1) { - return 'on ' + moment(date).format('MMM D'); - } else { - return 'on ' + moment(date).format('MMM D, YYYY'); + const now = Date.now(); + djs.diff(now, 'month'); + + if (djs.diff(now, 'month') < 1) { + return djs.fromNow(); + } else if (djs.diff(now, 'year') < 1) { + return `on ${djs.format('MMM D')}`; + } + return `on ${djs.format('MMM D, YYYY')}`; +} + + +export function gitHubLabelColor(hexColor: string, isDark: boolean, markDown: boolean = false): { textColor: string, backgroundColor: string, borderColor: string } { + if (hexColor.startsWith('#')) { + hexColor = hexColor.substring(1); + } + const rgbColor = hexToRgb(hexColor); + + if (isDark) { + const hslColor = rgbToHsl(rgbColor.r, rgbColor.g, rgbColor.b); + + const lightnessThreshold = 0.6; + const backgroundAlpha = 0.18; + const borderAlpha = 0.3; + + const perceivedLightness = (rgbColor.r * 0.2126 + rgbColor.g * 0.7152 + rgbColor.b * 0.0722) / 255; + const lightnessSwitch = Math.max(0, Math.min((perceivedLightness - lightnessThreshold) * -1000, 1)); + + const lightenBy = (lightnessThreshold - perceivedLightness) * 100 * lightnessSwitch; + const rgbBorder = hexToRgb(hslToHex(hslColor.h, hslColor.s, hslColor.l + lightenBy)); + + const textColor = `#${hslToHex(hslColor.h, hslColor.s, hslColor.l + lightenBy)}`; + const backgroundColor = !markDown ? + `rgba(${rgbColor.r},${rgbColor.g},${rgbColor.b},${backgroundAlpha})` : + `#${rgbToHex({ ...rgbColor, a: backgroundAlpha })}`; + const borderColor = !markDown ? + `rgba(${rgbBorder.r},${rgbBorder.g},${rgbBorder.b},${borderAlpha})` : + `#${rgbToHex({ ...rgbBorder, a: borderAlpha })}`; + + return { textColor: textColor, backgroundColor: backgroundColor, borderColor: borderColor }; + } + else { + return { textColor: `#${contrastColor(rgbColor)}`, backgroundColor: `#${hexColor}`, borderColor: `#${hexColor}` }; + } +} + +const rgbToHex = (color: { r: number, g: number, b: number, a?: number }) => { + const colors = [color.r, color.g, color.b]; + if (color.a) { + colors.push(Math.floor(color.a * 255)); + } + return colors.map((digit) => { + return digit.toString(16).padStart(2, '0'); + }).join(''); +}; + +function hexToRgb(color: string) { + const result = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color); + + if (result) { + return { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + }; } + return { + r: 0, + g: 0, + b: 0, + }; +} + +function rgbToHsl(r: number, g: number, b: number) { + // Source: https://css-tricks.com/converting-color-spaces-in-javascript/ + // Make r, g, and b fractions of 1 + r /= 255; + g /= 255; + b /= 255; + + // Find greatest and smallest channel values + let cmin = Math.min(r, g, b), + cmax = Math.max(r, g, b), + delta = cmax - cmin, + h = 0, + s = 0, + l = 0; + + // Calculate hue + // No difference + if (delta == 0) + h = 0; + // Red is max + else if (cmax == r) + h = ((g - b) / delta) % 6; + // Green is max + else if (cmax == g) + h = (b - r) / delta + 2; + // Blue is max + else + h = (r - g) / delta + 4; + + h = Math.round(h * 60); + + // Make negative hues positive behind 360 deg + if (h < 0) + h += 360; + + // Calculate lightness + l = (cmax + cmin) / 2; + + // Calculate saturation + s = delta == 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)); + + // Multiply l and s by 100 + s = +(s * 100).toFixed(1); + l = +(l * 100).toFixed(1); + + return { h: h, s: s, l: l }; +} + +function hslToHex(h: number, s: number, l: number): string { + // source https://www.jameslmilner.com/posts/converting-rgb-hex-hsl-colors/ + const hDecimal = l / 100; + const a = (s * Math.min(hDecimal, 1 - hDecimal)) / 100; + const f = (n: number) => { + const k = (n + h / 30) % 12; + const color = hDecimal - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + + // Convert to Hex and prefix with "0" if required + return Math.round(255 * color) + .toString(16) + .padStart(2, '0'); + }; + return `${f(0)}${f(8)}${f(4)}`; } + +function contrastColor(rgbColor: { r: number, g: number, b: number }) { + // Color algorithm from https://stackoverflow.com/questions/1855884/determine-font-color-based-on-background-color + const luminance = (0.299 * rgbColor.r + 0.587 * rgbColor.g + 0.114 * rgbColor.b) / 255; + return luminance > 0.5 ? '000000' : 'ffffff'; +} + export interface Predicate { (input: T): boolean; } -export class PathIterator implements IKeyIterator { +export interface AsyncPredicate { + (input: T): Promise; +} + +export const enum CharCode { + Period = 46, + /** + * The `/` character. + */ + Slash = 47, + + A = 65, + Z = 90, - private _value: string; - private _from: number; - private _to: number; + Backslash = 92, + + a = 97, + z = 122, +} + +export function compare(a: string, b: string): number { + if (a < b) { + return -1; + } else if (a > b) { + return 1; + } + return 0; +} + +export function compareSubstring( + a: string, + b: string, + aStart: number = 0, + aEnd: number = a.length, + bStart: number = 0, + bEnd: number = b.length, +): number { + for (; aStart < aEnd && bStart < bEnd; aStart++, bStart++) { + const codeA = a.charCodeAt(aStart); + const codeB = b.charCodeAt(bStart); + if (codeA < codeB) { + return -1; + } else if (codeA > codeB) { + return 1; + } + } + const aLen = aEnd - aStart; + const bLen = bEnd - bStart; + if (aLen < bLen) { + return -1; + } else if (aLen > bLen) { + return 1; + } + return 0; +} + +export function compareIgnoreCase(a: string, b: string): number { + return compareSubstringIgnoreCase(a, b, 0, a.length, 0, b.length); +} + +export function compareSubstringIgnoreCase( + a: string, + b: string, + aStart: number = 0, + aEnd: number = a.length, + bStart: number = 0, + bEnd: number = b.length, +): number { + for (; aStart < aEnd && bStart < bEnd; aStart++, bStart++) { + let codeA = a.charCodeAt(aStart); + let codeB = b.charCodeAt(bStart); + + if (codeA === codeB) { + // equal + continue; + } + + const diff = codeA - codeB; + if (diff === 32 && isUpperAsciiLetter(codeB)) { + //codeB =[65-90] && codeA =[97-122] + continue; + } else if (diff === -32 && isUpperAsciiLetter(codeA)) { + //codeB =[97-122] && codeA =[65-90] + continue; + } + + if (isLowerAsciiLetter(codeA) && isLowerAsciiLetter(codeB)) { + // + return diff; + } else { + return compareSubstring(a.toLowerCase(), b.toLowerCase(), aStart, aEnd, bStart, bEnd); + } + } + + const aLen = aEnd - aStart; + const bLen = bEnd - bStart; + + if (aLen < bLen) { + return -1; + } else if (aLen > bLen) { + return 1; + } + + return 0; +} + +export function isLowerAsciiLetter(code: number): boolean { + return code >= CharCode.a && code <= CharCode.z; +} + +export function isUpperAsciiLetter(code: number): boolean { + return code >= CharCode.A && code <= CharCode.Z; +} + +export interface IKeyIterator { + reset(key: K): this; + next(): this; + + hasNext(): boolean; + cmp(a: string): number; + value(): string; +} + +export class StringIterator implements IKeyIterator { + private _value: string = ''; + private _pos: number = 0; reset(key: string): this { - this._value = key.replace(/\\$|\/$/, ''); + this._value = key; + this._pos = 0; + return this; + } + + next(): this { + this._pos += 1; + return this; + } + + hasNext(): boolean { + return this._pos < this._value.length - 1; + } + + cmp(a: string): number { + const aCode = a.charCodeAt(0); + const thisCode = this._value.charCodeAt(this._pos); + return aCode - thisCode; + } + + value(): string { + return this._value[this._pos]; + } +} + +export class ConfigKeysIterator implements IKeyIterator { + private _value!: string; + private _from!: number; + private _to!: number; + + constructor(private readonly _caseSensitive: boolean = true) { } + + reset(key: string): this { + this._value = key; this._from = 0; this._to = 0; return this.next(); @@ -240,7 +543,7 @@ export class PathIterator implements IKeyIterator { let justSeps = true; for (; this._to < this._value.length; this._to++) { const ch = this._value.charCodeAt(this._to); - if (ch === 47 /* CharCode.Slash */ || ch === 92 /* CharCode.Backslash */) { + if (ch === CharCode.Period) { if (justSeps) { this._from++; } else { @@ -254,27 +557,57 @@ export class PathIterator implements IKeyIterator { } cmp(a: string): number { + return this._caseSensitive + ? compareSubstring(a, this._value, 0, a.length, this._from, this._to) + : compareSubstringIgnoreCase(a, this._value, 0, a.length, this._from, this._to); + } + + value(): string { + return this._value.substring(this._from, this._to); + } +} + +export class PathIterator implements IKeyIterator { + private _value!: string; + private _from!: number; + private _to!: number; + + constructor(private readonly _splitOnBackslash: boolean = true, private readonly _caseSensitive: boolean = true) { } + + reset(key: string): this { + this._value = key.replace(/\\$|\/$/, ''); + this._from = 0; + this._to = 0; + return this.next(); + } - let aPos = 0; - const aLen = a.length; - let thisPos = this._from; + hasNext(): boolean { + return this._to < this._value.length; + } - while (aPos < aLen && thisPos < this._to) { - const cmp = a.charCodeAt(aPos) - this._value.charCodeAt(thisPos); - if (cmp !== 0) { - return cmp; + next(): this { + // this._data = key.split(/[\\/]/).filter(s => !!s); + this._from = this._to; + let justSeps = true; + for (; this._to < this._value.length; this._to++) { + const ch = this._value.charCodeAt(this._to); + if (ch === CharCode.Slash || (this._splitOnBackslash && ch === CharCode.Backslash)) { + if (justSeps) { + this._from++; + } else { + break; + } + } else { + justSeps = false; } - aPos += 1; - thisPos += 1; } + return this; + } - if (aLen === this._to - this._from) { - return 0; - } else if (aPos < aLen) { - return -1; - } else { - return 1; - } + cmp(a: string): number { + return this._caseSensitive + ? compareSubstring(a, this._value, 0, a.length, this._from, this._to) + : compareSubstringIgnoreCase(a, this._value, 0, a.length, this._from, this._to); } value(): string { @@ -282,44 +615,142 @@ export class PathIterator implements IKeyIterator { } } -export interface IteratorUndefinedResult { - readonly done: true; - readonly value: undefined; +const enum UriIteratorState { + Scheme = 1, + Authority = 2, + Path = 3, + Query = 4, + Fragment = 5, } -export const FIN: IteratorUndefinedResult = { done: true, value: undefined }; -export interface IKeyIterator { - reset(key: string): this; - next(): this; +export class UriIterator implements IKeyIterator { + private _pathIterator!: PathIterator; + private _value!: Uri; + private _states: UriIteratorState[] = []; + private _stateIdx: number = 0; - hasNext(): boolean; - cmp(a: string): number; - value(): string; + constructor(private readonly _ignorePathCasing: (uri: Uri) => boolean) { } + + reset(key: Uri): this { + this._value = key; + this._states = []; + if (this._value.scheme) { + this._states.push(UriIteratorState.Scheme); + } + if (this._value.authority) { + this._states.push(UriIteratorState.Authority); + } + if (this._value.path) { + this._pathIterator = new PathIterator(false, !this._ignorePathCasing(key)); + this._pathIterator.reset(key.path); + if (this._pathIterator.value()) { + this._states.push(UriIteratorState.Path); + } + } + if (this._value.query) { + this._states.push(UriIteratorState.Query); + } + if (this._value.fragment) { + this._states.push(UriIteratorState.Fragment); + } + this._stateIdx = 0; + return this; + } + + next(): this { + if (this._states[this._stateIdx] === UriIteratorState.Path && this._pathIterator.hasNext()) { + this._pathIterator.next(); + } else { + this._stateIdx += 1; + } + return this; + } + + hasNext(): boolean { + return ( + (this._states[this._stateIdx] === UriIteratorState.Path && this._pathIterator.hasNext()) || + this._stateIdx < this._states.length - 1 + ); + } + + cmp(a: string): number { + if (this._states[this._stateIdx] === UriIteratorState.Scheme) { + return compareIgnoreCase(a, this._value.scheme); + } else if (this._states[this._stateIdx] === UriIteratorState.Authority) { + return compareIgnoreCase(a, this._value.authority); + } else if (this._states[this._stateIdx] === UriIteratorState.Path) { + return this._pathIterator.cmp(a); + } else if (this._states[this._stateIdx] === UriIteratorState.Query) { + return compare(a, this._value.query); + } else if (this._states[this._stateIdx] === UriIteratorState.Fragment) { + return compare(a, this._value.fragment); + } + throw new Error(); + } + + value(): string { + if (this._states[this._stateIdx] === UriIteratorState.Scheme) { + return this._value.scheme; + } else if (this._states[this._stateIdx] === UriIteratorState.Authority) { + return this._value.authority; + } else if (this._states[this._stateIdx] === UriIteratorState.Path) { + return this._pathIterator.value(); + } else if (this._states[this._stateIdx] === UriIteratorState.Query) { + return this._value.query; + } else if (this._states[this._stateIdx] === UriIteratorState.Fragment) { + return this._value.fragment; + } + throw new Error(); + } } -class TernarySearchTreeNode { - segment: string; - value: E | undefined; - key: string; - left: TernarySearchTreeNode | undefined; - mid: TernarySearchTreeNode | undefined; - right: TernarySearchTreeNode | undefined; +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; + key!: K; + left: TernarySearchTreeNode | undefined; + mid: TernarySearchTreeNode | undefined; + right: TernarySearchTreeNode | undefined; isEmpty(): boolean { return !this.left && !this.mid && !this.right && !this.value; } } -export class TernarySearchTree { +export class TernarySearchTree { + static forUris(ignorePathCasing: (key: Uri) => boolean = () => false): TernarySearchTree { + return new TernarySearchTree(new UriIterator(ignorePathCasing)); + } + + static forPaths(): TernarySearchTree { + return new TernarySearchTree(new PathIterator()); + } + + static forStrings(): TernarySearchTree { + return new TernarySearchTree(new StringIterator()); + } - static forPaths(): TernarySearchTree { - return new TernarySearchTree(new PathIterator()); + static forConfigKeys(): TernarySearchTree { + return new TernarySearchTree(new ConfigKeysIterator()); } - private _iter: IKeyIterator; - private _root: TernarySearchTreeNode | undefined; + private _iter: IKeyIterator; + private _root: TernarySearchTreeNode | undefined; - constructor(segments: IKeyIterator) { + constructor(segments: IKeyIterator) { this._iter = segments; } @@ -327,12 +758,12 @@ export class TernarySearchTree { this._root = undefined; } - set(key: string, element: E): E | undefined { + set(key: K, element: V): V | undefined { const iter = this._iter.reset(key); - let node: TernarySearchTreeNode; + let node: TernarySearchTreeNode; if (!this._root) { - this._root = new TernarySearchTreeNode(); + this._root = new TernarySearchTreeNode(); this._root.segment = iter.value(); } @@ -342,23 +773,22 @@ export class TernarySearchTree { if (val > 0) { // left if (!node.left) { - node.left = new TernarySearchTreeNode(); + node.left = new TernarySearchTreeNode(); node.left.segment = iter.value(); } node = node.left; } else if (val < 0) { // right if (!node.right) { - node.right = new TernarySearchTreeNode(); + node.right = new TernarySearchTreeNode(); node.right.segment = iter.value(); } node = node.right; - } else if (iter.hasNext()) { // mid iter.next(); if (!node.mid) { - node.mid = new TernarySearchTreeNode(); + node.mid = new TernarySearchTreeNode(); node.mid.segment = iter.value(); } node = node.mid; @@ -372,7 +802,11 @@ export class TernarySearchTree { return oldElement; } - get(key: string): E | undefined { + get(key: K): V | undefined { + return this._getNode(key)?.value; + } + + private _getNode(key: K) { const iter = this._iter.reset(key); let node = this._root; while (node) { @@ -391,13 +825,25 @@ export class TernarySearchTree { break; } } - return node ? node.value : undefined; + return node; } - delete(key: string): void { + has(key: K): boolean { + const node = this._getNode(key); + return !(node?.value === undefined && node?.mid === undefined); + } + + delete(key: K): void { + return this._delete(key, false); + } + deleteSuperstr(key: K): void { + return this._delete(key, true); + } + + private _delete(key: K, superStr: boolean): void { const iter = this._iter.reset(key); - const stack: [-1 | 0 | 1, TernarySearchTreeNode][] = []; + const stack: [-1 | 0 | 1, TernarySearchTreeNode][] = []; let node = this._root; // find and unset node @@ -417,16 +863,29 @@ export class TernarySearchTree { stack.push([0, node]); node = node.mid; } else { - // remove element - node.value = undefined; + if (superStr) { + // remove children + node.left = undefined; + node.mid = undefined; + node.right = undefined; + } else { + // remove element + node.value = undefined; + } // clean up empty nodes while (stack.length > 0 && node.isEmpty()) { - const [dir, parent] = stack.pop()!; + let [dir, parent] = stack.pop()!; switch (dir) { - case 1: parent.left = undefined; break; - case 0: parent.mid = undefined; break; - case -1: parent.right = undefined; break; + case 1: + parent.left = undefined; + break; + case 0: + parent.mid = undefined; + break; + case -1: + parent.right = undefined; + break; } node = parent; } @@ -435,10 +894,10 @@ export class TernarySearchTree { } } - findSubstr(key: string): E | undefined { + findSubstr(key: K): V | undefined { const iter = this._iter.reset(key); let node = this._root; - let candidate: E | undefined = undefined; + let candidate: V | undefined = undefined; while (node) { const val = iter.cmp(node.segment); if (val > 0) { @@ -456,10 +915,10 @@ export class TernarySearchTree { break; } } - return node && node.value || candidate; + return (node && node.value) || candidate; } - findSuperstr(key: string): Iterator | undefined { + findSuperstr(key: K): IterableIterator<[K, V]> | undefined { const iter = this._iter.reset(key); let node = this._root; while (node) { @@ -479,57 +938,254 @@ export class TernarySearchTree { if (!node.mid) { return undefined; } else { - return this._nodeIterator(node.mid); + return this._entries(node.mid); } } } return undefined; } - private _nodeIterator(node: TernarySearchTreeNode): Iterator { - let res: { done: false; value: E; }; - let idx: number; - let data: E[]; - const next = (): IteratorResult => { - if (!data) { - // lazy till first invocation - data = []; - idx = 0; - this._forEach(node, value => data.push(value)); - } - if (idx >= data.length) { - return { done: true, value: undefined }; - } - - if (!res) { - res = { done: false, value: data[idx++] }; - } else { - res.value = data[idx++]; - } - return res; - }; - return { next }; + forEach(callback: (value: V, index: K) => any): void { + for (const [key, value] of this) { + callback(value, key); + } } - forEach(callback: (value: E, index: string) => any) { - this._forEach(this._root, callback); + *[Symbol.iterator](): IterableIterator<[K, V]> { + yield* this._entries(this._root); } - private _forEach(node: TernarySearchTreeNode | undefined, callback: (value: E, index: string) => any) { + private *_entries(node: TernarySearchTreeNode | undefined): IterableIterator<[K, V]> { if (node) { // left - this._forEach(node.left, callback); + yield* this._entries(node.left); // node if (node.value) { // callback(node.value, this._iter.join(parts)); - callback(node.value, node.key); + yield [node.key, node.value]; } // mid - this._forEach(node.mid, callback); + yield* this._entries(node.mid); // right - this._forEach(node.right, callback); + yield* this._entries(node.right); + } + } +} + +export async function stringReplaceAsync(str: string, regex: RegExp, asyncFn: (substring: string, ...args: any[]) => Promise): Promise { + const promises: Promise[] = []; + str.replace(regex, (match, ...args) => { + const promise = asyncFn(match, ...args); + promises.push(promise); + return ''; + }); + const data = await Promise.all(promises); + 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 escapeHtmlAttr(value: string): string { + const escapedCharacters: Record = { + '&': '&', + '"': '"', + '\'': ''', + '<': '<', + '>': '>', + }; + + return value.replace(/[&"'<>]/g, (char) => escapedCharacters[char]); +} + +export function truncate(value: string, maxLength: number, suffix = '...'): string { + if (value.length <= maxLength) { + return value; + } + return `${value.substr(0, maxLength)}${suffix}`; +} + +/** + * Metadata extracted from code reference link data attributes. + * This interface defines the contract between the extension (which creates the attributes) + * and the webview (which reads them). + */ +export interface CodeReferenceLinkMetadata { + localFile: string; + startLine: number; + endLine: number; + linkType: 'blob' | 'diff'; + href: string; +} + +/** + * Extracts code reference link metadata from an anchor element's data attributes. + * Returns null if any required attributes are missing. + */ +export function extractCodeReferenceLinkMetadata(anchor: Element): CodeReferenceLinkMetadata | null { + const localFile = anchor.getAttribute('data-local-file'); + const startLine = anchor.getAttribute('data-start-line'); + const endLine = anchor.getAttribute('data-end-line'); + const linkType = anchor.getAttribute('data-link-type'); + const href = anchor.getAttribute('href'); + + if (!localFile || !startLine || !endLine || !linkType || !href) { + return null; + } + + return { + localFile, + startLine: parseInt(startLine), + endLine: parseInt(endLine), + linkType: linkType as 'blob' | 'diff', + href + }; +} + +/** + * Process GitHub blob permalinks in HTML and add data attributes for local file handling. + * Finds blob permalinks (e.g., /blob/[sha]/file.ts#L10), checks if files exist locally, + * and adds data attributes to enable clicking to open local files. + * Supports links from any repository owner to work across forks. + * + * @param bodyHTML - The HTML content to process + * @param repoName - GitHub repository name + * @param authority - Git protocol URL authority (e.g., 'github.com') + * @param fileExistsCheck - Async function that checks if a file exists locally given its relative path + * @returns Promise resolving to processed HTML + */ +export async function processPermalinks( + bodyHTML: string, + repoName: string, + authority: string, + fileExistsCheck: (filePath: string) => Promise +): Promise { + try { + const escapedRepoName = escapeRegExp(repoName); + const escapedAuthority = escapeRegExp(authority); + + // Process blob permalinks (exclude already processed links) + // Allow any owner to support links across forks + const blobPattern = new RegExp( + `]*data-permalink-processed)([^>]*?href="https?:\/\/${escapedAuthority}\/[^\/]+\/${escapedRepoName}\/blob\/[0-9a-f]{40}\/(?[^"#]+)#L(?\\d+)(?:-L(?\\d+))?"[^>]*?)>(?[^<]*?)<\/a>`, + 'g' + ); + + return await stringReplaceAsync(bodyHTML, blobPattern, async ( + fullMatch: string, + attributes: string, + filePath: string, + startLine: string, + endLine: string | undefined, + linkText: string + ) => { + try { + // Extract the original URL from attributes + const hrefMatch = attributes.match(/href="([^"]+)"/); + const originalUrl = hrefMatch ? hrefMatch[1] : ''; + + // Check if file exists locally + const exists = await fileExistsCheck(filePath); + if (exists) { + // File exists - add data attributes for local handling and "(view on GitHub)" suffix + const endLineValue = endLine || startLine; + const escapedFilePath = escapeHtmlAttr(filePath); + return `${linkText} (view on GitHub)`; + } + } catch (error) { + // File doesn't exist or check failed - keep original link + } + return fullMatch; + }); + } catch (error) { + // Return original HTML if processing fails + return bodyHTML; + } +} + +/** + * Process GitHub diff permalinks in HTML and add data attributes for local file handling. + * Finds diff permalinks (e.g., /pull/123/files#diff-[hash]R10), maps hashes to filenames, + * and adds data attributes to enable clicking to open diff views. + * + * @param bodyHTML - The HTML content to process + * @param repoOwner - GitHub repository owner + * @param repoName - GitHub repository name + * @param authority - Git protocol URL authority (e.g., 'github.com') + * @param hashMap - Map of diff hashes to file paths + * @param prNumber - Pull request number + * @returns Promise resolving to processed HTML + */ +export async function processDiffLinks( + bodyHTML: string, + repoOwner: string, + repoName: string, + authority: string, + hashMap: Record, + prNumber: number +): Promise { + try { + const escapedRepoName = escapeRegExp(repoName); + const escapedRepoOwner = escapeRegExp(repoOwner); + const escapedAuthority = escapeRegExp(authority); + + const diffPattern = new RegExp( + `]*data-permalink-processed)([^>]*?href="https?:\/\/${escapedAuthority}\/${escapedRepoOwner}\/${escapedRepoName}\/pull\/${prNumber}\/(?:files|changes)#diff-(?[a-f0-9]{64})(?:R(?\\d+)(?:-R(?\\d+))?)?"[^>]*?)>(?[^<]*?)<\/a>`, + 'g' + ); + + return await stringReplaceAsync(bodyHTML, diffPattern, async ( + fullMatch: string, + attributes: string, + diffHash: string, + startLine: string | undefined, + endLine: string | undefined, + linkText: string + ) => { + try { + // Extract the original URL from attributes + const hrefMatch = attributes.match(/href="([^"]+)"/); + const originalUrl = hrefMatch ? hrefMatch[1] : ''; + + // Look up filename from hash + const fileName = hashMap[diffHash]; + if (fileName) { + // Hash found - add data attributes for diff handling and "(view on GitHub)" suffix + const startLineValue = startLine || '1'; + const endLineValue = endLine || startLineValue; + const escapedFileName = escapeHtmlAttr(fileName); + return `${linkText} (view on GitHub)`; + } + } catch (error) { + // Failed to process - keep original link + } + return fullMatch; + }); + } catch (error) { + // Return original HTML if processing fails + return bodyHTML; + } } 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 new file mode 100644 index 0000000000..a41fab61e4 --- /dev/null +++ b/src/common/webview.ts @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * 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 './executeCommands'; +import { Disposable } from './lifecycle'; + +export const PULL_REQUEST_OVERVIEW_VIEW_TYPE = 'PullRequestOverview'; + +export interface IRequestMessage { + req: string; + command: string; + args: T; +} + +export interface IReplyMessage { + seq?: string; + err?: string; + // eslint-disable-next-line rulesdir/no-any-except-union-method-signature + res?: any; +} + +export class WebviewBase extends Disposable { + protected _webview?: vscode.Webview; + + private _waitForReady: Promise; + 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(); + resolve(); + }); + }); + } + + public initialize(): void { + const disposable = this._webview?.onDidReceiveMessage( + async message => { + await this._onDidReceiveMessage(message as IRequestMessage); + }); + if (disposable) { + this._register(disposable); + } + } + + protected async _onDidReceiveMessage(message: IRequestMessage): Promise { + switch (message.command) { + case 'ready': + this._onIsReady.fire(); + return; + default: + return this.MESSAGE_UNHANDLED; + } + } + + protected async _postMessage(message: any) { + // Without the following ready check, we can end up in a state where the message handler in the webview + // isn't ready for any of the messages we post. + await this._waitForReady; + this._webview?.postMessage({ + res: message, + }); + } + + protected async _replyMessage(originalMessage: IRequestMessage, message: any) { + const reply: IReplyMessage = { + seq: originalMessage.req, + res: message, + }; + await this._waitForReady; + this._webview?.postMessage(reply); + } + + protected async _throwError(originalMessage: IRequestMessage | undefined, error: string) { + const reply: IReplyMessage = { + seq: originalMessage?.req, + err: error, + }; + await this._waitForReady; + this._webview?.postMessage(reply); + } +} + +export class WebviewViewBase extends WebviewBase { + public readonly viewType: string; + protected _view?: vscode.WebviewView; + + constructor( + protected readonly _extensionUri: vscode.Uri) { + super(); + } + + protected resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken) { + this._view = webviewView; + this._webview = webviewView.webview; + super.initialize(); + webviewView.webview.options = { + // Allow scripts in the webview + enableScripts: true, + + localResourceRoots: [this._extensionUri], + }; + this._register(this._view.onDidDispose(() => { + this._webview = undefined; + this._view = undefined; + })); + } + + public show() { + if (this._view) { + this._view.show(); + } else { + commands.focusView(this.viewType); + } + } +} diff --git a/src/constants.ts b/src/constants.ts index 5c8f546d38..9a22a865f9 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,10 +1,11 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export const EXTENSION_ID = 'GitHub.vscode-pull-request-github'; -export const VSLS_REQUEST_NAME = 'git'; -export const VSLS_GIT_PR_SESSION_NAME = 'ghpr'; -export const VSLS_REPOSITORY_INITIALIZATION_NAME = 'initialize'; -export const VSLS_STATE_CHANGE_NOFITY_NAME = 'statechange'; \ 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. + *--------------------------------------------------------------------------------------------*/ + +export const EXTENSION_ID = 'GitHub.vscode-pull-request-github'; +export const VSLS_REQUEST_NAME = 'git'; +export const VSLS_GIT_PR_SESSION_NAME = 'ghpr'; +export const VSLS_REPOSITORY_INITIALIZATION_NAME = 'initialize'; +export const VSLS_STATE_CHANGE_NOTIFY_NAME = 'statechange'; +export const FOCUS_REVIEW_MODE = 'github:focusedReview'; diff --git a/src/env/browser/net.ts b/src/env/browser/net.ts new file mode 100644 index 0000000000..39baea382f --- /dev/null +++ b/src/env/browser/net.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. + *--------------------------------------------------------------------------------------------*/ + +export const agent = undefined; diff --git a/src/common/ssh.ts b/src/env/browser/ssh.ts similarity index 58% rename from src/common/ssh.ts rename to src/env/browser/ssh.ts index 343cc2e765..511b33ac45 100644 --- a/src/common/ssh.ts +++ b/src/env/browser/ssh.ts @@ -1,28 +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 { parse as parseConfig } from 'ssh-config'; -import { readFileSync } from 'fs'; -import { join } from 'path'; -import { homedir } from 'os'; -import Logger from './logger'; +import Logger from '../../common/logger'; const SSH_URL_RE = /^(?:([^@:]+)@)?([^:/]+):?(.+)$/; -const URL_SCHEME_RE = /^([a-z-]+):\/\//; +const URL_SCHEME_RE = /^([a-z-+]+):\/\//; -/** - * SSH Config interface - * - * Note that this interface atypically capitalizes field names. This is for consistency - * with SSH config files. - */ -export interface Config { - Host: string; - [param: string]: string; -} - -/** - * ConfigResolvers take a config, resolve some additional data (perhaps using - * a config file), and return a new Config. - */ -export type ConfigResolver = (config: Config) => Config; +export const sshParse = (url: string): Config | undefined => { + const urlMatch = URL_SCHEME_RE.exec(url); + if (urlMatch) { + const [fullSchemePrefix, scheme] = urlMatch; + if (scheme.includes('ssh')) { + url = url.slice(fullSchemePrefix.length); + } else { + return; + } + } + const match = SSH_URL_RE.exec(url); + if (!match) { + return; + } + const [, User, Host, path] = match; + return { User, Host, path }; +}; /** * Parse and resolve an SSH url. Resolves host aliases using the configuration @@ -51,53 +53,51 @@ export type ConfigResolver = (config: Config) => Config; * @returns {Config} */ export const resolve = (url: string, resolveConfig = Resolvers.current) => { - const config = parse(url); + const config = sshParse(url); return config && resolveConfig(config); }; -export class Resolvers { - static default = chainResolvers( - baseResolver, - resolverFromConfigFile(), - ); - - static fromConfig(conf: string) { - return chainResolvers(baseResolver, resolverFromConfig(conf)); - } - - static current = Resolvers.default; -} - -const parse = (url: string): Config | undefined => { - const urlMatch = URL_SCHEME_RE.exec(url); - if (urlMatch) { - const [fullSchemePrefix, scheme] = urlMatch; - if (scheme === 'ssh') { - url = url.slice(fullSchemePrefix.length); - } else { - return; - } - } - const match = SSH_URL_RE.exec(url); - if (!match) { return; } - const [, User, Host, path] = match; - return { User, Host, path }; -}; - -function baseResolver(config: Config) { +export function baseResolver(config: Config): Config { return { ...config, Hostname: config.Host, }; } -function resolverFromConfigFile(configPath = join(homedir(), '.ssh', 'config')): ConfigResolver | undefined { - try { - const config = readFileSync(configPath).toString(); - return resolverFromConfig(config); - } catch (error) { - Logger.appendLine(`${configPath}: ${error.message}`); - } +/** + * SSH Config interface + * + * Note that this interface atypically capitalizes field names. This is for consistency + * with SSH config files. + */ +export interface Config { + Host: string; + [param: string]: string; +} + +/** + * ConfigResolvers take a config, resolve some additional data (perhaps using + * a config file), and return a new Config. + */ +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) => { + 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 { @@ -105,11 +105,12 @@ export function resolverFromConfig(text: string): ConfigResolver { return h => config.compute(h.Host); } -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); +export class Resolvers { + static default = baseResolver; + + static fromConfig(conf: string) { + return chainResolvers(baseResolver, resolverFromConfig(conf)); + } + + static current = Resolvers.default; } diff --git a/src/common/net.ts b/src/env/node/net.ts similarity index 57% rename from src/common/net.ts rename to src/env/node/net.ts index 00b39fe85e..ec1f6fd5ea 100644 --- a/src/common/net.ts +++ b/src/env/node/net.ts @@ -1,7 +1,12 @@ -import { window } from 'vscode'; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { Agent, globalAgent } from 'https'; import { URL } from 'url'; import { httpsOverHttp } from 'tunnel'; +import { l10n, window } from 'vscode'; export const agent = getAgent(); @@ -13,13 +18,15 @@ export const agent = getAgent(); * @returns {https.Agent} */ function getAgent(url: string | undefined = process.env.HTTPS_PROXY): Agent { - if (!url) { return globalAgent; } + if (!url) { + return globalAgent; + } try { const { hostname, port, username, password } = new URL(url); const auth = username && password && `${username}:${password}`; return httpsOverHttp({ proxy: { host: hostname, port, proxyAuth: auth } }); } catch (e) { - window.showErrorMessage(`HTTPS_PROXY environment variable ignored: ${e.message}`); + window.showErrorMessage(l10n.t('HTTPS_PROXY environment variable ignored: {0}', (e as Error).message)); return globalAgent; } } diff --git a/src/env/node/ssh.ts b/src/env/node/ssh.ts new file mode 100644 index 0000000000..848b63085b --- /dev/null +++ b/src/env/node/ssh.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { readFileSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import Logger from '../../common/logger'; +import { baseResolver, chainResolvers, ConfigResolver, resolverFromConfig, sshParse } from '../browser/ssh'; + +export class Resolvers { + static default = chainResolvers(baseResolver, resolverFromConfigFile()); + + static fromConfig(conf: string) { + return chainResolvers(baseResolver, resolverFromConfig(conf)); + } + + static current = Resolvers.default; +} + +/** + * Parse and resolve an SSH url. Resolves host aliases using the configuration + * specified by ~/.ssh/config, if present. + * + * Examples: + * + * resolve("git@github.com:Microsoft/vscode") + * { + * Host: 'github.com', + * HostName: 'github.com', + * User: 'git', + * path: 'Microsoft/vscode', + * } + * + * resolve("hub:queerviolet/vscode", resolverFromConfig("Host hub\n HostName github.com\n User git\n")) + * { + * Host: 'hub', + * HostName: 'github.com', + * User: 'git', + * path: 'queerviolet/vscode', + * } + * + * @param {string} url the url to parse + * @param {ConfigResolver?} resolveConfig ssh config resolver (default: from ~/.ssh/config) + * @returns {Config} + */ +export const resolve = (url: string, resolveConfig = Resolvers.current) => { + const config = sshParse(url); + return config && resolveConfig(config); +}; + +function resolverFromConfigFile(configPath = join(homedir(), '.ssh', 'config')): ConfigResolver | undefined { + try { + const config = readFileSync(configPath).toString(); + return resolverFromConfig(config); + } catch (error) { + Logger.warn(`${configPath}: ${error.message}`); + } +} diff --git a/src/experimentationService.ts b/src/experimentationService.ts new file mode 100644 index 0000000000..9ffc1f4f34 --- /dev/null +++ b/src/experimentationService.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { + getExperimentationService, + IExperimentationService, + IExperimentationTelemetry, + TargetPopulation, +} from 'vscode-tas-client'; +import { Disposable } from './common/lifecycle'; + +/* __GDPR__ + "query-expfeature" : { + "ABExp.queriedFeature": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } +*/ + +export class ExperimentationTelemetry extends Disposable implements IExperimentationTelemetry { + private sharedProperties: Record = {}; + + constructor(private baseReporter: TelemetryReporter | undefined) { + super(); + if (baseReporter) { + this._register(baseReporter); + } + } + + sendTelemetryEvent(eventName: string, properties?: Record, measurements?: Record) { + this.baseReporter?.sendTelemetryEvent( + eventName, + { + ...this.sharedProperties, + ...properties, + }, + measurements, + ); + } + + sendTelemetryErrorEvent( + eventName: string, + properties?: Record, + _measurements?: Record, + ) { + this.baseReporter?.sendTelemetryErrorEvent(eventName, { + ...this.sharedProperties, + ...properties, + }); + } + + setSharedProperty(name: string, value: string): void { + this.sharedProperties[name] = value; + } + + postEvent(eventName: string, props: Map): void { + const event: Record = {}; + for (const [key, value] of props) { + event[key] = value; + } + this.sendTelemetryEvent(eventName, event); + } +} + +function getTargetPopulation(): TargetPopulation { + switch (vscode.env.uriScheme) { + case 'vscode': + return TargetPopulation.Public; + case 'vscode-insiders': + return TargetPopulation.Insiders; + case 'vscode-exploration': + return TargetPopulation.Internal; + case 'code-oss': + return TargetPopulation.Team; + default: + return TargetPopulation.Public; + } +} + +class NullExperimentationService implements IExperimentationService { + readonly initializePromise: Promise = Promise.resolve(); + readonly initialFetch: Promise = Promise.resolve(); + + isFlightEnabled(_flight: string): boolean { + return false; + } + + isCachedFlightEnabled(_flight: string): Promise { + return Promise.resolve(false); + } + + isFlightEnabledAsync(_flight: string): Promise { + return Promise.resolve(false); + } + + getTreatmentVariable(_configId: string, _name: string): T | undefined { + return undefined; + } + + getTreatmentVariableAsync( + _configId: string, + _name: string, + ): Promise { + return Promise.resolve(undefined); + } +} + +export async function createExperimentationService( + context: vscode.ExtensionContext, + experimentationTelemetry: ExperimentationTelemetry, +): Promise { + const id = context.extension.id; + const name = context.extension.packageJSON['name']; + 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, + ) + : new NullExperimentationService(); +} diff --git a/src/extension.ts b/src/extension.ts index 33c879c056..d3a4c65a38 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,143 +1,511 @@ -/*--------------------------------------------------------------------------------------------- - * 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 TelemetryReporter from 'vscode-extension-telemetry'; -import { Repository } from './api/api'; -import { ApiImpl } from './api/api1'; -import { registerCommands } from './commands'; -import Logger from './common/logger'; -import { Resource } from './common/resources'; -import { handler as uriHandler } from './common/uri'; -import { onceEvent } from './common/utils'; -import * as PersistentState from './common/persistentState'; -import { EXTENSION_ID } from './constants'; -import { FolderRepositoryManager } from './github/folderRepositoryManager'; -import { registerBuiltinGitProvider, registerLiveShareGitProvider } from './gitProviders/api'; -import { FileTypeDecorationProvider } from './view/fileTypeDecorationProvider'; -import { PullRequestsTreeDataProvider } from './view/prsTreeDataProvider'; -import { ReviewManager } from './view/reviewManager'; -import { IssueFeatureRegistrar } from './issues/issueFeatureRegistrar'; -import { CredentialStore } from './github/credentials'; -import { GitExtension, GitAPI } from './typings/git'; -import { GitHubContactServiceProvider } from './gitProviders/GitHubContactServiceProvider'; -import { LiveShare } from 'vsls/vscode.js'; -import { RepositoriesManager } from './github/repositoriesManager'; -import { PullRequestChangesTreeDataProvider } from './view/prChangesTreeDataProvider'; -import { ReviewsManager } from './view/reviewsManager'; - -const aiKey: string = 'AIF-d9b70cd4-b9f9-4d70-929b-a071c400b217'; - -// fetch.promise polyfill -const fetch = require('node-fetch'); -const PolyfillPromise = require('es6-promise').Promise; -fetch.Promise = PolyfillPromise; - -let telemetry: TelemetryReporter; - -async function init(context: vscode.ExtensionContext, git: ApiImpl, gitAPI: GitAPI, credentialStore: CredentialStore, repositories: Repository[], tree: PullRequestsTreeDataProvider, liveshareApiPromise: Promise): 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()); - } - } - }); - - context.subscriptions.push(vscode.window.registerUriHandler(uriHandler)); - context.subscriptions.push(new FileTypeDecorationProvider()); - - const folderManagers = repositories.map(repository => new FolderRepositoryManager(repository, telemetry, git, credentialStore)); - context.subscriptions.push(...folderManagers); - const reposManager = new RepositoriesManager(folderManagers, credentialStore, telemetry); - context.subscriptions.push(reposManager); - - 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); - context.subscriptions.push(changesTree); - const reviewManagers = folderManagers.map(folderManager => new ReviewManager(folderManager.repository, folderManager, telemetry, changesTree)); - const reviewsManager = new ReviewsManager(context, reposManager, reviewManagers, tree, changesTree, telemetry, gitAPI); - context.subscriptions.push(reviewsManager); - tree.initialize(reposManager); - registerCommands(context, reposManager, reviewManagers, telemetry, credentialStore, tree); - - git.onDidChangeState(() => { - reviewManagers.forEach(reviewManager => reviewManager.updateState()); - }); - - git.onDidOpenRepository(repo => { - const disposable = repo.state.onDidChange(() => { - const newFolderManager = new FolderRepositoryManager(repo, telemetry, git, credentialStore); - reposManager.folderManagers.push(newFolderManager); - const newReviewManager = new ReviewManager(newFolderManager.repository, newFolderManager, telemetry, changesTree); - reviewManagers.push(newReviewManager); - tree.refresh(); - disposable.dispose(); - }); - }); - - await vscode.commands.executeCommand('setContext', 'github:initialized', true); - const issuesFeatures = new IssueFeatureRegistrar(gitAPI, reposManager, reviewManagers, context, telemetry); - context.subscriptions.push(issuesFeatures); - await issuesFeatures.initialize(); - - /* __GDPR__ - "startup" : {} - */ - telemetry.sendTelemetryEvent('startup'); -} - -export async function activate(context: vscode.ExtensionContext): Promise { - // initialize resources - Resource.initialize(context); - const apiImpl = new ApiImpl(); - - const version = vscode.extensions.getExtension(EXTENSION_ID)!.packageJSON.version; - telemetry = new TelemetryReporter(EXTENSION_ID, version, aiKey); - context.subscriptions.push(telemetry); - - PersistentState.init(context); - const credentialStore = new CredentialStore(telemetry); - await credentialStore.initialize(); - - const gitExtension = vscode.extensions.getExtension('vscode.git')!.exports; - const gitAPI = gitExtension.getAPI(1); - - context.subscriptions.push(registerBuiltinGitProvider(apiImpl)); - const liveshareGitProvider = registerLiveShareGitProvider(apiImpl); - context.subscriptions.push(liveshareGitProvider); - const liveshareApiPromise = liveshareGitProvider.initialize(); - - context.subscriptions.push(apiImpl); - - Logger.appendLine('Looking for git repository'); - - const prTree = new PullRequestsTreeDataProvider(telemetry); - context.subscriptions.push(prTree); - - if (apiImpl.repositories.length > 0) { - await init(context, apiImpl, gitAPI, credentialStore, apiImpl.repositories, prTree, liveshareApiPromise); - } else { - onceEvent(apiImpl.onDidOpenRepository)(r => init(context, apiImpl, gitAPI, credentialStore, [r], prTree, liveshareApiPromise)); - } - - return apiImpl; -} - -export async function deactivate() { - if (telemetry) { - telemetry.dispose(); - } -} \ 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 { 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 { AUTO_REPO_DETECTION, AutoRepoDetectionVariants, 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 { PullRequestContextProvider, WorkspaceContextProvider } 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 { CheckRunLogContentProvider } from './view/checkRunLogContentProvider'; +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); + } + + const detectionMode = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(AUTO_REPO_DETECTION, 'workspace'); + const shouldFilterByWorkspace = detectionMode === 'workspace'; + + if (shouldFilterByWorkspace) { + 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; + } + } else { + Logger.debug(`Auto-detection is enabled for all Git repositories.`, ACTIVATION); + } + + 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); + context.subscriptions.push(issuesFeatures); + await issuesFeatures.initialize(); + + const workspaceContextProvider = new WorkspaceContextProvider(reposManager, git); + context.subscriptions.push(workspaceContextProvider); + context.subscriptions.push(vscode.chat.registerChatWorkspaceContextProvider('githubpr', workspaceContextProvider)); + workspaceContextProvider.initialize(); + const pullRequestContextProvider = new PullRequestContextProvider(prsTreeModel, reposManager, context); + context.subscriptions.push(pullRequestContextProvider); + context.subscriptions.push(vscode.chat.registerChatAttachContextProvider('githubpr', pullRequestContextProvider)); + context.subscriptions.push(vscode.chat.registerChatTabContextProvider({ uri: { scheme: 'webview-panel', pattern: '**/webview-PullRequestOverview**' } }, 'githubpr', pullRequestContextProvider)); + const issueContextProvider = new IssueContextProvider(issueStateManager, reposManager, context); + context.subscriptions.push(vscode.chat.registerChatAttachContextProvider('githubissue', issueContextProvider)); + context.subscriptions.push(vscode.chat.registerChatTabContextProvider({ uri: { scheme: 'webview-panel', pattern: '**/webview-IssueOverview**' } }, 'githubissue', issueContextProvider)); + + 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 chatEnabled = () => vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(EXPERIMENTAL_CHAT, false); + if (chatEnabled()) { + registerTools(context, credentialStore, reposManager); + } else { + initBasedOnSettingChange(PR_SETTINGS_NAMESPACE, EXPERIMENTAL_CHAT, chatEnabled, () => registerTools(context, credentialStore, reposManager), 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')) })); + context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(Schemes.CheckRunLog, new CheckRunLogContentProvider(reposManager))); + + 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 new file mode 100644 index 0000000000..4722bff657 --- /dev/null +++ b/src/extensionState.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 { IAccount } from './github/interface'; + +// Synced keys +// This key is deprecated in favor of the setting githubPullRequests.pullBranch. +export const NEVER_SHOW_PULL_NOTIFICATION = 'github.pullRequest.pullNotification.show'; + +// 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[]; + stateModifiedTime?: number; +} + +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 873eb79c11..88ee9cc940 100644 --- a/src/gitExtensionIntegration.ts +++ b/src/gitExtensionIntegration.ts @@ -3,9 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { RemoteSourceProvider, RemoteSource } from './typings/git'; -import { CredentialStore, GitHub } from './github/credentials'; +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; @@ -18,7 +20,7 @@ function repoResponseAsRemoteSource(raw: OctokitCommon.SearchReposResponseItem): return { name: `$(github) ${raw.full_name}`, description: raw.description || undefined, - url: raw.url + url: raw.url, }; } @@ -26,22 +28,25 @@ function asRemoteSource(raw: Repository): RemoteSource { return { name: `$(github) ${raw.full_name}`, description: raw.description || undefined, - url: raw.clone_url + url: raw.clone_url, }; } export class GithubRemoteSourceProvider implements RemoteSourceProvider { - - readonly name = 'GitHub'; + readonly name: string = 'GitHub'; readonly icon = 'github'; readonly supportsQuery = true; private userReposCache: RemoteSource[] = []; - constructor(private readonly credentialStore: CredentialStore) { } + constructor(private readonly credentialStore: CredentialStore, private readonly authProviderId: AuthProvider = AuthProvider.github) { + if (isEnterprise(authProviderId)) { + this.name = 'GitHub Enterprise'; + } + } async getRemoteSources(query?: string): Promise { - const hub = await this.credentialStore.getHubOrLogin(); + const hub = await this.credentialStore.getHubOrLogin(this.authProviderId); if (!hub) { throw new Error('Could not fetch repositories from GitHub.'); @@ -49,20 +54,17 @@ export class GithubRemoteSourceProvider implements RemoteSourceProvider { const [fromUser, fromQuery] = await Promise.all([ this.getUserRemoteSources(hub, query), - this.getQueryRemoteSources(hub, query) + this.getQueryRemoteSources(hub, query), ]); const userRepos = new Set(fromUser.map(r => r.name)); - return [ - ...fromUser, - ...fromQuery.filter(r => !userRepos.has(r.name)) - ]; + return [...fromUser, ...fromQuery.filter(r => !userRepos.has(r.name))]; } private async getUserRemoteSources(hub: GitHub, query?: string): Promise { if (!query) { - const res = await hub.octokit.repos.listForAuthenticatedUser({ sort: 'pushed', per_page: 100 }); + const res = await hub.octokit.call(hub.octokit.api.repos.listForAuthenticatedUser, { sort: 'pushed', per_page: 100 }); this.userReposCache = res.data.map(asRemoteSource); } @@ -74,7 +76,7 @@ export class GithubRemoteSourceProvider implements RemoteSourceProvider { return []; } - const raw = await hub.octokit.search.repos({ q: query, sort: 'updated' }); + const raw = await hub.octokit.call(hub.octokit.api.search.repos, { q: query, sort: 'updated' }); return raw.data.items.map(repoResponseAsRemoteSource); } -} \ No newline at end of file +} diff --git a/src/gitProviders/GitHubContactServiceProvider.ts b/src/gitProviders/GitHubContactServiceProvider.ts index 36f7203cc6..bc3973cfef 100644 --- a/src/gitProviders/GitHubContactServiceProvider.ts +++ b/src/gitProviders/GitHubContactServiceProvider.ts @@ -1,23 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { RepositoriesManager } from '../github/repositoriesManager'; import { IAccount } from '../github/interface'; +import { RepositoriesManager } from '../github/repositoriesManager'; /** * The liveshare contact service contract */ interface ContactServiceProvider { - requestAsync( - type: string, - parameters: Object, - cancellationToken?: vscode.CancellationToken) - : Promise; + requestAsync(type: string, parameters: Object, cancellationToken?: vscode.CancellationToken): Promise; readonly onNotified: vscode.Event; } interface NotifyContactServiceEventArgs { type: string; - body?: any | undefined; + body?: { contacts: Contact[]; exclusive?: boolean } | undefined; } /** @@ -48,9 +49,9 @@ export class GitHubContactServiceProvider implements ContactServiceProvider { public async requestAsync( type: string, _parameters: Object, - _cancellationToken?: vscode.CancellationToken) - : Promise { - let result = null; + _cancellationToken?: vscode.CancellationToken, + ): Promise { + let result: Object | null = null; switch (type) { case 'initialize': @@ -61,8 +62,8 @@ export class GitHubContactServiceProvider implements ContactServiceProvider { supportsInviteLink: false, supportsPresence: false, supportsContactPresenceRequest: false, - supportsPublishPresence: false - } + supportsPublishPresence: false, + }, }; // if we get initialized and users are available on the pr manager @@ -96,21 +97,24 @@ export class GitHubContactServiceProvider implements ContactServiceProvider { currentLoginUser = await this.getCurrentUserLogin(); } catch (e) { // If there are no GitHub repositories at the time of the above call, then we can get an error here. - // Since we don't care about the error and are just trying to nofity accounts and not responding to user action, + // Since we don't care about the error and are just trying to notify accounts and not responding to user action, // it is safe to ignore and leave currentLoginUser undefined. } if (currentLoginUser) { // Note: only suggest if the current user is part of the aggregated mentionable users if (accounts.findIndex(u => u.login === currentLoginUser) !== -1) { - this.notifySuggestedUsers(accounts - .filter(u => u.email) - .map(u => { - return { - id: u.login, - displayName: u.name ? u.name : u.login, - email: u.email - }; - }), true); + this.notifySuggestedUsers( + accounts + .filter(u => u.email) + .map(u => { + return { + id: u.login, + displayName: u.name ? u.name : u.login, + email: u.email, + }; + }), + true, + ); } } } @@ -121,24 +125,24 @@ export class GitHubContactServiceProvider implements ContactServiceProvider { } const origin = await this.pullRequestManager.folderManagers[0]?.getOrigin(); if (origin) { - const currentUser = origin.hub.currentUser; + const currentUser = origin.hub.currentUser ? await origin.hub.currentUser : undefined; if (currentUser) { return currentUser.login; } } } - private notify(type: string, body: any) { + private notify(type: string, body: { contacts: Contact[]; exclusive?: boolean }) { this.onNotifiedEmitter.fire({ type, - body + body, }); } private notifySuggestedUsers(contacts: Contact[], exclusive?: boolean) { this.notify('suggestedUsers', { contacts, - exclusive + exclusive, }); } -} \ No newline at end of file +} diff --git a/src/gitProviders/api.ts b/src/gitProviders/api.ts index 1ccc9cd979..cd275d7f93 100644 --- a/src/gitProviders/api.ts +++ b/src/gitProviders/api.ts @@ -1,20 +1,26 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { API } from '../api/api'; -import { LiveShareManager } from './vsls'; -import { BuiltinGitProvider } from './builtinGit'; - -export function registerBuiltinGitProvider(apiImpl: API): vscode.Disposable { - const builtInGitProvider = new BuiltinGitProvider(); - apiImpl.registerGitProvider(builtInGitProvider); - return builtInGitProvider; -} - -export function registerLiveShareGitProvider(apiImpl: API): LiveShareManager { - const liveShareManager = new LiveShareManager(apiImpl); - return liveShareManager; -} \ 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 * as vscode from 'vscode'; +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); + return liveShareManager; +} + +export async function registerBuiltinGitProvider( + _credentialStore: CredentialStore, + apiImpl: API, +): Promise { + const builtInGitProvider = await BuiltinGitProvider.createProvider(); + if (builtInGitProvider) { + apiImpl.registerGitProvider(builtInGitProvider); + return builtInGitProvider; + } +} diff --git a/src/gitProviders/builtinGit.ts b/src/gitProviders/builtinGit.ts index d0834dbf8c..bb6df80374 100644 --- a/src/gitProviders/builtinGit.ts +++ b/src/gitProviders/builtinGit.ts @@ -4,10 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { APIState, CloneOptions, GitAPI, GitExtension, PublishEvent } from '../@types/git'; import { IGit, Repository } from '../api/api'; -import { GitAPI, GitExtension, APIState } from '../typings/git'; +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[]; } @@ -22,20 +24,51 @@ export class BuiltinGitProvider implements IGit, vscode.Disposable { readonly onDidCloseRepository: vscode.Event = this._onDidCloseRepository.event; private _onDidChangeState = new vscode.EventEmitter(); readonly onDidChangeState: vscode.Event = this._onDidChangeState.event; + private _onDidPublish = new vscode.EventEmitter(); + readonly onDidPublish: vscode.Event = this._onDidPublish.event; - private _gitAPI: GitAPI; - private _disposables: vscode.Disposable[]; + private readonly _gitAPI: GitAPI; - constructor() { - const gitExtension = vscode.extensions.getExtension('vscode.git')!.exports; - this._gitAPI = gitExtension.getAPI(1); - 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))); + 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. + commands.setContext('gitNotInstalled', true); + throw 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 { + const extension = vscode.extensions.getExtension('vscode.git'); + if (extension) { + await extension.activate(); + return new BuiltinGitProvider(extension); + } + return undefined; } - dispose() { - this._disposables.forEach(disposable => disposable.dispose()); + registerPostCommitCommandsProvider?(provider: any): vscode.Disposable { + if (this._gitAPI.registerPostCommitCommandsProvider) { + return this._gitAPI.registerPostCommitCommandsProvider(provider); + } + return { + dispose: () => { } + }; } } diff --git a/src/gitProviders/gitCommands.ts b/src/gitProviders/gitCommands.ts new file mode 100644 index 0000000000..3110f7b42e --- /dev/null +++ b/src/gitProviders/gitCommands.ts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * 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 namespace git { + + export async function checkout(): Promise { + try { + await vscode.commands.executeCommand('git.checkout'); + } catch (e) { + await vscode.commands.executeCommand('remoteHub.switchToBranch'); + } + } + +} \ No newline at end of file diff --git a/src/gitProviders/vsls.ts b/src/gitProviders/vsls.ts index b82d941f74..a5eeee9c9d 100644 --- a/src/gitProviders/vsls.ts +++ b/src/gitProviders/vsls.ts @@ -1,92 +1,84 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { LiveShare } from 'vsls/vscode.js'; -import { VSLSGuest } from './vslsguest'; -import { VSLSHost } from './vslshost'; -import { API } from '../api/api'; - -/** - * Should be removed once we fix the webpack bundling issue. - */ -async function getVSLSApi() { - const liveshareExtension = vscode.extensions.getExtension('ms-vsliveshare.vsliveshare'); - if (!liveshareExtension) { - // The extension is not installed. - return null; - } - const extensionApi = liveshareExtension.isActive ? - liveshareExtension.exports : await liveshareExtension.activate(); - if (!extensionApi) { - // The extensibility API is not enabled. - return null; - } - const liveShareApiVersion = '0.3.967'; - // Support deprecated function name to preserve compatibility with older versions of VSLS. - if (!extensionApi.getApi) { - return extensionApi.getApiAsync(liveShareApiVersion); - } - return extensionApi.getApi(liveShareApiVersion); -} - -export class LiveShareManager implements vscode.Disposable { - private _liveShareAPI?: LiveShare; - private _host?: VSLSHost; - private _guest?: VSLSGuest; - private _localDisposables: vscode.Disposable[]; - private _globalDisposables: vscode.Disposable[]; - - constructor( - private _api: API - ) { - this._localDisposables = []; - this._globalDisposables = []; - } - - /** - * return the liveshare api if available - */ - public async initialize(): Promise { - if (!this._liveShareAPI) { - this._liveShareAPI = await getVSLSApi(); - } - - if (!this._liveShareAPI) { - return; - } - - this._globalDisposables.push(this._liveShareAPI.onDidChangeSession(e => this._onDidChangeSession(e.session), this)); - if (this._liveShareAPI!.session) { - this._onDidChangeSession(this._liveShareAPI!.session); - } - - return this._liveShareAPI; - } - - private async _onDidChangeSession(session: any) { - this._localDisposables.forEach(disposable => disposable.dispose()); - - if (session.role === 1 /* Role.Host */) { - this._host = new VSLSHost(this._liveShareAPI!, this._api); - this._localDisposables.push(this._host); - await this._host.initialize(); - return; - } - - if (session.role === 2 /* Role.Guest */) { - this._guest = new VSLSGuest(this._liveShareAPI!); - this._localDisposables.push(this._guest); - await this._guest.initialize(); - 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()); - } -} \ 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 * as vscode from 'vscode'; + +import { LiveShare } from 'vsls/vscode.js'; +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. + */ +async function getVSLSApi() { + const liveshareExtension = vscode.extensions.getExtension('ms-vsliveshare.vsliveshare'); + if (!liveshareExtension) { + // The extension is not installed. + return null; + } + const extensionApi = liveshareExtension.isActive ? liveshareExtension.exports : await liveshareExtension.activate(); + if (!extensionApi) { + // The extensibility API is not enabled. + return null; + } + const liveShareApiVersion = '0.3.967'; + // Support deprecated function name to preserve compatibility with older versions of VSLS. + if (!extensionApi.getApi) { + return extensionApi.getApiAsync(liveShareApiVersion); + } + return extensionApi.getApi(liveShareApiVersion); +} + +export class LiveShareManager extends Disposable { + private _liveShareAPI?: LiveShare; + private _host?: VSLSHost; + private _guest?: VSLSGuest; + private readonly _localDisposables: vscode.Disposable[] = []; + + constructor(private readonly _api: API) { + super(); + this._register({ dispose: () => disposeAll(this._localDisposables) }); + } + + /** + * return the liveshare api if available + */ + public async initialize(): Promise { + if (!this._liveShareAPI) { + this._liveShareAPI = await getVSLSApi(); + } + + if (!this._liveShareAPI) { + return; + } + + this._register(this._liveShareAPI.onDidChangeSession(e => this._onDidChangeSession(e.session), this)); + if (this._liveShareAPI!.session) { + this._onDidChangeSession(this._liveShareAPI!.session); + } + + return this._liveShareAPI; + } + + private async _onDidChangeSession(session: any) { + disposeAll(this._localDisposables); + + if (session.role === 1 /* Role.Host */) { + this._host = new VSLSHost(this._liveShareAPI!, this._api); + this._localDisposables.push(this._host); + await this._host.initialize(); + return; + } + + if (session.role === 2 /* Role.Guest */) { + this._guest = new VSLSGuest(this._liveShareAPI!); + this._localDisposables.push(this._guest); + await this._guest.initialize(); + this._localDisposables.push(this._api.registerGitProvider(this._guest)); + } + } +} diff --git a/src/gitProviders/vslsguest.ts b/src/gitProviders/vslsguest.ts index 5ea56238f5..aa4370d9be 100644 --- a/src/gitProviders/vslsguest.ts +++ b/src/gitProviders/vslsguest.ts @@ -1,174 +1,183 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { LiveShare, SharedServiceProxy } from 'vsls/vscode.js'; -import { VSLS_GIT_PR_SESSION_NAME, VSLS_REQUEST_NAME, VSLS_REPOSITORY_INITIALIZATION_NAME, VSLS_STATE_CHANGE_NOFITY_NAME } from '../constants'; -import { RepositoryState, Commit, Branch, Ref, Remote, Submodule, Change } from '../typings/git'; -import { Repository, IGit } from '../api/api'; - -export class VSLSGuest implements IGit, vscode.Disposable { - private _onDidOpenRepository = new vscode.EventEmitter(); - readonly onDidOpenRepository: vscode.Event = this._onDidOpenRepository.event; - private _onDidCloseRepository = new vscode.EventEmitter(); - readonly onDidCloseRepository: vscode.Event = this._onDidCloseRepository.event; - private _openRepositories: Repository[] = []; - get repositories(): Repository[] { - return this._openRepositories; - } - - private _sharedServiceProxy?: SharedServiceProxy; - private _disposables: vscode.Disposable[]; - constructor(private _liveShareAPI: LiveShare) { - this._disposables = []; - } - - public async initialize() { - this._sharedServiceProxy = await this._liveShareAPI.getSharedService(VSLS_GIT_PR_SESSION_NAME) || undefined; - - if (!this._sharedServiceProxy) { - return; - } - - 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))); - } - - private async _onDidChangeWorkspaceFolders(e: vscode.WorkspaceFoldersChangeEvent) { - e.added.forEach(async folder => { - if (folder.uri.scheme === 'vsls' && this._sharedServiceProxy && this._sharedServiceProxy.isServiceAvailable) { - await this.openVSLSRepository(folder); - } - }); - - e.removed.forEach(async folder => { - if (folder.uri.scheme === 'vsls' && this._sharedServiceProxy && this._sharedServiceProxy.isServiceAvailable) { - await this.closeVSLSRepository(folder); - } - }); - } - - private async _refreshWorkspaces(available: boolean) { - if (vscode.workspace.workspaceFolders) { - vscode.workspace.workspaceFolders.forEach(async (folder) => { - if (folder.uri.scheme === 'vsls') { - if (available) { - await this.openVSLSRepository(folder); - } else { - await this.closeVSLSRepository(folder); - } - } - }); - } - } - - public async openVSLSRepository(folder: vscode.WorkspaceFolder): Promise { - const existingRepository = this.getRepository(folder); - if (existingRepository) { - return; - } - const liveShareRepository = new LiveShareRepository(folder, this._sharedServiceProxy!); - const repositoryProxyHandler = new LiveShareRepositoryProxyHandler(); - const repository = new Proxy(liveShareRepository, repositoryProxyHandler); - await repository.initialize(); - this.openRepository(repository); - } - - public async closeVSLSRepository(folder: vscode.WorkspaceFolder): Promise { - const existingRepository = this.getRepository(folder); - if (!existingRepository) { - return; - } - - this.closeRepository(existingRepository); - } - - public openRepository(repository: Repository) { - this._openRepositories.push(repository); - this._onDidOpenRepository.fire(repository); - } - - public closeRepository(repository: Repository) { - this._openRepositories = this._openRepositories.filter(e => e !== repository); - this._onDidCloseRepository.fire(repository); - } - - 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 = []; - } -} - -class LiveShareRepositoryProxyHandler { - constructor() { } - - get(obj: any, prop: any) { - if (prop in obj) { - return obj[prop]; - } - - return function () { - return obj.proxy.request(VSLS_REQUEST_NAME, [prop, obj.workspaceFolder.uri.toString(), ...arguments]); - }; - } -} - -class LiveShareRepositoryState implements RepositoryState { - HEAD: Branch | undefined; - refs: Ref[]; - remotes: Remote[]; - submodules: Submodule[]; - rebaseCommit: Commit; - mergeChanges: Change[]; - indexChanges: Change[]; - workingTreeChanges: Change[]; - _onDidChange = new vscode.EventEmitter(); - onDidChange = this._onDidChange.event; - - constructor(state: RepositoryState) { - this.HEAD = state.HEAD; - this.remotes = state.remotes; - this.refs = state.refs; - } - - public update(state: RepositoryState) { - this.HEAD = state.HEAD; - this.remotes = state.remotes; - this.refs = state.refs; - - this._onDidChange.fire(); - } -} - -class LiveShareRepository { - rootUri: vscode.Uri; - state: LiveShareRepositoryState; - - constructor( - public workspaceFolder: vscode.WorkspaceFolder, - public proxy: SharedServiceProxy - ) { } - - public async initialize() { - const result = await this.proxy.request(VSLS_REQUEST_NAME, [VSLS_REPOSITORY_INITIALIZATION_NAME, this.workspaceFolder.uri.toString()]); - this.state = new LiveShareRepositoryState(result); - this.rootUri = vscode.Uri.parse(result.rootUri); - this.proxy.onNotify(VSLS_STATE_CHANGE_NOFITY_NAME, this._notifyHandler.bind(this)); - } - - private _notifyHandler(args: any) { - this.state.update(args); - } -} +/*--------------------------------------------------------------------------------------------- + * 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 { LiveShare, SharedServiceProxy } from 'vsls/vscode.js'; +import { Branch, Change, Commit, Ref, Remote, RepositoryState, Submodule, Worktree } from '../@types/git'; +import { IGit, Repository } from '../api/api'; +import { Disposable } from '../common/lifecycle'; +import { + VSLS_GIT_PR_SESSION_NAME, + VSLS_REPOSITORY_INITIALIZATION_NAME, + VSLS_REQUEST_NAME, + VSLS_STATE_CHANGE_NOTIFY_NAME, +} from '../constants'; + +export class VSLSGuest extends Disposable implements IGit { + private _onDidOpenRepository = this._register(new vscode.EventEmitter()); + readonly onDidOpenRepository: vscode.Event = this._onDidOpenRepository.event; + private _onDidCloseRepository = this._register(new vscode.EventEmitter()); + readonly onDidCloseRepository: vscode.Event = this._onDidCloseRepository.event; + private _openRepositories: Repository[] = []; + get repositories(): Repository[] { + return this._openRepositories; + } + + private _sharedServiceProxy?: SharedServiceProxy; + constructor(private readonly _liveShareAPI: LiveShare) { + super(); + } + + public async initialize() { + this._sharedServiceProxy = (await this._liveShareAPI.getSharedService(VSLS_GIT_PR_SESSION_NAME)) || undefined; + + if (!this._sharedServiceProxy) { + return; + } + + if (this._sharedServiceProxy.isServiceAvailable) { + await this._refreshWorkspaces(true); + } + 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) { + e.added.forEach(async folder => { + if ( + folder.uri.scheme === 'vsls' && + this._sharedServiceProxy && + this._sharedServiceProxy.isServiceAvailable + ) { + await this.openVSLSRepository(folder); + } + }); + + e.removed.forEach(async folder => { + if ( + folder.uri.scheme === 'vsls' && + this._sharedServiceProxy && + this._sharedServiceProxy.isServiceAvailable + ) { + await this.closeVSLSRepository(folder); + } + }); + } + + private async _refreshWorkspaces(available: boolean) { + if (vscode.workspace.workspaceFolders) { + vscode.workspace.workspaceFolders.forEach(async folder => { + if (folder.uri.scheme === 'vsls') { + if (available) { + await this.openVSLSRepository(folder); + } else { + await this.closeVSLSRepository(folder); + } + } + }); + } + } + + public async openVSLSRepository(folder: vscode.WorkspaceFolder): Promise { + const existingRepository = this.getRepository(folder); + if (existingRepository) { + return; + } + const liveShareRepository = new LiveShareRepository(folder, this._sharedServiceProxy!); + const repositoryProxyHandler = new LiveShareRepositoryProxyHandler(); + const repository = new Proxy(liveShareRepository, repositoryProxyHandler); + await repository.initialize(); + this.openRepository(repository); + } + + public async closeVSLSRepository(folder: vscode.WorkspaceFolder): Promise { + const existingRepository = this.getRepository(folder); + if (!existingRepository) { + return; + } + + this.closeRepository(existingRepository); + } + + public openRepository(repository: Repository) { + this._openRepositories.push(repository); + this._onDidOpenRepository.fire(repository); + } + + public closeRepository(repository: Repository) { + this._openRepositories = this._openRepositories.filter(e => e !== repository); + this._onDidCloseRepository.fire(repository); + } + + public getRepository(folder: vscode.WorkspaceFolder): Repository { + return this._openRepositories.filter(repository => (repository as (Repository & { workspaceFolder: vscode.WorkspaceFolder })).workspaceFolder === folder)[0]; + } +} + +class LiveShareRepositoryProxyHandler { + constructor() { } + + get(obj: any, prop: any) { + // eslint-disable-next-line no-restricted-syntax + if (prop in obj) { + return obj[prop]; + } + + return function (...args: any[]) { + return obj.proxy.request(VSLS_REQUEST_NAME, [prop, obj.workspaceFolder.uri.toString(), ...args]); + }; + } +} + +class LiveShareRepositoryState implements RepositoryState { + HEAD: Branch | undefined; + remotes: Remote[]; + submodules: Submodule[] = []; + worktrees: Worktree[] = []; + rebaseCommit: Commit | undefined; + mergeChanges: Change[] = []; + indexChanges: Change[] = []; + workingTreeChanges: Change[] = []; + _onDidChange = new vscode.EventEmitter(); + onDidChange = this._onDidChange.event; + + constructor(state: RepositoryState) { + this.HEAD = state.HEAD; + this.remotes = state.remotes; + } + refs: Ref[] = []; + untrackedChanges: Change[] = []; + + public update(state: RepositoryState) { + this.HEAD = state.HEAD; + this.remotes = state.remotes; + + this._onDidChange.fire(); + } +} + +class LiveShareRepository { + rootUri: vscode.Uri | undefined; + state: LiveShareRepositoryState | undefined; + + constructor(public workspaceFolder: vscode.WorkspaceFolder, public proxy: SharedServiceProxy) { } + + public async initialize() { + const result = await this.proxy.request(VSLS_REQUEST_NAME, [ + VSLS_REPOSITORY_INITIALIZATION_NAME, + this.workspaceFolder.uri.toString(), + ]); + this.state = new LiveShareRepositoryState(result); + this.rootUri = vscode.Uri.parse(result.rootUri); + this.proxy.onNotify(VSLS_STATE_CHANGE_NOTIFY_NAME, this._notifyHandler.bind(this)); + } + + private _notifyHandler(args: any) { + this.state?.update(args); + } +} diff --git a/src/gitProviders/vslshost.ts b/src/gitProviders/vslshost.ts index 6839237b30..0cbb235761 100644 --- a/src/gitProviders/vslshost.ts +++ b/src/gitProviders/vslshost.ts @@ -1,76 +1,82 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { LiveShare, SharedService } from 'vsls/vscode.js'; -import { VSLS_GIT_PR_SESSION_NAME, VSLS_REQUEST_NAME, VSLS_REPOSITORY_INITIALIZATION_NAME, VSLS_STATE_CHANGE_NOFITY_NAME } from '../constants'; -import { API } from '../api/api'; -export class VSLSHost implements vscode.Disposable { - private _sharedService?: SharedService; - private _disposables: vscode.Disposable[]; - constructor(private _liveShareAPI: LiveShare, private _api: API) { - this._disposables = []; - } - - public async initialize() { - this._sharedService = await this._liveShareAPI!.shareService(VSLS_GIT_PR_SESSION_NAME) || undefined; - - if (this._sharedService) { - this._sharedService.onRequest(VSLS_REQUEST_NAME, this._gitHandler.bind(this)); - } - } - - private async _gitHandler(args: any[]) { - const type = args[0]; - const workspaceFolderPath = args[1]; - const workspaceFolderUri = vscode.Uri.parse(workspaceFolderPath); - const localWorkSpaceFolderUri = this._liveShareAPI.convertSharedUriToLocal(workspaceFolderUri); - const gitProvider = this._api.getGitProvider(localWorkSpaceFolderUri); - - if (!gitProvider) { - return; - } - - const localRepository: any = gitProvider.repositories.filter(repository => repository.rootUri.toString() === localWorkSpaceFolderUri.toString())[0]; - if (localRepository) { - const commandArgs = args.slice(2); - if (type === VSLS_REPOSITORY_INITIALIZATION_NAME) { - this._disposables.push(localRepository.state.onDidChange((e: any) => { - this._sharedService!.notify(VSLS_STATE_CHANGE_NOFITY_NAME, { - HEAD: localRepository.state.HEAD, - remotes: localRepository.state.remotes, - refs: localRepository.state.refs - }); - })); - return { - HEAD: localRepository.state.HEAD, - remotes: localRepository.state.remotes, - refs: localRepository.state.refs, - rootUri: workspaceFolderUri.toString() // file: --> vsls:/ - }; - } - - if (type === 'show') { - const path = commandArgs[1]; - const vslsFileUri = workspaceFolderUri.with({ path: path }); - const localFileUri = this._liveShareAPI.convertSharedUriToLocal(vslsFileUri); - commandArgs[1] = localFileUri.fsPath; - - return localRepository[type](...commandArgs); - } - - if (localRepository[type]) { - return localRepository[type](...commandArgs); - } - } else { - return null; - } - } - public dispose() { - this._disposables.forEach(d => d.dispose()); - this._sharedService = undefined; - this._disposables = []; - } -} +/*--------------------------------------------------------------------------------------------- + * 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 { 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, + VSLS_REQUEST_NAME, + VSLS_STATE_CHANGE_NOTIFY_NAME, +} from '../constants'; + +export class VSLSHost extends Disposable { + private _sharedService?: SharedService; + constructor(private readonly _liveShareAPI: LiveShare, private _api: API) { + super(); + } + + public async initialize() { + this._sharedService = (await this._liveShareAPI!.shareService(VSLS_GIT_PR_SESSION_NAME)) || undefined; + + if (this._sharedService) { + this._sharedService.onRequest(VSLS_REQUEST_NAME, this._gitHandler.bind(this)); + } + } + + private async _gitHandler(args: any[]) { + const type = args[0]; + const workspaceFolderPath = args[1]; + const workspaceFolderUri = vscode.Uri.parse(workspaceFolderPath); + const localWorkSpaceFolderUri = this._liveShareAPI.convertSharedUriToLocal(workspaceFolderUri); + const gitProvider = this._api.getGitProvider(localWorkSpaceFolderUri); + + if (!gitProvider) { + return; + } + + const localRepository: any = gitProvider.repositories.filter( + repository => repository.rootUri.toString() === localWorkSpaceFolderUri.toString(), + )[0]; + if (localRepository) { + const commandArgs = args.slice(2); + if (type === VSLS_REPOSITORY_INITIALIZATION_NAME) { + + 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, + refs: localRepository.state.refs, + rootUri: workspaceFolderUri.toString(), // file: --> vsls:/ + }; + } + + if (type === 'show') { + const path = commandArgs[1]; + const vslsFileUri = workspaceFolderUri.with({ path: path }); + const localFileUri = this._liveShareAPI.convertSharedUriToLocal(vslsFileUri); + commandArgs[1] = localFileUri.fsPath; + + return localRepository[type](...commandArgs); + } + + if (localRepository[type]) { + return localRepository[type](...commandArgs); + } + } else { + return null; + } + } +} diff --git a/src/github/activityBarViewProvider.ts b/src/github/activityBarViewProvider.ts new file mode 100644 index 0000000000..bf90fe05a0 --- /dev/null +++ b/src/github/activityBarViewProvider.ts @@ -0,0 +1,516 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { openPullRequestOnGitHub } from '../commands'; +import { addAttestationCommit, isAttestationCommitsEnabled } from './attestationCommit'; +import { FolderRepositoryManager } from './folderRepositoryManager'; +import { GithubItemStateEnum, IAccount, MergeMethod, ReviewEventEnum, ReviewState } from './interface'; +import { isCopilotOnMyBehalf, PullRequestModel } from './pullRequestModel'; +import { getDefaultMergeMethod } from './pullRequestOverview'; +import { PullRequestReviewCommon, ReviewContext } from './pullRequestReviewCommon'; +import { isInCodespaces, parseReviewers } from './utils'; +import { MergeArguments, PullRequest, ReviewType, SubmitReviewArgs } 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, TimelineEvent } from '../common/timelineEvent'; +import { formatError } from '../common/utils'; +import { generateUuid } from '../common/uuid'; +import { IRequestMessage, WebviewViewBase } from '../common/webview'; +import { ReviewManager } from '../view/reviewManager'; + +export class PullRequestViewProvider extends WebviewViewBase implements vscode.WebviewViewProvider { + public override readonly viewType = 'github:activePullRequest'; + private _existingReviewers: ReviewState[] = []; + private _updatingPromise: Promise | undefined; + + constructor( + extensionUri: vscode.Uri, + private readonly _folderRepositoryManager: FolderRepositoryManager, + private readonly _reviewManager: ReviewManager, + private _item: PullRequestModel, + ) { + super(extensionUri); + + 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 override resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ) { + super.resolveWebviewView(webviewView, _context, _token); + webviewView.webview.html = this._getHtmlForWebview(); + + this.updatePullRequest(this._item); + } + + 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; + } + + switch (message.command) { + case 'alert': + vscode.window.showErrorMessage(message.args); + return; + case 'pr.close': + return this.close(message); + case 'pr.comment': + return this.createComment(message); + case 'pr.merge': + return this.mergePullRequest(message); + case 'pr.open-create': + return this.create(); + case 'pr.deleteBranch': + return this.deleteBranch(message); + case 'pr.readyForReview': + return this.setReadyForReview(message); + case 'pr.approve': + return this.approvePullRequestMessage(message); + case 'pr.request-changes': + return this.requestChangesMessage(message); + case 'pr.submit': + return this.submitReviewMessage(message); + case 'pr.openOnGitHub': + 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); + case 'pr.add-attestation-commit': + return this.addAttestationCommitMessage(message); + } + } + + private async checkoutDefaultBranch(message: IRequestMessage): Promise { + return PullRequestReviewCommon.checkoutDefaultBranch(this.getReviewContext(), message); + } + + private reRequestReview(message: IRequestMessage): void { + return PullRequestReviewCommon.reRequestReview(this.getReviewContext(), message); + } + + private async addAttestationCommitMessage(message: IRequestMessage): Promise { + const result = await addAttestationCommit(this._folderRepositoryManager, this._item); + this._replyMessage(message, { success: !!result, sha: result?.sha }); + } + + public async refresh(): Promise { + 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) { + disposeAll(this._prDisposables); + } + this._prDisposables = []; + 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 { + 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; + } + + 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; + + if (!pullRequest) { + throw new Error( + `Fail to resolve Pull Request #${pullRequestModel.number} in ${pullRequestModel.remote.owner}/${pullRequestModel.remote.repositoryName}`, + ); + } + + 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), + attestationCommitsEnabled: isAttestationCommitsEnabled() + }; + + 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 { + vscode.commands.executeCommand('pr.close', this._item, message.args).then(comment => { + if (comment) { + this._replyMessage(message, { + value: comment, + }); + } + }); + } + + private create() { + this._reviewManager.createPullRequest(); + } + + private createComment(message: IRequestMessage) { + this._item.createIssueComment(message.args).then(comment => { + this._replyMessage(message, { + value: comment, + }); + }); + } + + + private async doReviewCommand(context: { body: string }, reviewType: ReviewType, action: (body: string) => Promise) { + return PullRequestReviewCommon.doReviewCommand( + this.getReviewContext(), + context, + reviewType, + false, + action + ); + } + + private async doReviewMessage(message: IRequestMessage, action: (body) => Promise, needsTimelineRefresh: boolean = false, additionalEvents?: TimelineEvent[]) { + return PullRequestReviewCommon.doReviewMessage( + this.getReviewContext(), + message, + needsTimelineRefresh, + action, + additionalEvents, + ); + } + + /** + * Handles a review-submission message that may include a request to first + * push a signed attestation commit. If attestation fails, the review is + * NOT submitted. + */ + private async doReviewMessageWithAttestation( + message: IRequestMessage, + action: (body: string, expectedRemoteHead: string | undefined) => Promise, + ): Promise { + const { body, addAttestation } = message.args ?? { body: '' }; + let attestationEvent: TimelineEvent | undefined; + let attestationSha: string | undefined; + if (addAttestation) { + const result = await addAttestationCommit(this._folderRepositoryManager, this._item); + if (!result) { + this._throwError(message, 'Attestation commit failed; review was not submitted.'); + return; + } + attestationEvent = result.event; + attestationSha = result.sha; + } + const forwarded: IRequestMessage = { req: message.req, command: message.command, args: body }; + // Skip the timeline refetch when we have the attestation commit locally; + // splice it into the webview state via `additionalEvents` instead. + // Pass the attestation sha as the expected remote head so the approve + // check waits for GitHub's PR API to catch up to *our* commit (a + // concurrent third-party push would still be rejected). + await this.doReviewMessage(forwarded, (b) => action(b, attestationSha), false, attestationEvent ? [attestationEvent] : undefined); + } + + private approvePullRequest(body: string, expectedRemoteHead?: string): Promise { + return this._item.approve(this._folderRepositoryManager.repository, body, expectedRemoteHead); + } + + private async approvePullRequestMessage(message: IRequestMessage): Promise { + await this.doReviewMessageWithAttestation(message, (body, expectedRemoteHead) => this.approvePullRequest(body, expectedRemoteHead)); + } + + 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.doReviewMessageWithAttestation(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.doReviewMessageWithAttestation(message, (body) => this.submitReview(body)); + } + + private async deleteBranch(message: IRequestMessage) { + const result = await PullRequestReviewCommon.deleteBranch(this._folderRepositoryManager, this._item); + if (result.isReply) { + this._replyMessage(message, result.message); + } else { + this._postMessage(result.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, + ): 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?'), + { modal: true }, + yes, + ); + if (confirmation !== yes) { + this._replyMessage(message, { state: GithubItemStateEnum.Open }); + return; + } + try { + const result = await this._item.merge(this._folderRepositoryManager.repository, title, description, method, email); + + 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, ''); + } + } + + private _getHtmlForWebview() { + const nonce = generateUuid(); + + const uri = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview-open-pr-view.js'); + + return ` + + + + + + + Active Pull Request + + +
+ + +`; + } +} diff --git a/src/github/attestationCommit.ts b/src/github/attestationCommit.ts new file mode 100644 index 0000000000..bf786b9833 --- /dev/null +++ b/src/github/attestationCommit.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. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { FolderRepositoryManager } from './folderRepositoryManager'; +import { PullRequestModel } from './pullRequestModel'; +import { Repository } from '../api/api'; +import Logger from '../common/logger'; +import { ENABLE_ATTESTATION_COMMITS, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { CommitEvent, EventType } from '../common/timelineEvent'; +import { formatError } from '../common/utils'; + +const LOG_ID = 'AttestationCommit'; + +const DEFAULT_ATTESTATION_COMMIT_MESSAGE = 'Attestation commit'; + +/** + * Returns true when the repository appears to have commit signing configured. + * + * Accepts any of the following (checked across local + global git config): + * - `user.signingkey` is set, OR + * - `commit.gpgsign` is a truthy git boolean (`true`/`1`/`yes`/`on`), OR + * - `gpg.format` is set to `ssh` or `x509` (the user is explicitly opting in + * to a non-default signing format). + */ +async function hasCommitSigningConfigured(repository: Repository): Promise { + const read = async (key: string): Promise => { + const tryRead = async (fn: (k: string) => Promise): Promise => { + try { + const value = await fn(key); + return value?.trim() || undefined; + } catch { + // `getConfig`/`getGlobalConfig` reject when the key is not set. + return undefined; + } + }; + return (await tryRead(k => repository.getConfig(k))) + ?? (await tryRead(k => repository.getGlobalConfig(k))); + }; + + const [signingKey, commitGpgSign, gpgFormat] = await Promise.all([ + read('user.signingkey'), + read('commit.gpgsign'), + read('gpg.format'), + ]); + + if (signingKey) { + return true; + } + // `commit.gpgsign` is a git boolean: true/1/yes/on (case-insensitive) are all truthy. + if (commitGpgSign && ['true', '1', 'yes', 'on'].includes(commitGpgSign.toLowerCase())) { + return true; + } + if (gpgFormat && ['ssh', 'x509'].includes(gpgFormat.toLowerCase())) { + return true; + } + return false; +} + +/** + * Reads the `githubPullRequests.enableAttestationCommits` setting. + * Returns `false` when the feature is disabled, otherwise the commit message + * that should be used for the attestation commit. + */ +export function getAttestationCommitSetting(): false | string { + const value = vscode.workspace + .getConfiguration(PR_SETTINGS_NAMESPACE) + .get(ENABLE_ATTESTATION_COMMITS, false); + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return false; + } + return trimmed; + } + return value === true ? DEFAULT_ATTESTATION_COMMIT_MESSAGE : false; +} + +/** + * Whether the attestation commit feature is enabled by the user setting. + */ +export function isAttestationCommitsEnabled(): boolean { + return getAttestationCommitSetting() !== false; +} + +/** + * Adds an empty, signed "attestation" commit to the head of the given pull request branch + * and pushes it to the corresponding remote. Requires that the pull request is currently + * checked out and that the user has commit signing configured. + * + * Returns the SHA of the new attestation commit plus a synthetic `CommitEvent` that + * callers can splice into the timeline (avoiding an extra `getTimelineEvents` round-trip + * to GitHub) when successful, otherwise `undefined`. + */ +export async function addAttestationCommit( + folderRepositoryManager: FolderRepositoryManager, + pullRequestModel: PullRequestModel, +): Promise<{ sha: string; event: CommitEvent } | undefined> { + const message = getAttestationCommitSetting(); + if (message === false) { + vscode.window.showWarningMessage(vscode.l10n.t('Attestation commits are not enabled. Enable them via the `githubPullRequests.enableAttestationCommits` setting.')); + return undefined; + } + + const activePullRequest = folderRepositoryManager.activePullRequest; + if (!activePullRequest || !activePullRequest.equals(pullRequestModel)) { + vscode.window.showErrorMessage(vscode.l10n.t('The pull request must be checked out before an attestation commit can be added.')); + return undefined; + } + + const repository = folderRepositoryManager.repository; + const head = repository.state.HEAD; + if (!head || !head.name) { + vscode.window.showErrorMessage(vscode.l10n.t('Unable to add an attestation commit: no branch is currently checked out.')); + return undefined; + } + + if (!await hasCommitSigningConfigured(repository)) { + vscode.window.showErrorMessage(vscode.l10n.t('Unable to add an attestation commit: commit signing does not appear to be configured. Set `user.signingkey` (or enable `commit.gpgsign`) in your git config and ensure your signing tool (GPG, SSH, or X.509) is set up.')); + return undefined; + } + + const originalSha = head.commit; + try { + Logger.appendLine(`Creating attestation commit on branch ${head.name} for PR #${pullRequestModel.number}`, LOG_ID); + await repository.commit(message, { + empty: true, + signCommit: true, + noVerify: true, + }); + + try { + const upstream = head.upstream; + if (upstream) { + // When the local branch name differs from its upstream (e.g. a + // checked-out PR branch `pr//` tracking `their/branch` + // on the fork), pass an explicit `:` refspec so + // git pushes to the tracked branch instead of trying to create + // a new remote branch named after the local ref. + const refspec = head.name && head.name !== upstream.name + ? `${head.name}:${upstream.name}` + : head.name; + await repository.push(upstream.remote, refspec); + } else { + await repository.push(); + } + } catch (pushError) { + // Push failed (e.g. no write access to a fork). Rewind the local + // commit so the branch doesn't diverge from the remote. + await rewindLocalCommit(repository, originalSha); + const errText = formatError(pushError); + const isPermissionDenied = /permission denied|forbidden|403|401/i.test(errText); + const detail = isPermissionDenied + ? vscode.l10n.t('You do not have push access to the pull request branch (`{0}:{1}`). The local attestation commit was rewound.', pullRequestModel.head?.repositoryCloneUrl.owner ?? '', pullRequestModel.head?.ref ?? '') + : vscode.l10n.t('Failed to push the attestation commit: {0}. The local commit was rewound.', errText); + Logger.error(`Attestation commit push failed: ${errText}`, LOG_ID); + vscode.window.showErrorMessage(detail); + return undefined; + } + + const sha = repository.state.HEAD?.commit; + if (!sha) { + return undefined; + } + + const currentUser = await folderRepositoryManager.getCurrentUser(pullRequestModel.githubRepository); + // Derive the commit URL from the PR's html_url (e.g. `.../pull/123` -> `.../commit/`). + const commitHtmlUrl = pullRequestModel.html_url.replace(/\/pull\/\d+.*$/, `/commit/${sha}`); + const event: CommitEvent = { + id: sha, + sha, + event: EventType.Committed, + author: currentUser, + htmlUrl: commitHtmlUrl, + message, + committedDate: new Date(), + }; + return { sha, event }; + } catch (e) { + Logger.error(`Failed to add attestation commit: ${formatError(e)}`, LOG_ID); + vscode.window.showErrorMessage(vscode.l10n.t('Failed to add attestation commit: {0}', formatError(e))); + return undefined; + } +} + +/** + * Best-effort rewind of the just-created (empty) attestation commit when we + * fail to push it. Uses the VS Code git extension's internal `_repository.reset` + * when available so the branch pointer moves back to `originalSha` without + * leaving a divergent local state. + */ +async function rewindLocalCommit(repository: Repository, originalSha: string | undefined): Promise { + if (!originalSha) { + return; + } + try { + const internal = (repository as unknown as { _repository?: { reset?: (treeish: string, hard?: boolean) => Promise } })._repository; + if (internal?.reset) { + await internal.reset(originalSha, true); + await repository.status(); + } else { + Logger.warn(`Cannot rewind attestation commit: internal reset API not available. Run 'git reset --hard ${originalSha}' to clean up.`, LOG_ID); + } + } catch (e) { + Logger.error(`Failed to rewind attestation commit: ${formatError(e)}. Run 'git reset --hard ${originalSha}' to clean up.`, LOG_ID); + } +} diff --git a/src/github/common.ts b/src/github/common.ts index b722fbe623..23a253493c 100644 --- a/src/github/common.ts +++ b/src/github/common.ts @@ -1,29 +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 * as OctokitRest from '@octokit/rest'; -import * as OctokitTypes from '@octokit/types'; +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']; export type IssuesCreateParams = OctokitRest.RestEndpointMethodTypes['issues']['create']['parameters']; + 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 = { + 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 ReposGetCombinedStatusForRefResponseStatusesItem = OctokitTypes.ReposGetCombinedStatusForRefResponseData['statuses'][0]; - export type ReposGetResponseOrganization = OctokitTypes.ReposGetResponseData['organization']; - export type PullsListResponseItem = OctokitTypes.PullsListResponseData[0]; + 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']; + export type PullsListResponseItem = OctokitRest.RestEndpointMethodTypes['pulls']['list']['response']['data'][0]; export type PullsListResponseItemHead = PullsListResponseItem['head']; export type PullsListResponseItemBase = PullsListResponseItem['base']; export type PullsListResponseItemHeadRepo = PullsListResponseItemHead['repo']; export type PullsListResponseItemBaseRepo = PullsListResponseItemBase['repo']; - export type PullsListResponseItemUser = PullsListResponseItem['user']; + export type PullsListResponseItemUser = Exclude; export type PullsListResponseItemAssignee = PullsListResponseItem['assignee']; - export type PullsListResponseItemAssigneesItem = PullsListResponseItem['assignees'][0]; - export type PullsListResponseItemRequestedReviewersItem = PullsListResponseItem['requested_reviewers'][0]; + export type PullsListResponseItemAssigneesItem = (Exclude)[0]; + export type PullsListResponseItemRequestedReviewersItem = (Exclude)[0]; export type PullsListResponseItemBaseUser = PullsListResponseItemBase['user']; export type PullsListResponseItemBaseRepoOwner = PullsListResponseItemBase['repo']['owner']; export type PullsListResponseItemHeadUser = PullsListResponseItemHead['user']; export type PullsListResponseItemHeadRepoOwner = PullsListResponseItemHead['repo']['owner']; - export type IssuesListEventsForTimelineResponseItemActor = OctokitTypes.IssuesListEventsForTimelineResponseData[0]['actor']; - export type PullsListReviewRequestsResponseTeamsItem = OctokitTypes.PullsListRequestedReviewersResponseData['teams'][0]; - export type PullsListResponseItemHeadRepoTemplateRepository = PullsListResponseItem['head']['repo']['template_repository']; - export type ReposGetResponseCodeOfConduct = OctokitTypes.ReposGetResponseData['code_of_conduct']; - export type PullsListCommitsResponseItem = OctokitTypes.PullsListCommitsResponseData[0]; - export type SearchReposResponseItem = OctokitTypes.SearchReposResponseData['items'][0]; + export type PullsListReviewRequestsResponseTeamsItem = Endpoints['GET /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers']['response']['data']['teams'][0]; + 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]; + export type ReposGetCommitResponseData = Endpoints['GET /repos/{owner}/{repo}/commits/{ref}']['response']['data']; + export type ReposGetCommitResponseFiles = Endpoints['GET /repos/{owner}/{repo}/commits/{ref}']['response']['data']['files']; + export type ReposGetResponseData = Endpoints['GET /repos/{owner}/{repo}']['response']['data']; + export type ReposGetResponseCodeOfConduct = ReposGetResponseData['code_of_conduct']; + 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']; +} -} \ No newline at end of file +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..9f3c987aee --- /dev/null +++ b/src/github/copilotRemoteAgent.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CopilotPRWatcher } from './copilotPrWatcher'; + +import { CredentialStore } from './credentials'; +import { RepositoriesManager } from './repositoriesManager'; +import { COPILOT_CLOUD_AGENT } from '../common/copilot'; +import { Disposable } from '../common/lifecycle'; +import { ITelemetry } from '../common/telemetry'; +import { PrsTreeModel } from '../view/prsTreeModel'; + +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)); + } +} \ No newline at end of file diff --git a/src/github/createPRLinkProvider.ts b/src/github/createPRLinkProvider.ts new file mode 100644 index 0000000000..543de0559c --- /dev/null +++ b/src/github/createPRLinkProvider.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. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { FolderRepositoryManager } from './folderRepositoryManager'; +import { PR_SETTINGS_NAMESPACE, TERMINAL_LINK_HANDLER } from '../common/settingKeys'; +import { ReviewManager } from '../view/reviewManager'; + +interface GitHubCreateTerminalLink extends vscode.TerminalLink { + url: string; +} + +export class GitHubCreatePullRequestLinkProvider implements vscode.TerminalLinkProvider { + constructor( + private readonly reviewManager: ReviewManager, + private readonly folderRepositoryManager: FolderRepositoryManager, + ) { } + + private static getSettingsValue() { + return vscode.workspace + .getConfiguration(PR_SETTINGS_NAMESPACE) + .get<'vscode' | 'github' | undefined>(TERMINAL_LINK_HANDLER); + } + + static registerProvider(reviewManager: ReviewManager, folderManager: FolderRepositoryManager): vscode.Disposable { + return vscode.window.registerTerminalLinkProvider( + new GitHubCreatePullRequestLinkProvider(reviewManager, folderManager), + ); + } + + provideTerminalLinks( + context: vscode.TerminalLinkContext, + _token: vscode.CancellationToken, + ): vscode.ProviderResult { + const startIndex = context.line.indexOf('https://github.com'); + if (startIndex === -1) { + return []; + } + + /** + * When a branch is published, a line like the following is written to the terminal: + * remote: https://github.com/RMacfarlane/pullrequest-demo/pull/new/rmacfarlane/testbranch3 + */ + const url = context.line.substring(startIndex); + const regex = new RegExp(/https:\/\/github\.com\/(.*)\/(.*)\/pull\/new\/(.*)/); + const result = url.match(regex); + if (result && result.length === 4) { + const owner = result[1]; + const repositoryName = result[2]; + const branchName = result[3]; + + const hasMatchingGitHubRepo = + this.folderRepositoryManager.gitHubRepositories.findIndex( + repo => repo.remote.owner === owner && repo.remote.repositoryName === repositoryName, + ) > -1; + + // The create flow compares against the current branch, so check that the published branch is this branch + if (hasMatchingGitHubRepo && this.reviewManager.repository.state.HEAD?.name === branchName) { + return [ + { + startIndex, + length: context.line.length - startIndex, + tooltip: vscode.l10n.t('Create a Pull Request'), + url, + }, + ]; + } + } + + return []; + } + + private openLink(link: GitHubCreateTerminalLink) { + return vscode.env.openExternal(vscode.Uri.parse(link.url)); + } + + handleTerminalLink(link: GitHubCreateTerminalLink): vscode.ProviderResult { + const defaultHandler = GitHubCreatePullRequestLinkProvider.getSettingsValue(); + + if (defaultHandler === 'github') { + this.openLink(link); + return; + } + + if (defaultHandler === 'vscode') { + this.reviewManager.createPullRequest(); + return; + } + + const yes = 'Yes'; + const neverShow = 'Don\'t Show Again'; + + vscode.window + .showInformationMessage( + 'Do you want to create a pull request using the GitHub Pull Requests and Issues extension?', + yes, + 'No, continue to github.com', + neverShow + ) + .then(notificationResult => { + switch (notificationResult) { + case yes: { + this.reviewManager.createPullRequest(); + break; + } + case neverShow: { + vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(TERMINAL_LINK_HANDLER, 'github', vscode.ConfigurationTarget.Global); + this.openLink(link); + break; + } + default: this.openLink(link); + } + }); + } +} diff --git a/src/github/createPRViewProvider.ts b/src/github/createPRViewProvider.ts new file mode 100644 index 0000000000..5281bbe673 --- /dev/null +++ b/src/github/createPRViewProvider.ts @@ -0,0 +1,1609 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { + byRemoteName, + FolderRepositoryManager, + PullRequestDefaults, + titleAndBodyFrom, +} from './folderRepositoryManager'; +import { GitHubRepository, isRateLimitError, ViewerPermission } from './githubRepository'; +import { IAccount, ILabel, IMilestone, IProject, isITeam, ITeam, MergeMethod, RepoAccessAndMergeMethods } from './interface'; +import { BaseBranchMetadata, PullRequestGitHelper } from './pullRequestGitHelper'; +import { PullRequestModel } from './pullRequestModel'; +import { getDefaultMergeMethod } from './pullRequestOverview'; +import { branchPicks, cachedBranchPicks, getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, getProjectFromQuickPick, reviewersQuickPick } from './quickPicks'; +import { ISSUE_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 interface BasePullRequestDataModel { + baseOwner: string; + repositoryName: string; +} + +export abstract class BaseCreatePullRequestViewProvider extends WebviewViewBase implements vscode.WebviewViewProvider { + protected static readonly ID = 'CreatePullRequestViewProvider'; + public override readonly viewType = 'github:createPullRequestWebview'; + + protected _onDone = new vscode.EventEmitter(); + readonly onDone: vscode.Event = this._onDone.event; + + protected _firstLoad: boolean = true; + + constructor( + protected readonly telemetry: ITelemetry, + protected readonly model: T, + extensionUri: vscode.Uri, + protected readonly _folderRepositoryManager: FolderRepositoryManager, + protected readonly _pullRequestDefaults: PullRequestDefaults, + protected _defaultCompareBranch: string + ) { + super(extensionUri); + } + + public override resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ) { + super.resolveWebviewView(webviewView, _context, _token); + webviewView.webview.html = this._getHtmlForWebview(); + + if (this._firstLoad) { + this._firstLoad = false; + // Reset any stored state. + return this.initializeParams(true); + } else { + 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); + if (!repo) { + return undefined; + } + 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; + + // Called once the detected base branch is known, before getTitleAndDescription runs. + // Subclasses can override to update model state that getTitleAndDescription depends on. + protected onBaseBranchDetected(_baseOwner: string, _baseBranch: string): void { } + + 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; + + // Notify subclasses so they can update model state before getTitleAndDescription runs. + this.onBaseBranchDetected(defaultBaseRemote.owner, defaultBaseBranch); + + let defaultTitleAndDescription: { title: string; description: string }; + let mergeConfiguration: RepoAccessAndMergeMethods | undefined; + let viewerPermission: ViewerPermission; + let mergeQueueMethodForBranch: MergeMethod | undefined; + let labels: ILabel[]; + try { + [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) + ]); + } catch (e) { + if (isRateLimitError(e)) { + vscode.window.showErrorMessage(vscode.l10n.t('GitHub API rate limit exceeded. Please wait and try again.')); + } + Logger.error(`Error initializing create pull request view: ${e}`, BaseCreatePullRequestViewProvider.ID); + return { + canModifyBranches: true, + defaultBaseRemote, + defaultBaseBranch, + defaultCompareRemote, + defaultCompareBranch: this._defaultCompareBranch, + defaultTitle: '', + defaultDescription: '', + baseHasMergeQueue: false, + remoteCount: remotes.length, + autoMergeDefault: false, + createError: '', + isDraftDefault: false, + isDarkTheme: vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark, + generateTitleAndDescriptionTitle: undefined, + creating: false, + initializeWithGeneratedTitleAndDescription: false, + preReviewState: PreReviewState.None, + preReviewer: undefined, + reviewing: false, + usingTemplate: false, + }; + } + + 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 = mergeConfiguration ? getDefaultMergeMethod(mergeConfiguration.mergeMethodsAvailability) : 'merge' as MergeMethod; + + // 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 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; + } + + public async setDefaultCompareBranch(compareBranch: Branch | undefined) { + if (!compareBranch?.name) { + return; + } + this._defaultCompareBranch = compareBranch.name; + this.model.setCompareBranch(compareBranch.name); + this.changeBranch(compareBranch.name, false).then(async titleAndDescription => { + const params: Partial = { + defaultTitle: titleAndDescription.title, + defaultDescription: titleAndDescription.description, + compareBranch: compareBranch.name, + defaultCompareBranch: compareBranch.name, + warning: await this.existingPRMessage(), + }; + return this._postMessage({ + command: 'pr.initialize', + params, + }); + }); + + } + + public override show(compareBranch?: Branch): void { + if (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<{ commit: { message: string }; parents: { sha: string }[] }[] | undefined> { + const origin = await this._folderRepositoryManager.getOrigin(compareBranch); + + if (compareBranch.upstream) { + const headRepo = this._folderRepositoryManager.findRepo(byRemoteName(compareBranch.upstream.remote)); + + if (headRepo) { + const headBranch = `${headRepo.remote.owner}:${compareBranch.name ?? ''}`; + const baseBranch = `${this._pullRequestDefaults.owner}:${baseBranchName}`; + const compareResult = await origin.compareCommits(baseBranch, headBranch); + + return compareResult?.commits; + } + } + + return undefined; + } + + 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) => { + const nameWithSpaces = name.replace(/[-_]/g, ' '); + return `${nameWithSpaces.charAt(0).toUpperCase()}${nameWithSpaces.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); + + let useBranchName = this._pullRequestDefaults.base === compareBranch.name; + Logger.debug(`Compare branch name: ${compareBranch.name}, Base branch name: ${this._pullRequestDefaults.base}`, CreatePullRequestViewProvider.ID); + try { + const [totalCommits, lastCommit, pullRequestTemplate] = await Promise.all([ + this.getTotalGitHubCommits(compareBranch, baseBranch), + name ? titleAndBodyFrom(promiseWithTimeout(this._folderRepositoryManager.getTipCommitMessage(name), 5000)) : undefined, + descriptionSource === 'template' ? this.getPullRequestTemplate() : undefined + ]); + let totalNonMergeCommitsCount: number | undefined = totalCommits?.filter(commit => commit.parents.length < 2).length; + + // If we couldn't determine the number of commits from GitHub (e.g. no upstream, or the compare API + // call failed), fall back to the local git log so that we can still match github.com's behavior of + // using the branch name when there is more than one commit. + if (totalNonMergeCommitsCount === undefined) { + try { + const localCommits = await this.model.gitCommits(); + totalNonMergeCommitsCount = localCommits.filter(commit => commit.parents.length < 2).length; + } catch (e) { + Logger.debug(`Failed to retrieve local git commits as fallback for PR title for branch ${name}: ${e}`, CreatePullRequestViewProvider.ID); + } + } + + Logger.debug(`Total commits: ${totalNonMergeCommitsCount}`, CreatePullRequestViewProvider.ID); + if (totalNonMergeCommitsCount === undefined) { + // We couldn't determine the number of commits at all. Use the last commit as the title and description. + useBranchName = false; + } else if (totalNonMergeCommitsCount > 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 = branchNameTitle(name); + } else if (name && lastCommit) { + title = lastCommit.title; + } + + // Set description + // Match GitHub.com behavior: only use the commit body when there is a single commit. + if (pullRequestTemplate && lastCommit?.body && !useBranchName) { + description = `${lastCommit.body}\n\n${pullRequestTemplate}`; + } else if (pullRequestTemplate) { + description = pullRequestTemplate; + } else if (lastCommit?.body && !useBranchName && (this._pullRequestDefaults.base !== compareBranch.name)) { + description = lastCommit.body; + } + + // If the description is empty, check to see if the title of the PR contains something that looks like an issue + if (!description) { + const issueExpMatch = title.match(ISSUE_EXPRESSION); + const match = parseIssueExpressionOutput(issueExpMatch); + if (match?.issueNumber && !match.name && !match.owner) { + description = `#${match.issueNumber}`; + const prefix = title.substr(0, title.indexOf(issueExpMatch![0])); + + const keyWordMatch = prefix.match(ISSUE_CLOSING_KEYWORDS); + if (keyWordMatch) { + description = `${keyWordMatch[0]} ${description}`; + } + } + } + } catch (e) { + // Ignore and fall back to commit message + Logger.debug(`Error while getting total commits: ${e}`, CreatePullRequestViewProvider.ID); + } + return { title, description }; + } + + private async getPullRequestTemplate(): Promise { + 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); + } + + // 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 + } + ); + + if (selectedTemplate) { + const reply: ChangeTemplateReply = { + description: selectedTemplate.template + }; + return this._replyMessage(message, reply); + } + return this._replyMessage(message, undefined); + } + + 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; + } + + // Get the repository root + const workspaceFolder = this._folderRepositoryManager.repository.rootUri; + const templatePath = vscode.Uri.joinPath(workspaceFolder, selected.label); + + // Default template content + const templateContent = `## Sample Pull Request Template Description + +This is a sample pull request template. You can customize it to fit your project's needs. + +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)); + + // 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)) + ); + } + } + + 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; + } + + 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; + } + } + + protected override onBaseBranchDetected(baseOwner: string, baseBranch: string): void { + this.model.baseOwner = baseOwner; + this.model.baseBranch = baseBranch; + } + + protected override async getCreateParams(): Promise { + const params = await super.getCreateParams(); + // Pre-fetch branches so they're cached when the user opens the branch picker + this.prefetchBranches(params.defaultBaseRemote!); + return params; + } + + private prefetchBranches(baseRemote: RemoteInfo): void { + const githubRepository = this._folderRepositoryManager.findRepo( + repo => repo.remote.owner === baseRemote.owner && repo.remote.repositoryName === baseRemote.repositoryName, + ); + if (githubRepository) { + githubRepository.listBranches(baseRemote.owner, baseRemote.repositoryName, undefined).catch(e => { + Logger.debug(`Pre-fetching branches failed: ${e}`, CreatePullRequestViewProvider.ID); + }); + } + } + + + 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 { + iconPath: new vscode.ThemeIcon('repo'), + label: `${remote.owner}/${remote.repositoryName}`, + remote: { + owner: remote.owner, + repositoryName: remote.repositoryName, + } + }; + }); + } + + private async processRemoteAndBranchResult(githubRepository: GitHubRepository, result: { remote: RemoteInfo, branch: string }, isBase: boolean) { + let viewerPermission: ViewerPermission; + try { + viewerPermission = await githubRepository.getViewerPermission(); + } catch (e) { + if (isRateLimitError(e)) { + vscode.window.showErrorMessage(vscode.l10n.t('GitHub API rate limit exceeded. Please wait and try again.')); + viewerPermission = ViewerPermission.Unknown; + } else { + throw e; + } + } + const defaultBranch = await githubRepository.getDefaultBranch(); + + 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'); + } + + chooseResult = { + baseRemote: result.remote, + baseBranch: result.branch, + defaultBaseBranch: defaultBranch, + defaultMergeMethod: mergeConfiguration ? getDefaultMergeMethod(mergeConfiguration.mergeMethodsAvailability) : 'merge' as MergeMethod, + allowAutoMerge: mergeConfiguration?.viewerCanAutoMerge ?? false, + baseHasMergeQueue: !!mergeQueueMethodForBranch, + mergeMethodsAvailability: mergeConfiguration?.mergeMethodsAvailability ?? { merge: true, squash: true, rebase: true }, + 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 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 => message.args.currentRemote?.owner === repo.remote.owner && message.args.currentRemote.repositoryName === repo.remote.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) { + // Show cached branches immediately if available, then refresh in the background + const cached = cachedBranchPicks(githubRepository, this._folderRepositoryManager, chooseDifferentRemote, isBase); + if (cached) { + quickPick.items = cached; + const activeItem = message.args.currentBranch ? quickPick.items.find(item => item.branch === message.args.currentBranch) : undefined; + quickPick.activeItems = activeItem ? [activeItem] : []; + } + } + // Register event handlers before awaiting async operations to avoid missing early user interactions + const remoteAndBranch: Promise<{ remote: RemoteInfo, branch: string } | undefined> = new Promise((resolve) => { + quickPick.onDidAccept(async () => { + const selectedPick = quickPick.selectedItems[0] ?? quickPick.activeItems[0]; + if (!selectedPick) { + return; + } + 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())); + if (githubRepository) { + await updateItems(githubRepository, undefined); + } else { + quickPick.items = await this.remotePicks(isBase); + } + const activeItem = message.args.currentBranch ? quickPick.items.find(item => item.branch === message.args.currentBranch) : undefined; + quickPick.activeItems = activeItem ? [activeItem] : []; + quickPick.busy = false; + const result = await Promise.race([remoteAndBranch, hidePromise]); + if (!result || !githubRepository) { + quickPick.hide(); + quickPick.dispose(); + onDidChangeValueDisposable?.dispose(); + return; + } + + quickPick.busy = true; + const chooseResult = await this.processRemoteAndBranchResult(githubRepository, result, isBase); + + quickPick.hide(); + quickPick.dispose(); + onDidChangeValueDisposable?.dispose(); + return this._replyMessage(message, chooseResult); + } + + private async getCommitsAndPatches(): Promise<{ commitMessages: string[], patches: { patch: string, fileUri: string, previousFileUri?: string }[] }> { + return this.model.getCommitsAndPatches(); + } + + 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.model.findIssueContext(commitMessages); + const template = await templatePromise; + + const provider = this._folderRepositoryManager.getTitleAndDescriptionProvider(searchTerm); + const result = await provider?.provider.provideTitleAndDescription({ commitMessages, patches, issues, template, compareBranch: this.model.compareBranch }, 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 generatingCancellationToken: vscode.CancellationTokenSource | undefined; + private async generateTitleAndDescription(message: IRequestMessage): Promise { + if (this.generatingCancellationToken) { + this.generatingCancellationToken.cancel(); + } + 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 cancelGenerateTitleAndDescription(): Promise { + if (this.generatingCancellationToken) { + this.generatingCancellationToken.cancel(); + } + } + + 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; + } + + public async review(): Promise { + this._postMessage({ command: 'reviewing', params: { reviewing: true } }); + } + + private reviewingCancellationToken: vscode.CancellationTokenSource | undefined; + private async preReview(message: IRequestMessage): Promise { + return CreatePullRequestViewProvider.withProgress(async () => { + await commands.setContext('pr:preReviewing', true); + + if (this.reviewingCancellationToken) { + this.reviewingCancellationToken.cancel(); + } + this.reviewingCancellationToken = new vscode.CancellationTokenSource(); + + const result = await Promise.race([this.getPreReviewFromProvider(this.reviewingCancellationToken.token), + new Promise(resolve => this.reviewingCancellationToken?.token.onCancellationRequested(() => resolve()))]); + + this.reviewingCancellationToken = undefined; + await commands.setContext('pr:preReviewing', false); + + return this._replyMessage(message, result); + }); + } + + private async cancelPreReview(): Promise { + if (this.reviewingCancellationToken) { + this.reviewingCancellationToken.cancel(); + } + } + + private async pushUpstream(compareOwner: string, compareRepositoryName: string, compareBranchName: string): Promise<{ compareUpstream: GitHubRemote, repo: GitHubRepository | undefined } | undefined> { + let createdPushRemote: GitHubRemote | undefined; + const pushRemote = this._folderRepositoryManager.repository.state.remotes.find(localRemote => { + if (!localRemote.pushUrl) { + return false; + } + const testRemote = new GitHubRemote(localRemote.name, localRemote.pushUrl, new Protocol(localRemote.pushUrl), GitHubServerType.GitHubDotCom); + if ((testRemote.owner.toLowerCase() === compareOwner.toLowerCase()) && (testRemote.repositoryName.toLowerCase() === compareRepositoryName.toLowerCase())) { + createdPushRemote = testRemote; + return true; + } + return false; + }); + + if (pushRemote && createdPushRemote) { + 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; + } + + 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}`; + 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; + let existingCompareUpstream = headRepo?.remote; + + if (!existingCompareUpstream + || (existingCompareUpstream.owner !== compareOwner) + || (existingCompareUpstream.repositoryName !== compareRepositoryName)) { + + // 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(PR_SETTINGS_NAMESPACE).get(PUSH_BRANCH) === 'always'; + const messageResult = !pushBranchSetting ? await vscode.window.showInformationMessage( + 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(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 }); + totalIncrement += 10; + + const pushResult = await this.pushUpstream(compareOwner, compareRepositoryName, compareBranchName); + if (pushResult) { + existingCompareUpstream = pushResult.compareUpstream; + headRepo = pushResult.repo; + } else { + this._throwError(message, vscode.l10n.t('The current repository does not have a push remote for {0}', compareGithubRemoteName)); + } + } + } + if (!existingCompareUpstream) { + 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; + } + + if (!headRepo) { + throw new Error(vscode.l10n.t('Unable to find GitHub repository matching \'{0}\'. You can add \'{0}\' to the setting "githubPullRequests.remotes" to ensure \'{0}\' is found.', existingCompareUpstream.remoteName)); + } + + progress.report({ message: vscode.l10n.t('Creating pull request'), increment: 70 - totalIncrement }); + totalIncrement += 70 - totalIncrement; + const head = `${headRepo.remote.owner}:${compareBranchName}`; + 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 { + // 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) { + 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 { + 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(newBranch: string, isBase: boolean): Promise<{ title: string, description: string }> { + let compareBranch: Branch | undefined; + if (isBase) { + this.model.baseBranch = newBranch; + } else { + try { + compareBranch = await this._folderRepositoryManager.repository.getBranch(newBranch); + } 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.model.compareBranch); + return this.getTitleAndDescription(compareBranch, this.model.baseBranch); + } + + protected override async _onDidReceiveMessage(message: IRequestMessage) { + const result = await super._onDidReceiveMessage(message); + if (result !== this.MESSAGE_UNHANDLED) { + return; + } + + switch (message.command) { + case 'pr.changeBaseRemoteAndBranch': + return this.changeRemoteAndBranch(message, true); + + case 'pr.changeCompareRemoteAndBranch': + return this.changeRemoteAndBranch(message, false); + + case 'pr.generateTitleAndDescription': + return this.generateTitleAndDescription(message); + + case 'pr.cancelGenerateTitleAndDescription': + return this.cancelGenerateTitleAndDescription(); + + case 'pr.changeTemplate': + return this.changeTemplate(message); + + case 'pr.preReview': + return this.preReview(message); + + case 'pr.cancelPreReview': + return this.cancelPreReview(); + + default: + // Log error + vscode.window.showErrorMessage('Unsupported webview message'); + } + } +} diff --git a/src/github/credentials.ts b/src/github/credentials.ts index 36810214e5..b633428403 100644 --- a/src/github/credentials.ts +++ b/src/github/credentials.ts @@ -4,82 +4,439 @@ *--------------------------------------------------------------------------------------------*/ import { Octokit } from '@octokit/rest'; -import * as OctokitTypes from '@octokit/types'; -import { ApolloClient, InMemoryCache, NormalizedCacheObject } from 'apollo-boost'; +import { ApolloClient, InMemoryCache } from 'apollo-boost'; import { setContext } from 'apollo-link-context'; +import { createHttpLink } from 'apollo-link-http'; +import fetch from 'cross-fetch'; import * as vscode from 'vscode'; -import { agent } from '../common/net'; +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 { createHttpLink } from 'apollo-link-http'; -import fetch from 'node-fetch'; +import { GITHUB_ENTERPRISE, URI } from '../common/settingKeys'; +import { initBasedOnSettingChange } from '../common/settingsUtils'; import { ITelemetry } from '../common/telemetry'; +import { agent } from '../env/node/net'; -const TRY_AGAIN = 'Try again?'; -const CANCEL = 'Cancel'; -const SIGNIN_COMMAND = 'Sign in'; -const IGNORE_COMMAND = 'Don\'t show again'; +const TRY_AGAIN = vscode.l10n.t('Try again?'); +const CANCEL = vscode.l10n.t('Cancel'); +const SIGNIN_COMMAND = vscode.l10n.t('Sign In'); +const IGNORE_COMMAND = vscode.l10n.t('Don\'t Show Again'); -const PROMPT_FOR_SIGN_IN_SCOPE = 'prompt for sign in'; +const PROMPT_FOR_SIGN_IN_SCOPE = vscode.l10n.t('prompt for sign in'); const PROMPT_FOR_SIGN_IN_STORAGE_KEY = 'login'; -const AUTH_PROVIDER_ID = 'github'; -const SCOPES = ['read:user', 'user:email', 'repo']; +// If the scopes are changed, make sure to notify all interested parties to make sure this won't cause problems. +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: Octokit; - graphql: ApolloClient | null; - currentUser?: OctokitTypes.PullsGetResponseData['user']; + octokit: LoggingOctokit; + graphql: LoggingApolloClient; + currentUser?: Promise; + isEmu?: Promise; +} + +interface AuthResult { + canceled: boolean; } -export class CredentialStore { +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 _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 _handlingAuthError: Map> = new Map(); + private _lastAuthErrorHandledAt: Map = new Map(); + // Cooldown long enough to absorb retries from in-flight requests that were + // issued with the now-invalid token, but short enough that a token that + // is invalidated again soon after re-auth will still trigger another prompt. + private static readonly AUTH_ERROR_COOLDOWN_MS = 60_000; + + 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; + + private _onDidUpgradeSession: vscode.EventEmitter = new vscode.EventEmitter(); + public readonly onDidUpgradeSession = this._onDidUpgradeSession.event; + + constructor(private readonly _telemetry: ITelemetry, private readonly context: vscode.ExtensionContext) { + super(); + this.setScopesFromState(); + + this._register(vscode.authentication.onDidChangeSessions((e) => this.handlOnDidChangeSessions(e))); + } + + 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 tryInitializeFromEnvironmentToken(authProviderId: AuthProvider): Promise { + if (isEnterprise(authProviderId)) { + return undefined; + } + const token = process.env.GITHUB_OAUTH_TOKEN; + if (!token) { + return undefined; + } + Logger.debug('Attempting authentication using GITHUB_OAUTH_TOKEN environment variable.', CredentialStore.ID); + try { + const github = await this.createHub(token, authProviderId); + this._githubAPI = github; + this._sessionId = 'environment-token'; + if (!this._isInitialized) { + this._isInitialized = true; + this._onDidInitialize.fire(); + } + Logger.appendLine('Successfully authenticated using GITHUB_OAUTH_TOKEN environment variable.', CredentialStore.ID); + return { canceled: false }; + } catch (e) { + Logger.error(`Failed to authenticate using GITHUB_OAUTH_TOKEN: ${e.message}`, CredentialStore.ID); + return undefined; + } + } + + 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 { canceled: false }; + } + } - constructor(private readonly _telemetry: ITelemetry) { } + const envResult = await this.tryInitializeFromEnvironmentToken(authProviderId); + if (envResult) { + return envResult; + } + + if (getAuthSessionOptions.createIfNone === undefined && getAuthSessionOptions.forceNewSession === undefined) { + getAuthSessionOptions.createIfNone = false; + } - public async initialize(): Promise { - const session = await vscode.authentication.getSession(AUTH_PROVIDER_ID, SCOPES, { 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 { + // 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) { + 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 { + throw e; + } + } if (session) { - const token = session.accessToken; - this._sessionId = session.id; - const octokit = await this.createHub(token); - this._githubAPI = octokit; - await this.setCurrentUser(octokit); + if (!isEnterprise(authProviderId)) { + this._sessionId = session.id; + } else { + this._enterpriseSessionId = session.id; + } + let github: GitHub | undefined; + try { + 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, 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 (!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 (!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 token found.`, 'Authentication'); + Logger.debug(`No GitHub${getGitHubSuffix(authProviderId)} token found.`, CredentialStore.ID); + return authResult; + } + } + + 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 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 = {}, additionalScopes: boolean = false) { + return this.doCreate(options, additionalScopes); + } + + public async recreate(reason?: string): Promise { + return this.doCreate({ forceNewSession: reason ? { detail: reason } : true }); + } + + /** + * Handles authentication errors that surface from API calls (e.g. "Bad credentials" + * or 401 Unauthorized). Triggers a re-authentication prompt for the affected + * provider, deduplicating concurrent requests so we don't show multiple prompts + * when many in-flight calls fail at once. + */ + public async handleAuthError(authProviderId: AuthProvider): Promise { + // Only prompt if we currently believe we are authenticated for this provider. + // Otherwise the regular sign-in flow will handle it. + if (!this.isAuthenticated(authProviderId)) { + return { canceled: true }; + } + const inFlight = this._handlingAuthError.get(authProviderId); + if (inFlight) { + return inFlight; + } + // In-flight requests that were issued with the now-invalid token may continue + // to fail with auth errors for a short period after a successful re-auth. + // Suppress re-prompting for a cooldown window to avoid repeatedly nagging the + // user. + const lastHandled = this._lastAuthErrorHandledAt.get(authProviderId); + if (lastHandled !== undefined && (Date.now() - lastHandled) < CredentialStore.AUTH_ERROR_COOLDOWN_MS) { + return { canceled: true }; } + Logger.appendLine(`Detected invalid GitHub${getGitHubSuffix(authProviderId)} credentials; prompting for re-authentication.`, CredentialStore.ID); + /* __GDPR__ + "auth.badCredentials" : {} + */ + this._telemetry.sendTelemetryEvent('auth.badCredentials'); + const reason = vscode.l10n.t('Your GitHub{0} authentication session is no longer valid. Please sign in again.', getGitHubSuffix(authProviderId)); + const promise = (async () => { + try { + // Force re-auth only for the affected provider, not both. Going through + // recreate()/doCreate() would prompt re-auth for both GitHub.com and + // GitHub Enterprise when both are configured. + return await this.initialize(authProviderId, { forceNewSession: { detail: reason } }); + } finally { + this._handlingAuthError.delete(authProviderId); + this._lastAuthErrorHandledAt.set(authProviderId, Date.now()); + } + })(); + this._handlingAuthError.set(authProviderId, promise); + return promise; } public async reset() { this._githubAPI = undefined; - await this.initialize(); + this._githubEnterpriseAPI = undefined; + return this.create(); + } + + public isAnyAuthenticated() { + return this.isAuthenticated(AuthProvider.github) || this.isAuthenticated(AuthProvider.githubEnterprise); + } + + public isAuthenticated(authProviderId: AuthProvider): boolean { + 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 (!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); } - public isAuthenticated(): boolean { - return !!this._githubAPI; + 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 getHub(): GitHub | undefined { - return this._githubAPI; + 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 getHubOrLogin(): Promise { - return this._githubAPI ?? await this.login(); + 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 showSignInNotification(): Promise { + public async getHubOrLogin(authProviderId: AuthProvider): Promise { + if (!isEnterprise(authProviderId)) { + return this._githubAPI ?? (await this.login(authProviderId)); + } + return this._githubEnterpriseAPI ?? (await this.login(authProviderId)); + } + + public async showSignInNotification(authProviderId: AuthProvider): Promise { if (PersistentState.fetch(PROMPT_FOR_SIGN_IN_SCOPE, PROMPT_FOR_SIGN_IN_STORAGE_KEY) === false) { return; } const result = await vscode.window.showInformationMessage( - `In order to use the Pull Requests functionality, you must sign in to GitHub`, - SIGNIN_COMMAND, IGNORE_COMMAND); + vscode.l10n.t('In order to use the Pull Requests functionality, you must sign in to GitHub{0}', getGitHubSuffix(authProviderId)), + SIGNIN_COMMAND, + IGNORE_COMMAND, + ); if (result === SIGNIN_COMMAND) { - return await this.login(); + return await this.login(authProviderId); } else { // user cancelled sign in, remember that and don't ask again PersistentState.store(PROMPT_FOR_SIGN_IN_SCOPE, PROMPT_FOR_SIGN_IN_STORAGE_KEY, false); @@ -91,44 +448,42 @@ export class CredentialStore { } } - public async logout(): Promise { - if (this._sessionId) { - vscode.authentication.logout('github', this._sessionId); - } - } - - public async login(): Promise { - + public async login(authProviderId: AuthProvider): Promise { /* __GDPR__ "auth.start" : {} */ this._telemetry.sendTelemetryEvent('auth.start'); + const errorPrefix = vscode.l10n.t('Error signing in to GitHub{0}', getGitHubSuffix(authProviderId)); let retry: boolean = true; let octokit: GitHub | undefined = undefined; - + const sessionOptions: vscode.AuthenticationGetSessionOptions = { createIfNone: true }; + let isCanceled: boolean = false; while (retry) { try { - const token = await this.getSessionOrLogin(); - octokit = await this.createHub(token); + await this.initialize(authProviderId, sessionOptions); } catch (e) { - Logger.appendLine(`Error signing in to GitHub: ${e}`); + Logger.error(`Login error: ${errorPrefix}: ${e}`, CredentialStore.ID); if (e instanceof Error && e.stack) { - Logger.appendLine(e.stack); + Logger.error(e.stack, CredentialStore.ID); + } + if (e.message === 'Cancelled') { + isCanceled = true; } } - - if (octokit) { + octokit = this.getHub(authProviderId); + if (octokit || isCanceled) { retry = false; } else { - retry = (await vscode.window.showErrorMessage(`Error signing in to GitHub`, TRY_AGAIN, CANCEL)) === TRY_AGAIN; + retry = (await vscode.window.showErrorMessage(errorPrefix, TRY_AGAIN, CANCEL)) === TRY_AGAIN; + if (retry) { + sessionOptions.forceNewSession = true; + sessionOptions.createIfNone = undefined; + } } } if (octokit) { - this._githubAPI = octokit; - await this.setCurrentUser(octokit); - /* __GDPR__ "auth.success" : {} */ @@ -143,65 +498,147 @@ export class CredentialStore { return octokit; } - public isCurrentUser(username: string): boolean { - return this._githubAPI?.currentUser?.login === username; + 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(authProviderId: AuthProvider, username: string): Promise { + const api = authProviderId === AuthProvider.github ? this._githubAPI : this._githubEnterpriseAPI; + return (await api?.currentUser)?.login === username; } - public getCurrentUser(): OctokitTypes.PullsGetResponseData['user'] { - const octokit = this._githubAPI?.octokit; - // TODO remove cast - return octokit && (this._githubAPI as any).currentUser; + public async getIsEmu(authProviderId: AuthProvider): Promise { + const github = this.getHub(authProviderId); + return !!(await github?.isEmu); } - private async setCurrentUser(github: GitHub): Promise { - const user = await github.octokit.users.getAuthenticated({}); - github.currentUser = user.data; + public getCurrentUser(authProviderId: AuthProvider): Promise { + const github = this.getHub(authProviderId); + const octokit = github?.octokit; + return (octokit && github?.currentUser)!; } - private async getSessionOrLogin(): Promise { - const session = await vscode.authentication.getSession(AUTH_PROVIDER_ID, SCOPES, { createIfNone: true }); - this._sessionId = session.id; - return session.accessToken; + private setCurrentUser(github: GitHub): void { + 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, 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 }; + } + + const session = await vscode.authentication.getSession(authProviderId, requireScopes ? scopes : SCOPES_OLD, getAuthSessionOptions); + return { session, isNew: !!session, scopes: requireScopes ? scopes : SCOPES_OLD }; + } + + 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 }; + } + } } - private async createHub(token: string): Promise { + private async createHub(token: string, authProviderId: AuthProvider): Promise { + let baseUrl = 'https://api.github.com'; + let enterpriseServerUri: vscode.Uri | undefined; + 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) { + 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; + if (vscode.env.uiKind === vscode.UIKind.Web) { + fetchCore = (url: string, options: { headers?: Record }) => { + if (options.headers !== undefined) { + const { 'user-agent': userAgent, ...headers } = options.headers; + if (userAgent) { + options.headers = headers; + } + } + return fetch(url, options); + }; + } + const octokit = new Octokit({ - request: { agent }, + request: { agent, fetch: fetchCore }, userAgent: 'GitHub VSCode Pull Requests', // `shadow-cat-preview` is required for Draft PR API access -- https://developer.github.com/v3/previews/#draft-pull-requests - previews: ['shadow-cat-preview'], - auth: `${token || ''}` - + previews: ['shadow-cat-preview', 'merge-info-preview'], + auth: `${token || ''}`, + baseUrl: baseUrl, }); + let graphQLBaseUrl = baseUrl; + if (enterpriseServerUri && !isGhe) { + graphQLBaseUrl = `${enterpriseServerUri.scheme}://${enterpriseServerUri.authority}/api`; + } + const graphql = new ApolloClient({ - link: link('https://api.github.com', token || ''), - cache: new InMemoryCache, + link: link(graphQLBaseUrl, token || ''), + cache: new InMemoryCache(), defaultOptions: { query: { - fetchPolicy: 'no-cache' - } - } + fetchPolicy: 'no-cache', + }, + }, }); + const rateLogger = new RateLogger(this._telemetry, isEnterprise(authProviderId), (_e) => { + void this.handleAuthError(authProviderId); + }); const github: GitHub = { - octokit, - graphql + octokit: new LoggingOctokit(octokit, rateLogger), + graphql: new LoggingApolloClient(graphql, rateLogger), }; - await this.setCurrentUser(github); + this.setCurrentUser(github); return github; } } const link = (url: string, token: string) => - setContext((_, { headers }) => (({ + setContext((_, { headers }) => ({ headers: { ...headers, authorization: token ? `Bearer ${token}` : '', - Accept: 'application/vnd.github.shadow-cat-preview+json, application/vnd.github.antiope-preview+json' - } - }))).concat(createHttpLink({ - uri: `${url}/graphql`, - // https://github.com/apollographql/apollo-link/issues/513 - fetch: fetch as any - })); \ No newline at end of file + Accept: 'application/vnd.github.merge-info-preview' + }, + })).concat( + createHttpLink({ + uri: `${url}/graphql`, + // https://github.com/apollographql/apollo-link/issues/513 + fetch: fetch as (((input: URL | string, init?: RequestInit) => Promise) | undefined), + }), + ); + +function getGitHubSuffix(authProviderId: AuthProvider) { + 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/fileUpload.ts b/src/github/fileUpload.ts new file mode 100644 index 0000000000..0f6ed116ba --- /dev/null +++ b/src/github/fileUpload.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 buffer from 'buffer'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { GitHubRepository } from './githubRepository'; +import Logger from '../common/logger'; +import { formatError } from '../common/utils'; + +export interface FileUploadPlaceholder { + uri: vscode.Uri; + name: string; + placeholder: string; +} + +export interface PendingFileUpload { + name: string; + placeholder: string; + getBytes(): Thenable; +} + +/** + * Decode a base64 string to a {@linkcode Uint8Array}. + */ +export function decodeBase64(input: string): Uint8Array { + return buffer.Buffer.from(input, 'base64'); +} + +/** + * Guess a file extension (including the dot) for a given MIME type, falling back + * to an empty string when no good guess is available. + */ +export function guessExtensionFromMime(mimeType: string): string { + const lower = mimeType.toLowerCase(); + switch (lower) { + case 'image/png': return '.png'; + case 'image/jpeg': return '.jpg'; + case 'image/gif': return '.gif'; + case 'image/webp': return '.webp'; + case 'image/svg+xml': return '.svg'; + case 'image/bmp': return '.bmp'; + case 'image/heic': return '.heic'; + case 'video/mp4': return '.mp4'; + case 'video/quicktime': return '.mov'; + case 'video/webm': return '.webm'; + case 'application/pdf': return '.pdf'; + case 'application/zip': return '.zip'; + case 'application/json': return '.json'; + case 'text/plain': return '.txt'; + case 'text/markdown': return '.md'; + default: return ''; + } +} + +/** + * Compute placeholder strings for the given file names, deduplicating + * by name with `(2)`, `(3)` suffixes. + */ +export function placeholdersForNames(names: readonly string[]): { name: string; placeholder: string }[] { + const used = new Map(); + return names.map(name => { + const count = used.get(name) ?? 0; + used.set(name, count + 1); + const placeholder = count === 0 + ? `` + : ``; + return { name, placeholder }; + }); +} + +/** + * Prompt the user for files to upload and compute the placeholder text that + * should be inserted into a comment textarea while the uploads run. + * Returns `undefined` when the user cancels. + */ +export async function pickFilesForUpload(): Promise { + const fileUris = await vscode.window.showOpenDialog({ + canSelectMany: true, + canSelectFiles: true, + canSelectFolders: false, + openLabel: vscode.l10n.t('Upload'), + title: vscode.l10n.t('Select files to upload'), + }); + if (!fileUris || fileUris.length === 0) { + return undefined; + } + + const names = fileUris.map(uri => path.basename(uri.fsPath)); + const placeholders = placeholdersForNames(names); + return fileUris.map((uri, i) => ({ uri, name: placeholders[i].name, placeholder: placeholders[i].placeholder })); +} + +/** + * Maximum number of file uploads to run in parallel. Limiting concurrency + * avoids memory and network spikes when many files are uploaded at once. + */ +const MAX_CONCURRENT_UPLOADS = 3; + +/** + * Run the actual file uploads with limited concurrency, invoking the supplied + * callbacks as each upload finishes (or fails). + */ +export function runFileUploads( + githubRepository: GitHubRepository, + uploads: readonly FileUploadPlaceholder[], + logId: string, + onComplete: (placeholder: string, name: string, markdown: string) => void | Promise, + onError: (placeholder: string, name: string, error: string) => void | Promise, +): void { + runPendingUploads( + githubRepository, + uploads.map(u => ({ + name: u.name, + placeholder: u.placeholder, + getBytes: () => vscode.workspace.fs.readFile(u.uri), + })), + logId, + onComplete, + onError, + ); +} + +/** + * Run uploads in parallel, fetching the bytes lazily via {@linkcode PendingFileUpload.getBytes}. + */ +export function runPendingUploads( + githubRepository: GitHubRepository, + uploads: readonly PendingFileUpload[], + logId: string, + onComplete: (placeholder: string, name: string, markdown: string) => void | Promise, + onError: (placeholder: string, name: string, error: string) => void | Promise, +): void { + let next = 0; + + const runOne = async (): Promise => { + while (next < uploads.length) { + const u = uploads[next++]; + (async () => { + const bytes = await u.getBytes(); + return githubRepository.uploadFileBytes(bytes, u.name); + })().then(markdown => { + return onComplete(u.placeholder, u.name, markdown); + }).catch(err => { + Logger.error(`Failed to upload file ${u.name}: ${formatError(err)}`, logId); + return onError(u.placeholder, u.name, formatError(err)); + }); + } + }; + + const workerCount = Math.min(MAX_CONCURRENT_UPLOADS, uploads.length); + for (let i = 0; i < workerCount; i++) { + void runOne(); + } +} diff --git a/src/github/folderRepositoryManager.ts b/src/github/folderRepositoryManager.ts index 6dd20ebd72..9b4497d244 100644 --- a/src/github/folderRepositoryManager.ts +++ b/src/github/folderRepositoryManager.ts @@ -3,30 +3,115 @@ * 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 * as path from 'path'; +import { OctokitCommon } from './common'; +import { ConflictModel } from './conflictGuide'; +import { ConflictResolutionCoordinator } from './conflictResolutionCoordinator'; +import { Conflict, ConflictResolutionModel } from './conflictResolutionModel'; import { CredentialStore } from './credentials'; -import { Remote, parseRepositoryRemotes } from '../common/remote'; -import { TimelineEvent, EventType } from '../common/timelineEvent'; -import { GitHubRepository, PullRequestData, ItemsData, ViewerPermission } from './githubRepository'; -import { IPullRequestsPagingOptions, PRType, IAccount, ILabel, RepoAccessAndMergeMethods, User } from './interface'; +import { CopilotWorkingStatus, GitHubRepository, isRateLimitError, ItemsData, PULL_REQUEST_PAGE_SIZE, PullRequestChangeEvent, PullRequestData, TeamReviewerRefreshKind, ViewerPermission } from './githubRepository'; +import { 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 { PullRequestModel } from './pullRequestModel'; +import { IResolvedPullRequestModel, PullRequestModel } from './pullRequestModel'; +import { + convertRESTIssueToRawPullRequest, + convertRESTPullRequestToRawPullRequest, + getOverrideBranch, + getPRFetchQuery, + getStateFromQuery, + loginComparator, + 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 { formatError, Predicate } from '../common/utils'; -import { Repository, RefType, UpstreamRef } from '../api/api'; +import { AuthProvider, GitHubServerType } from '../common/authentication'; +import { commands, contexts } from '../common/executeCommands'; +import { InMemFileChange, SlimFileChange } from '../common/file'; +import { findLocalRepoRemoteFromGitHubRef } from '../common/githubRef'; +import { stripCoAuthoredByTrailers, unwrapCommitMessageBody } from '../common/gitUtils'; +import { Disposable, disposeAll } from '../common/lifecycle'; import Logger from '../common/logger'; -import { EXTENSION_ID } from '../constants'; -import { fromPRUri } from '../common/uri'; -import { convertRESTPullRequestToRawPullRequest, getRelatedUsersFromTimelineEvents, convertRESTUserToAccount, loginComparator, convertRESTIssueToRawPullRequest, parseGraphQLUser } from './utils'; -import { PullRequestState, UserResponse } from './graphql'; +import { Protocol, ProtocolType } from '../common/protocol'; +import { GitHubRemote, parseRemote, parseRepositoryRemotesAsync, Remote } from '../common/remote'; +import { + ALLOW_FETCH, + AUTO_STASH, + CHAT_SETTINGS_NAMESPACE, + CHECKOUT_DEFAULT_BRANCH, + CHECKOUT_PULL_REQUEST_BASE_BRANCH, + DEFAULT_DELETION_METHOD, + DISABLE_AI_FEATURES, + GIT, + POST_DONE, + PR_SETTINGS_NAMESPACE, + PULL_BEFORE_CHECKOUT, + PULL_BRANCH, + REMOTES, + SELECT_WORKTREE, + UPSTREAM_REMOTE, +} from '../common/settingKeys'; import { ITelemetry } from '../common/telemetry'; -import { ApiImpl } from '../api/api1'; -import { Protocol } from '../common/protocol'; -import { IssueModel } from './issueModel'; -import { MilestoneModel } from './milestoneModel'; -import { userMarkdown, UserCompletion } from '../issues/util'; -import { OctokitCommon } from './common'; +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; @@ -37,51 +122,50 @@ export interface ItemsResponseResult { items: T[]; hasMorePages: boolean; hasUnsearchedRepositories: boolean; + totalCount?: number; } export class NoGitHubReposError extends Error { - constructor(public repository: Repository) { + constructor(public readonly repository: Repository) { super(); } - get message() { - return `${this.repository.rootUri.toString()} has no GitHub remotes`; + override get message() { + return vscode.l10n.t('{0} has no GitHub remotes', this.repository.rootUri.toString()); } } export class DetachedHeadError extends Error { - constructor(public repository: Repository) { + constructor(public readonly repository: Repository) { super(); } - get message() { - return `${this.repository.rootUri.toString()} has a detached HEAD (create a branch first)`; + 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 branchName: string, - public upstreamRef: UpstreamRef, - public problem: string) { + constructor(public readonly branchName: string, public readonly upstreamRef: UpstreamRef, public readonly problem: string) { super(); } - get message() { - const { upstreamRef: { remote, name }, branchName, problem } = this; - return `The upstream ref ${remote}/${name} for branch ${branchName} ${problem}.`; + 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 SETTINGS_NAMESPACE = 'githubPullRequests'; -export const REMOTES_SETTING = 'remotes'; - export const ReposManagerStateContext: string = 'ReposManagerStateContext'; export enum ReposManagerState { Initializing = 'Initializing', NeedsAuthentication = 'NeedsAuthentication', - RepositoriesLoaded = 'RepositoriesLoaded' + RepositoriesLoaded = 'RepositoriesLoaded', } export interface PullRequestDefaults { @@ -90,279 +174,180 @@ export interface PullRequestDefaults { base: string; } -export const NO_MILESTONE: string = 'No Milestone'; - enum PagedDataType { PullRequest, - Milestones, - IssuesWithoutMilestone, - IssueSearch + IssueSearch, } -export class FolderRepositoryManager implements vscode.Disposable { +const CACHED_TEMPLATE_BODY = 'templateBody'; + +export class FolderRepositoryManager extends Disposable { static ID = 'FolderRepositoryManager'; - private _subs: vscode.Disposable[]; + private _state: ReposManagerState = ReposManagerState.Initializing; private _activePullRequest?: PullRequestModel; private _activeIssue?: IssueModel; private _githubRepositories: GitHubRepository[]; - private _allGitHubRemotes: Remote[] = []; + 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 = new vscode.EventEmitter(); - readonly onDidChangeActivePullRequest: vscode.Event = this._onDidChangeActivePullRequest.event; - private _onDidChangeActiveIssue = new vscode.EventEmitter(); + 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 = new vscode.EventEmitter(); + private _onDidLoadRepositories = this._register(new vscode.EventEmitter()); readonly onDidLoadRepositories: vscode.Event = this._onDidLoadRepositories.event; - private _onDidChangeRepositories = new vscode.EventEmitter(); - readonly onDidChangeRepositories: vscode.Event = this._onDidChangeRepositories.event; + private _onDidChangeRepositories = this._register(new vscode.EventEmitter<{ added: boolean }>()); + readonly onDidChangeRepositories: vscode.Event<{ added: boolean }> = this._onDidChangeRepositories.event; - private _onDidChangeAssignableUsers = new vscode.EventEmitter(); + 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(); + private _inaccessibleRepos: Set = new Set(); + constructor( + private readonly _id: number, + public readonly context: vscode.ExtensionContext, private _repository: Repository, - private readonly _telemetry: ITelemetry, - private _git: ApiImpl, - private _credentialStore: CredentialStore, + public readonly telemetry: ITelemetry, + private readonly _git: GitApiImpl, + private readonly _credentialStore: CredentialStore, + public readonly createPullRequestHelper: CreatePullRequestHelper, + public readonly themeWatcher: IThemeWatcher ) { - this._subs = []; + super(); this._githubRepositories = []; - this._githubManager = new GitHubManager(); + this._githubManager = new GitHubManager(this.telemetry); + + 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(); + } - this._subs.push(vscode.workspace.onDidChangeConfiguration(async e => { - if (e.affectsConfiguration(`${SETTINGS_NAMESPACE}.${REMOTES_SETTING}`)) { - await this.updateRepositories(); + 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); } - })); + } + } - this.setUpCompletionItemProvider(); + private get id(): string { + return `${FolderRepositoryManager.ID}+${this._id}`; } get gitHubRepositories(): GitHubRepository[] { return this._githubRepositories; } - private computeAllGitHubRemotes(): Promise { - const remotes = parseRepositoryRemotes(this.repository); + public async computeAllUnknownRemotes(): Promise { + const remotes = await parseRepositoryRemotesAsync(this.repository); const potentialRemotes = remotes.filter(remote => remote.host); - return Promise.all(potentialRemotes.map(remote => this._githubManager.isGitHub(remote.gitProtocol.normalizeUri()!))) - .then(results => potentialRemotes.filter((_, index, __) => results[index])) - .catch(e => { - Logger.appendLine(`Resolving GitHub remotes failed: ${e}`); - vscode.window.showErrorMessage(`Resolving GitHub remotes failed: ${formatError(e)}`); - return []; - }); + 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: Remote[]): Promise { - const remotesSetting = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get(REMOTES_SETTING); + public async getActiveGitHubRemotes(allGitHubRemotes: GitHubRemote[]): Promise { + const remotesSetting = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(REMOTES); if (!remotesSetting) { - Logger.appendLine(`Unable to read remotes setting`); + Logger.error(`Unable to read remotes setting`, this.id); return Promise.resolve([]); } - remotesSetting.forEach(remote => { - if (!allGitHubRemotes.some(repo => repo.remoteName === remote)) { - Logger.appendLine(`No remote with name '${remote}' found. Please update your 'githubPullRequests.remotes' setting.`); - } + const missingRemotes = remotesSetting.filter(remote => { + return !allGitHubRemotes.some(repo => repo.remoteName === remote); }); - Logger.debug(`Displaying configured remotes: ${remotesSetting.join(', ')}`, FolderRepositoryManager.ID); + 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: Remote | undefined): repo is Remote => !!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 (query.extensionId !== EXTENSION_ID) { - 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 === '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 githubRepos = this._githubRepositories.filter(repo => repo.remote.remoteName === remoteName); - - if (githubRepos.length) { - lastPullRequest = await githubRepos[0].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 in blameLines) { - const matches = /^\w{11} \S*\s*\((.*)\s*\d{4}\-/.exec(blameLines[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) { - mentionableUsers[mentionableUserGroup].forEach(user => { - 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, item.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; - } - }, '@'); - + .filter((repo: GitHubRemote | undefined): repo is GitHubRemote => !!repo && !this._sessionIgnoredRemoteNames.has(repo.remoteName)); } get activeIssue(): IssueModel | undefined { @@ -379,16 +364,21 @@ export class FolderRepositoryManager implements vscode.Disposable { } 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(); + this._onDidChangeActivePullRequest.fire({ old: oldPR, new: pullRequest }); } get repository(): Repository { @@ -403,58 +393,258 @@ export class FolderRepositoryManager implements vscode.Disposable { return this._credentialStore; } - private async getActiveRemotes(): Promise { + /** + * 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'); + 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 { - await vscode.commands.executeCommand('setContext', 'github:hasGitHubRemotes', false); - Logger.appendLine('No GitHub remotes found'); + Logger.appendLine(`No GitHub remotes found for folder ${this.repository.rootUri.fsPath}`, this.id); } return activeRemotes; } - async updateRepositories(silent: boolean = false): Promise { - if (this._git.state === 'uninitialized') { - return; + 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++; + } } - const activeRemotes = await this.getActiveRemotes(); - const isAuthenticated = this._credentialStore.isAuthenticated(); + 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 resolveRemotePromises: Promise[] = []; + const oldRepositories: GitHubRepository[] = []; + this._githubRepositories.forEach(repo => oldRepositories.push(repo)); - const authenticatedRemotes = isAuthenticated ? activeRemotes : []; - authenticatedRemotes.forEach(remote => { - const repository = this.createGitHubRepository(remote, this._credentialStore); + 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); + } + } - return Promise.all(resolveRemotePromises).then(_ => { - const oldRepositories = this._githubRepositories; + disposeAll(this._onDidChangePullRequestsEvents); this._githubRepositories = repositories; - oldRepositories.forEach(repo => repo.dispose()); + 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; + } + } - const repositoriesChanged = oldRepositories.length !== this._githubRepositories.length - || !oldRepositories.every(oldRepo => this._githubRepositories.some(newRepo => newRepo.remote.equals(oldRepo.remote))); + if (this.activePullRequest) { + this.getMentionableUsers(repositoriesAdded.length > 0); + } - this.getMentionableUsers(repositoriesChanged); - this.getAssignableUsers(repositoriesChanged); - this._onDidLoadRepositories.fire(isAuthenticated || !activeRemotes.length ? ReposManagerState.RepositoriesLoaded : ReposManagerState.NeedsAuthentication); + 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(); + this._onDidChangeRepositories.fire({ added: repositoriesAdded.length > 0 }); } - return Promise.resolve(); + 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[] = []; @@ -464,6 +654,79 @@ export class FolderRepositoryManager implements vscode.Disposable { 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[] }> { @@ -472,27 +735,18 @@ export class FolderRepositoryManager implements vscode.Disposable { } if (this._mentionableUsers) { + Logger.appendLine('Using in-memory cached mentionable users.', this.id); return this._mentionableUsers; } - if (!this._fetchMentionableUsersPromise) { - const cache: { [key: string]: IAccount[] } = {}; - return this._fetchMentionableUsersPromise = new Promise((resolve) => { - const promises = this._githubRepositories.map(async githubRepository => { - const data = await githubRepository.getMentionableUsers(); - cache[githubRepository.remote.remoteName] = data; - return; - }); + const globalStateMentionableUsers = await this.getCachedFromGlobalState('mentionableUsers'); - Promise.all(promises).then(() => { - this._mentionableUsers = cache; - this._fetchMentionableUsersPromise = undefined; - resolve(cache); - }); - }); + if (!this._fetchMentionableUsersPromise) { + this._fetchMentionableUsersPromise = this.createFetchMentionableUsersPromise(); + return globalStateMentionableUsers ?? this._fetchMentionableUsersPromise; } - return this._fetchMentionableUsersPromise; + return globalStateMentionableUsers ?? this._fetchMentionableUsersPromise; } async getAssignableUsers(clearCache?: boolean): Promise<{ [key: string]: IAccount[] }> { @@ -501,13 +755,16 @@ export class FolderRepositoryManager implements vscode.Disposable { } 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[] = []; - return this._fetchAssignableUsersPromise = new Promise((resolve) => { + this._fetchAssignableUsersPromise = new Promise(resolve => { const promises = this._githubRepositories.map(async githubRepository => { const data = await githubRepository.getAssignableUsers(); cache[githubRepository.remote.remoteName] = data.sort(loginComparator); @@ -518,74 +775,309 @@ export class FolderRepositoryManager implements vscode.Disposable { 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; + return globalStateAssignableUsers ?? this._fetchAssignableUsersPromise; } - /** - * 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 - */ - getGitHubRemotes(): Remote[] { - const githubRepositories = this._githubRepositories; - - if (!githubRepositories || !githubRepositories.length) { - return []; + async getTeamReviewers(refreshKind: TeamReviewerRefreshKind): Promise<{ [key: string]: ITeam[] }> { + if (refreshKind === TeamReviewerRefreshKind.Force) { + delete this._teamReviewers; } - return githubRepositories.map(repository => repository.remote); - } - - /** - * 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 []; + if (this._teamReviewers) { + Logger.appendLine('Using in-memory cached team reviewers.', this.id); + return this._teamReviewers; } - const localBranches = this.repository.state.refs - .filter(r => r.type === RefType.Head && r.name !== undefined) - .map(r => r.name!); - - const promises = localBranches.map(async localBranchName => { - const matchingPRMetadata = await PullRequestGitHelper.getMatchingPullRequestMetadataForBranch(this.repository, localBranchName); + const globalStateTeamReviewers = (refreshKind === TeamReviewerRefreshKind.Force) ? undefined : await this.getCachedFromGlobalState('teamReviewers'); + if (globalStateTeamReviewers) { + this._teamReviewers = globalStateTeamReviewers; + return globalStateTeamReviewers || {}; + } - if (matchingPRMetadata) { - const { owner, prNumber } = matchingPRMetadata; - const githubRepo = githubRepositories.find(repo => repo.remote.owner.toLocaleLowerCase() === owner.toLocaleLowerCase()); + 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); + } - if (githubRepo) { - const pullRequest: PullRequestModel | undefined = await githubRepo.getPullRequest(prNumber); + this._teamReviewers = cache; + this._fetchTeamReviewersPromise = undefined; + this.saveInGlobalState('teamReviewers', cache); + resolve(cache); + })); + } - if (pullRequest) { - pullRequest.localBranchName = localBranchName; - return pullRequest; + 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) ?? []; } - return Promise.resolve(null); + 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(); - return Promise.all(promises).then(values => { - return values.filter(value => value !== null) as PullRequestModel[]; + 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, 'FolderRepositoryManager.getLocalPullRequests'); + + 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); + 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.`); } @@ -596,18 +1088,22 @@ export class FolderRepositoryManager implements vscode.Disposable { let results: ILabel[] = []; do { - const result = await octokit.issues.listLabelsForRepo({ + const result = await octokit.call(octokit.api.issues.listLabelsForRepo, { owner: remote.owner, repo: remote.repositoryName, - page + per_page: 100, + page, }); - results = results.concat(result.data.map(label => { - return { - name: label.name, - color: label.color - }; - })); + 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)); @@ -637,8 +1133,9 @@ export class FolderRepositoryManager implements vscode.Disposable { 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); + const hasOtherAssociatedBranches = configs.some( + ({ key, value }) => /^branch.*\.remote$/.test(key) && value === remoteName, + ); if (!hasOtherAssociatedBranches) { await this.repository.removeRemote(remoteName); @@ -648,7 +1145,7 @@ export class FolderRepositoryManager implements vscode.Disposable { /* __GDPR__ "branch.delete" : {} */ - this._telemetry.sendTelemetryEvent('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. @@ -665,39 +1162,58 @@ export class FolderRepositoryManager implements vscode.Disposable { * 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) { + 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 + hasUnsearchedRepositories: false, + totalCount: 0 }; } const getTotalFetchedPages = () => this.totalFetchedPages.get(queryId) || 0; const setTotalFetchedPages = (numPages: number) => this.totalFetchedPages.set(queryId, numPages); - for (const repository of this._githubRepositories) { + 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 + hasMorePages: null, }); } } let pagesFetched = 0; - const itemData: ItemsData = { hasMorePages: false, items: [] }; - const addPage = (page: PullRequestData | undefined) => { + 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); @@ -706,25 +1222,30 @@ export class FolderRepositoryManager implements vscode.Disposable { for (let i = 0; i < githubRepositories.length; i++) { const githubRepository = githubRepositories[i]; const remoteId = githubRepository.remote.url.toString() + queryId; - const pageInformation = this._repositoryPageInformation.get(remoteId)!; - - const fetchPage = async (pageNumber: number): Promise<{ items: any[], hasMorePages: boolean } | undefined> => { + 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 githubRepository.getPullRequestsForCategory(query || '', pageNumber); + return this.getPullRequestsForCategory(githubRepository, 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, query); + return githubRepository.getIssues(pageInformation.pullRequestPage, resolvedQuery); } } }; @@ -743,21 +1264,24 @@ export class FolderRepositoryManager implements vscode.Disposable { } const pages = await Promise.all( - Array.from({ length: pageInformation.pullRequestPage }).map((_, j) => fetchPage(j + 1))); + Array.from({ length: pageInformation.pullRequestPage }).map((_, j) => fetchPage(j + 1)), + ); pages.forEach(page => addPage(page)); } pageInformation.hasMorePages = itemData.hasMorePages; - // Break early if + // 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). - if ( - itemData.items.length && - (options.fetchNextPage === true || - (options.fetchNextPage === false && pagesFetched >= getTotalFetchedPages())) - ) { + // 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); @@ -766,88 +1290,314 @@ export class FolderRepositoryManager implements vscode.Disposable { return { items: itemData.items, hasMorePages: pageInformation.hasMorePages, - hasUnsearchedRepositories: i < githubRepositories.length - 1 + hasUnsearchedRepositories: i < githubRepositories.length - 1, + totalCount: itemData.totalCount, }; } } return { - items: [], - hasMorePages: false, - hasUnsearchedRepositories: false + items: itemData.items, + hasMorePages: itemData.hasMorePages, + hasUnsearchedRepositories: false, + totalCount: itemData.totalCount }; } - async getPullRequests(type: PRType, options: IPullRequestsPagingOptions = { fetchNextPage: false }, query?: string): Promise> { + async getPullRequestsForCategory(githubRepository: GitHubRepository, categoryQuery: string, page?: number): Promise { + try { + Logger.debug(`Fetch pull request category ${categoryQuery} - enter`, this.id); + const { octokit } = 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: PullRequestModel | undefined, repo: GitHubRepository } | undefined>[] = data.items.map(async (item) => { + const protocol = new Protocol(item.repository_url); + + const prRepo = await this.createGitHubRepositoryFromOwnerName(protocol.owner, protocol.repositoryName); + if (!prRepo) { + return undefined; + } + const data = await prRepo.getPullRequest(item.number, 'FolderRepositoryManager.getPullRequestsForCategory', false, true); + 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) { + Logger.appendLine('Pull request doesn\'t appear to exist.', this.id); + return null; + } + + return response.data; + }))) + .filter(item => item !== null) as PullRequestModel[]; + + // GitHub's search API can return stale results whose state doesn't match the query. + // Post-filter to ensure only PRs with the requested state are included. + const queryState = getStateFromQuery(categoryQuery); + const filteredPullRequests = queryState + ? pullRequests.filter(pr => pr.state === queryState) + : pullRequests; + + Logger.debug(`Fetch pull request category ${categoryQuery} - done`, this.id); + + return { + items: filteredPullRequests, + hasMorePages, + totalCount: data.total_count - (pullRequests.length - filteredPullRequests.length) + }; + } 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 getMilestones(options: IPullRequestsPagingOptions = { fetchNextPage: false }, includeIssuesWithoutMilstone: boolean = false, query?: string): Promise> { - const milestones: ItemsResponseResult = await this.fetchPagedData(options, 'issuesKey', PagedDataType.Milestones, PRType.All, query); - if (includeIssuesWithoutMilstone) { - 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 + 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; } - return milestones; } - async getIssues(options: IPullRequestsPagingOptions = { fetchNextPage: false }, query?: string): Promise> { - return this.fetchPagedData(options, 'issuesKey', PagedDataType.IssueSearch, PRType.All, query); + 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); + } - 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); + /** + * 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(repository: Repository): Promise { + const remoteNames = new Set(repository.state.remotes.map(remote => remote.name)); + const ghRepo = this._githubRepositories.find(repo => remoteNames.has(repo.remote.remoteName)); + if (!ghRepo) { + return 0; + } + + const reposToCheck: GitHubRepository[] = [ghRepo]; + const metadata = await ghRepo.getMetadata(); + if (metadata.fork) { + for (const repo of this._githubRepositories) { + if (repo !== ghRepo && repo.remote.repositoryName === ghRepo.remote.repositoryName) { + reposToCheck.push(repo); + } + } + } + + const maxIssues = await Promise.all(reposToCheck.map(repo => repo.getMaxIssue())); + let max = 0; + for (const value of maxIssues) { + if (value !== undefined) { + max = Math.max(max, value); } } 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 templatesPattern1 = await vscode.workspace.findFiles(new vscode.RelativePattern(this._repository.rootUri.path, '{pull_request_template,PULL_REQUEST_TEMPLATE}.md')); - const templatesPattern2 = await vscode.workspace.findFiles(new vscode.RelativePattern(this._repository.rootUri.path, '{docs,.github}/{pull_request_template,PULL_REQUEST_TEMPLATE}.md')); + 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; + } - const templatesPattern3 = await vscode.workspace.findFiles(new vscode.RelativePattern(this._repository.rootUri.path, 'PULL_REQUEST_TEMPLATE/*.md')); - const templatesPattern4 = await vscode.workspace.findFiles(new vscode.RelativePattern(this._repository.rootUri.path, '{docs,.github}/PULL_REQUEST_TEMPLATE/*.md')); + 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]; + } + } - return [...templatesPattern1, ...templatesPattern2, ...templatesPattern3, ...templatesPattern4]; + 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(): Promise { - if (!this.repository.state.HEAD) { + async getPullRequestDefaults(branch?: Branch): Promise { + if (!branch && !this.repository.state.HEAD) { throw new DetachedHeadError(this.repository); } - const origin = await this.getOrigin(); + + const origin = await this.getOrigin(branch); const meta = await origin.getMetadata(); - const parent = meta.fork + 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, + owner: parent.owner!.login, repo: parent.name, - base: parent.default_branch + 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(); @@ -863,12 +1613,36 @@ export class FolderRepositoryManager implements vscode.Disposable { return ''; } - async getOrigin(): Promise { + 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 } = this; + 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)); @@ -878,17 +1652,14 @@ export class FolderRepositoryManager implements vscode.Disposable { if (!upstream) { const remote = (await this.getAllGitHubRemotes()).find(r => r.remoteName === upstreamRef.remote); if (remote) { - return new GitHubRepository(remote, this._credentialStore, this._telemetry); + return this.createAndAddGitHubRepository(remote, this._credentialStore); } - vscode.window.showErrorMessage(`The remote '${upstreamRef.remote}' is not a GitHub repository.`); + 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'); + throw new BadUpstreamError(this.repository.state.HEAD!.name!, upstreamRef, 'is not a GitHub repo'); } // Otherwise, we'll push upstream. @@ -897,11 +1668,11 @@ export class FolderRepositoryManager implements vscode.Disposable { // If no upstream is set, let's go digging. const [first, ...rest] = this._githubRepositories; - return !rest.length // Is there only one GitHub remote? + return !rest.length // Is there only one GitHub remote? ? first // I GUESS THAT'S WHAT WE'RE GOING WITH, THEN. - : // Otherwise, let's try... + : // Otherwise, let's try... this.findRepo(byRemoteName('origin')) || // by convention - this.findRepo(ownedByMe) || // bc maybe we can push there + await this.findRepoAsync(ownedByMe) || // bc maybe we can push there first; // out of raw desperation } @@ -909,33 +1680,33 @@ export class FolderRepositoryManager implements vscode.Disposable { return this._githubRepositories.filter(where)[0]; } + findRepoAsync(where: AsyncPredicate): Promise { + return (async () => { + for (const repo of this._githubRepositories) { + if (await where(repo)) { + return repo; + } + } + return undefined; + })(); + } + get upstreamRef(): UpstreamRef | undefined { const { HEAD } = this.repository.state; return HEAD && HEAD.upstream; } async createPullRequest(params: OctokitCommon.PullsCreateParams): 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(); - - const { title, body } = titleAndBodyFrom(await this.getHeadCommitMessage()); - if (!params.title) { - params.title = title; - } - - if (!params.body) { - params.body = body; - } + 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}`); + } - // Create PR - const { data } = await repo.octokit.pulls.create(params); - const item = convertRESTPullRequestToRawPullRequest(data, repo); - const pullRequestModel = new PullRequestModel(this._telemetry, repo, repo.remote, item, true); + let pullRequestModel: PullRequestModel | undefined; + try { + pullRequestModel = await repo.createPullRequest(params); const branchNameSeparatorIndex = params.head.indexOf(':'); const branchName = params.head.slice(branchNameSeparatorIndex + 1); @@ -946,28 +1717,71 @@ export class FolderRepositoryManager implements vscode.Disposable { "isDraft" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } } */ - this._telemetry.sendTelemetryEvent('pr.create.success', { isDraft: (params.draft || '').toString() }); + this.telemetry.sendTelemetryEvent('pr.create.success', { isDraft: (params.draft || '').toString() }); return pullRequestModel; } catch (e) { - Logger.appendLine(`GitHubRepository> Creating pull requests failed: ${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" }, - "message" : { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth" } + "isDraft" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } } */ - this._telemetry.sendTelemetryErrorEvent('pr.create.failure', { + this.telemetry.sendTelemetryErrorEvent('pr.create.failure', { isDraft: (params.draft || '').toString(), - message: formatError(e) }); - vscode.window.showWarningMessage(`Creating pull requests for '${params.head}' failed: ${formatError(e)}`); + + 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); + 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}`); } @@ -975,97 +1789,72 @@ export class FolderRepositoryManager implements vscode.Disposable { await repo.ensure(); // Create PR - const { data } = await repo.octokit.issues.create(params); + 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); + const issueModel = new IssueModel(this.telemetry, repo, repo.remote, item); /* __GDPR__ "issue.create.success" : { } */ - this._telemetry.sendTelemetryEvent('issue.create.success'); + this.telemetry.sendTelemetryEvent('issue.create.success'); return issueModel; } catch (e) { - Logger.appendLine(`GitHubRepository> Creating issue failed: ${e}`); + Logger.error(` Creating issue failed: ${e}`, this.id); /* __GDPR__ - "issue.create.failure" : { - "message" : { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth" } - } + "issue.create.failure" : {} */ - this._telemetry.sendTelemetryErrorEvent('issue.create.failure', { - message: formatError(e) - }); - vscode.window.showWarningMessage(`Creating issue failed: ${formatError(e)}`); + this.telemetry.sendTelemetryErrorEvent('issue.create.failure'); + vscode.window.showWarningMessage(vscode.l10n.t('Creating issue failed: {0}', formatError(e))); } - } - getCurrentUser(issueModel: IssueModel): IAccount { - return convertRESTUserToAccount(this._credentialStore.getCurrentUser(), issueModel.githubRepository); + return undefined; } - async mergePullRequest(pullRequest: PullRequestModel, title?: string, description?: string, method?: 'merge' | 'squash' | 'rebase'): Promise { - const { octokit, remote } = await pullRequest.githubRepository.ensure(); + 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}`, + ); + } - 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; + await repo.ensure(); - if (activePRSHA === mergingPRSHA) { - // We're on the branch of the pr being merged. + 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); - if (workingDirectorySHA !== mergingPRSHA) { - // We are looking at different commit than what will be merged - const { ahead } = this.repository.state.HEAD!; - if (ahead && - await vscode.window.showWarningMessage( - `You have ${ahead} unpushed ${ahead > 1 ? 'commits' : 'commit'} on this PR branch.\n\nWould you like to proceed anyway?`, - { modal: true }, - 'Yes') === undefined - ) { - return { - merged: false, - message: 'unpushed changes' - }; + /* __GDPR__ + "issue.assign.success" : { } - } + */ + this.telemetry.sendTelemetryEvent('issue.assign.success'); + } catch (e) { + Logger.error(`Assigning issue failed: ${e}`, this.id); - if (workingDirectoryIsDirty) { - // We have made changes to the PR that are not committed - if (await vscode.window.showWarningMessage( - 'You have uncommitted changes on this PR branch.\n\n Would you like to proceed anyway?', { modal: true }, 'Yes') === undefined) { - return { - merged: false, - message: 'uncommitted changes' - }; + /* __GDPR__ + "issue.assign.failure" : { } - } + */ + this.telemetry.sendTelemetryErrorEvent('issue.assign.failure'); + vscode.window.showWarningMessage(vscode.l10n.t('Assigning issue failed: {0}', formatError(e))); } + } - return await octokit.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'); - return x.data; - }).catch(e => { - /* __GDPR__ - "pr.merge.failure" : { - "message" : { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth" } - } - */ - this._telemetry.sendTelemetryErrorEvent('pr.merge.failure', { message: formatError(e) }); - throw e; - }); + getCurrentUser(githubRepository?: GitHubRepository): Promise { + if (!githubRepository) { + githubRepository = this.gitHubRepositories[0]; + } + return this._credentialStore.getCurrentUser(githubRepository.remote.authProviderId); } async deleteBranch(pullRequest: PullRequestModel) { @@ -1073,8 +1862,12 @@ export class FolderRepositoryManager implements vscode.Disposable { } private async getBranchDeletionItems() { + interface BranchDeletionMetadata extends PullRequestMetadata { + isOpen?: boolean; + } + const allConfigs = await this.repository.getConfigs(); - const branchInfos: Map = new Map(); + const branchInfos: Map = new Map(); allConfigs.forEach(config => { const key = config.key; @@ -1087,81 +1880,147 @@ export class FolderRepositoryManager implements vscode.Disposable { branchInfos.set(branchName, {}); } - const value = branchInfos.get(branchName); + const value = branchInfos.get(branchName)!; if (matches[2] === 'remote') { - value!['remote'] = config.value; + value['remote'] = config.value; } if (matches[2] === 'github-pr-owner-number') { const metadata = PullRequestGitHelper.parsePullRequestMetadata(config.value); - value!['metadata'] = metadata; + 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: PullRequestMetadata, legacy?: boolean })[] = []; + const actions: (vscode.QuickPickItem & { metadata: BranchDeletionMetadata[]; 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; + const activeMetadata = value.metadata.find(metadata => + metadata.owner === activePRUrl?.owner && + metadata.repositoryName === activePRUrl?.repositoryName && + metadata.prNumber === this.activePullRequest?.number + ); - if (!matchesActiveBranch) { + if (!activeMetadata) { actions.push({ label: `${key}`, - description: `${value.metadata!.repositoryName}/${value.metadata!.owner} #${value.metadata.prNumber}`, picked: false, - metadata: value.metadata! + 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 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, - } - }); + 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; + } - action.legacy = data.repository.pullRequest.state !== 'OPEN'; - } catch { } + 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; - })); + 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; - } else { - result.description = result.description + ' is still Open'; } }); return results; } - private async getRemoteDeletionItems() { - // check if there are remotes that should be cleaned + 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; url?: string; createdForPullRequest?: boolean }> = new Map(); + const remoteInfos: Map< + string, + { branches: Set; url?: string; createdForPullRequest?: boolean } + > = new Map(); newConfigs.forEach(config => { const key = config.key; @@ -1177,8 +2036,10 @@ export class FolderRepositoryManager implements vscode.Disposable { remoteInfos.set(remoteName, { branches: new Set() }); } - const value = remoteInfos.get(remoteName); - value!.branches.add(branchName); + if (!nonExistantBranches?.has(branchName)) { + const value = remoteInfos.get(remoteName); + value!.branches.add(branchName); + } } } @@ -1200,24 +2061,28 @@ export class FolderRepositoryManager implements vscode.Disposable { 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 ? '' : 'Not created by GitHub Pull Request extension'; + let description = value.createdForPullRequest ? '' : vscode.l10n.t('Not created by GitHub Pull Request extension'); if (value.url) { - description = description ? (description + ' ' + value.url) : value.url; + description = description ? `${description} ${value.url}` : value.url; } remoteItems.push({ label: key, description: description, picked: value.createdForPullRequest, - remote: key + remote: key, }); } }); @@ -1225,63 +2090,166 @@ export class FolderRepositoryManager implements vscode.Disposable { 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 => { + return new Promise(async resolve => { const quickPick = vscode.window.createQuickPick(); quickPick.canSelectMany = true; quickPick.ignoreFocusOut = true; - quickPick.placeholder = 'Choose local branches you want to delete permanently'; + 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 => result.picked); + 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 () => { - if (firstStep) { - const picks = quickPick.selectedItems; - if (picks.length) { - quickPick.busy = true; + let step: 'branches' | 'worktrees' | 'remotes' = 'branches'; + let nonExistantBranches = new Set(); + let branchPicks: readonly vscode.QuickPickItem[] = []; + + const showWorktreeStep = (worktreeItems: (vscode.QuickPickItem & { worktreePath: string })[]) => { + quickPick.canSelectMany = true; + quickPick.value = ''; + quickPick.placeholder = vscode.l10n.t('Do you want to delete the associated worktrees?'); + quickPick.items = worktreeItems; + quickPick.selectedItems = worktreeItems.filter(item => item.picked); + step = 'worktrees'; + }; + + const deleteBranchesAndShowRemoteStep = async () => { + if (branchPicks.length) { + await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Cleaning up') }, async (progress) => { try { - await Promise.all(picks.map(async pick => { - await this.repository.deleteBranch(pick.label, true); - })); - quickPick.busy = false; + await this.deleteBranches(branchPicks, nonExistantBranches, progress, branchPicks.length, 0, []); } catch (e) { quickPick.hide(); - vscode.window.showErrorMessage(`Deleting branches failed: ${e}`); + vscode.window.showErrorMessage(vscode.l10n.t('Deleting branches failed: {0} {1}', e.message, e.stderr)); } - } + }); + } - firstStep = false; - quickPick.busy = true; + 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); + step = 'remotes'; + } else { + quickPick.hide(); + } + }; - const remoteItems = await this.getRemoteDeletionItems(); + quickPick.onDidAccept(async () => { + quickPick.busy = true; + + if (step === 'branches') { + branchPicks = quickPick.selectedItems; + nonExistantBranches = new Set(); + + // Find which selected branches have worktrees (must be deleted before the branch) + const preferredWorktreeDeletion = !!vscode.workspace + .getConfiguration(PR_SETTINGS_NAMESPACE) + .get(`${DEFAULT_DELETION_METHOD}.${SELECT_WORKTREE}`); + const worktreeItems: (vscode.QuickPickItem & { worktreePath: string })[] = []; + for (const pick of branchPicks) { + const worktreeUri = this.getWorktreeForBranch(pick.label); + if (worktreeUri && !vscode.workspace.workspaceFolders?.some(folder => + folder.uri.fsPath === worktreeUri.fsPath || + (process.platform === 'win32' && folder.uri.fsPath.toLowerCase() === worktreeUri.fsPath.toLowerCase()) + )) { + worktreeItems.push({ + label: pick.label, + description: worktreeUri.fsPath, + picked: preferredWorktreeDeletion, + worktreePath: worktreeUri.fsPath, + }); + } + } - if (remoteItems) { - quickPick.placeholder = 'Choose remotes you want to delete permanently'; - quickPick.busy = false; - quickPick.items = remoteItems; - quickPick.selectedItems = remoteItems.filter(item => item.picked); + if (worktreeItems.length && this.repository.deleteWorktree) { + showWorktreeStep(worktreeItems); } else { - quickPick.hide(); + await deleteBranchesAndShowRemoteStep(); + } + } else if (step === 'worktrees') { + const picks = quickPick.selectedItems as readonly (vscode.QuickPickItem & { worktreePath: string })[]; + if (picks.length) { + await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Deleting {0} worktrees...', picks.length) }, async () => { + for (const pick of picks) { + try { + await this.removeWorktree(pick.worktreePath); + } catch (e) { + Logger.error(`Failed to delete worktree ${pick.worktreePath}: ${e}`, this.id); + } + } + }); } + await deleteBranchesAndShowRemoteStep(); } else { - // delete remotes + // batch deleting the remotes to avoid consuming all available resources const picks = quickPick.selectedItems; if (picks.length) { - quickPick.busy = true; - await Promise.all(picks.map(async pick => { - await this.repository.removeRemote(pick.label); - })); - quickPick.busy = false; + 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(() => { @@ -1290,147 +2258,716 @@ export class FolderRepositoryManager implements vscode.Disposable { }); } + 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(pullRequest: PullRequestModel): Promise { - const mergeOptions = await pullRequest.githubRepository.getRepoAccessAndMergeMethods(); + async getPullRequestRepositoryAccessAndMergeMethods( + issue: IssueModel, + ): Promise { + const mergeOptions = await issue.githubRepository.getRepoAccessAndMergeMethods(); return mergeOptions; } - async fullfillPullRequestMissingInfo(pullRequest: PullRequestModel): Promise { + 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(`Fullfill pull request missing info - start`, FolderRepositoryManager.ID); + 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.pulls.get({ + const { data } = await octokit.call(octokit.api.pulls.get, { owner: remote.owner, repo: remote.repositoryName, - pull_number: pullRequest.number + pull_number: pullRequest.number, }); pullRequest.update(convertRESTPullRequestToRawPullRequest(data, githubRepository)); } if (!pullRequest.mergeBase) { - const { data } = await octokit.repos.compareCommits({ + 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}` + head: `${pullRequest.head.repositoryCloneUrl.owner}:${pullRequest.head.ref}`, }); pullRequest.mergeBase = data.merge_base_commit.sha; } } catch (e) { - vscode.window.showErrorMessage(`Fetching Pull Request merge base failed: ${formatError(e)}`); + vscode.window.showErrorMessage(vscode.l10n.t('Fetching Pull Request merge base failed: {0}', formatError(e))); } - Logger.debug(`Fullfill pull request missing info - done`, FolderRepositoryManager.ID); + 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(); + 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 = this.createGitHubRepositoryFromOwnerName(owner, repositoryName); + githubRepo = await this.createGitHubRepositoryFromOwnerName(owner, repositoryName); } return githubRepo; } - async resolvePullRequest(owner: string, repositoryName: string, pullRequestNumber: number): Promise { + 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) { - return githubRepo.getPullRequest(pullRequestNumber); + const pr = await githubRepo.getPullRequest(pullRequestNumber, 'FolderRepositoryManager.resolvePullRequest', 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): Promise { + 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) { - return githubRepo.getIssue(pullRequestNumber, withComments); + const issue = await githubRepo.getIssue(pullRequestNumber, withComments, useCache); + Logger.trace(`Found GitHub issue repo for issue #${pullRequestNumber}: ${issue ? 'yes' : 'no'}`, this.id); + return issue; } + return undefined; } async resolveUser(owner: string, repositoryName: string, login: string): Promise { - Logger.debug(`Fetch user ${login}`, FolderRepositoryManager.ID); - const githubRepository = this.createGitHubRepositoryFromOwnerName(owner, repositoryName); - const { query, schema } = await githubRepository.ensure(); + 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 { - const { data } = await query({ - query: schema.GetUser, - variables: { - login - } - }); - return parseGraphQLUser(data); + if (remoteName) { + return this.getMatchingPullRequestMetadataFromGitHubWithRemoteName(remoteName, upstreamBranchName); + } + return this.getMatchingPullRequestMetadataFromGitHubWithUrl(branch, remoteUrl, upstreamBranchName); } catch (e) { - console.log(e); + Logger.error(`Unable to get matching pull request metadata from GitHub: ${e}`, this.id); + return null; } } - async getMatchingPullRequestMetadataForBranch() { - if (!this.repository || !this.repository.state.HEAD || !this.repository.state.HEAD.name) { + 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); + } - const matchingPullRequestMetadata = await PullRequestGitHelper.getMatchingPullRequestMetadataForBranch(this.repository, this.repository.state.HEAD.name); - return matchingPullRequestMetadata; + 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): Promise { - return await PullRequestGitHelper.checkoutExistingPullRequestBranch(this.repository, pullRequest); + 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): Promise { - await PullRequestGitHelper.fetchAndCheckout(this.repository, this._allGitHubRemotes, pullRequest); + getWorktreeForBranch(branchName: string): vscode.Uri | undefined { + const worktrees = this.repository.state.worktrees; + if (!worktrees) { + return undefined; + } + const refsHeadsPrefix = 'refs/heads/'; + const worktree = worktrees.find(wt => { + if (wt.main) { + return false; + } + const ref = wt.ref.startsWith(refsHeadsPrefix) ? wt.ref.substring(refsHeadsPrefix.length) : wt.ref; + return ref === branchName; + }); + return worktree ? vscode.Uri.file(worktree.path) : undefined; + } + + async removeWorktree(worktreePath: string): Promise { + if (!this.repository.deleteWorktree) { + Logger.error(`deleteWorktree is not available on this repository`, this.id); + return; + } + try { + await this.repository.deleteWorktree(worktreePath); + } catch (e) { + Logger.error(`Failed to remove worktree ${worktreePath}: ${e}`, this.id); + throw e; + } + } + + 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); } - createGitHubRepository(remote: Remote, credentialStore: CredentialStore): GitHubRepository { - return new GitHubRepository(remote, credentialStore, this._telemetry); + 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 githubRepoResults = await Promise.all([this.createGitHubRepositoryFromOwnerName(pullRequest.head!.owner, pullRequest.head!.repositoryCloneUrl.repositoryName), this.createGitHubRepositoryFromOwnerName(pullRequest.base.owner, pullRequest.base.repositoryCloneUrl.repositoryName)]); + const githubRepos = githubRepoResults.filter((r): r is GitHubRepository => !!r); + 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 && !pullRequest.githubRepository.remote.isEnterprise) { + 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, 'FolderRepositoryManager.fetchById'); + 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)), + ); } - createGitHubRepositoryFromOwnerName(owner: string, name: string): GitHubRepository { - const uri = `https://github.com/${owner}/${name}`; - return new GitHubRepository(new Remote(name, uri, new Protocol(uri)), this._credentialStore, this._telemetry); + 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; } - async findUpstreamForItem(item: {remote: Remote, githubRepository: GitHubRepository}): Promise<{ needsFork: boolean, upstream?: GitHubRepository, remote?: Remote }> { + 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 repoKey = `${owner.toLowerCase()}/${repositoryName.toLowerCase()}`; + if (this._inaccessibleRepos.has(repoKey)) { + Logger.debug(`Skipping inaccessible repository: ${owner}/${repositoryName}`, this.id); + return undefined; + } + const gitRemotes = await parseRepositoryRemotesAsync(this.repository); + const gitRemote = gitRemotes.find(r => r.owner === owner && r.repositoryName === repositoryName); + const uri = gitRemote?.url ?? `https://github.com/${owner}/${repositoryName}`; + const repo = await this.createAndAddGitHubRepository(new Remote(gitRemote?.remoteName ?? repositoryName, uri, new Protocol(uri)), this._credentialStore); + let reason: string; + try { + await repo.getMetadata(); + return repo; + } catch (e) { + reason = 'error'; + Logger.appendLine(`Repository ${owner}/${repositoryName} is not accessible: ${e}`, this.id); + } + Logger.appendLine(`Repository ${owner}/${repositoryName} is not accessible.`, this.id); + this._inaccessibleRepos.add(repoKey); + this.removeGitHubRepository(repo.remote); + /* __GDPR__ + "repository.inaccessible" : { + "hasLocalRemote" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "reason" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this.telemetry.sendTelemetryEvent('repository.inaccessible', { hasLocalRemote: (!!gitRemote).toString(), reason }); + return undefined; + } + + 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)) { + 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 === item.remote.owner) && - (forkDetails.parent.name === item.remote.repositoryName)) { + 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)) { + if ( + foundforkPermission === ViewerPermission.Admin || + foundforkPermission === ViewerPermission.Maintain || + foundforkPermission === ViewerPermission.Write + ) { existingForkRemote = githubRepo.remote; break; } @@ -1439,31 +2976,44 @@ export class FolderRepositoryManager implements vscode.Disposable { let needsFork = false; if (upstream && !existingForkRemote) { const permission = await item.githubRepository.getViewerPermission(); - if ((permission === ViewerPermission.Read) || (permission === ViewerPermission.Triage) || (permission === ViewerPermission.Unknown)) { + 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: `Forking ${repoString}...` }); + 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(`Unable to create a fork of ${repoString}. Check that your GitHub credentials are correct.`); + 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: 'Adding remotes. This may take a few moments.' }); + 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 === 0) { + 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 > 0) { + if (this.gitHubRepositories.length > startingRepoCount) { disposable.dispose(); resolve(); } @@ -1473,36 +3023,162 @@ export class FolderRepositoryManager implements vscode.Disposable { } }); progress.report({ increment: 50 }); - return true; + return workingRemoteName; } - async doFork(githubRepository: GitHubRepository, repoString: string, matchingRepo: Repository): Promise { - return vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: 'Creating Fork' }, async (progress) => { - try { - return this.forkWithProgress(progress, githubRepository, repoString, matchingRepo); - } catch (e) { - vscode.window.showErrorMessage('Creating fork failed: ' + e); - } - }); + 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 { + async tryOfferToFork(githubRepository: GitHubRepository): Promise { const repoString = `${githubRepository.remote.owner}/${githubRepository.remote.repositoryName}`; - const fork = 'Fork'; - const dontFork = 'Don\'t Fork'; - const response = await vscode.window.showInformationMessage(`You don't have permission to push to ${repoString}. Do you want to fork ${repoString}? This will modify your git remotes to set \`origin\` to the fork, and \`upstream\` to ${repoString}.`, { modal: true }, fork, dontFork); + 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; + case dontFork: + return false; + default: + return undefined; } } - dispose() { - this._subs.forEach(sub => sub.dispose()); + public async publishBranch(pushRemote: Remote, branchName: string): Promise { + const githubRepo = await this.createGitHubRepository( + pushRemote, + this.credentialStore, + ); + let permission: ViewerPermission; + try { + permission = await githubRepo.getViewerPermission(); + } catch (e) { + if (isRateLimitError(e)) { + vscode.window.showErrorMessage(vscode.l10n.t('GitHub API rate limit exceeded. Please wait and try again.'), { modal: true }); + return; + } + throw e; + } + 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(); } } @@ -1523,23 +3199,24 @@ export function getEventType(text: string) { } } -const ownedByMe: Predicate = repo => { - const { currentUser = null } = repo.octokit as any; - return currentUser && repo.remote.owner === currentUser.login; +const ownedByMe: AsyncPredicate = async repo => { + return repo.isCurrentUser(repo.remote.authProviderId, repo.remote.owner); }; -const byRemoteName = (name: string): Predicate => - ({ remote: { remoteName } }) => remoteName === name; +export const byRemoteName = (name: string): Predicate => ({ remote: { remoteName } }) => + remoteName === name; -export const titleAndBodyFrom = (message: string): { title: string, body: string } => { +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 ? stripCoAuthoredByTrailers(message.slice(idxLineBreak + 1)).trim() : ''; return { - title: idxLineBreak === -1 - ? message - : message.substr(0, idxLineBreak), + title: hasBody ? message.slice(0, idxLineBreak) : message, - body: idxLineBreak === -1 - ? '' - : message.slice(idxLineBreak + 1), + body: unwrapCommitMessageBody(rawBody), }; }; diff --git a/src/github/githubRepository.ts b/src/github/githubRepository.ts index c9ef0c0059..d3aad7ed7b 100644 --- a/src/github/githubRepository.ts +++ b/src/github/githubRepository.ts @@ -1,703 +1,2305 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { Octokit } from '@octokit/rest'; -import * as OctokitTypes from '@octokit/types'; -import Logger from '../common/logger'; -import { Remote, parseRemote } from '../common/remote'; -import { IAccount, RepoAccessAndMergeMethods, IMilestone, Issue } from './interface'; -import { PullRequestModel } from './pullRequestModel'; -import { CredentialStore, GitHub } from './credentials'; -import { AuthenticationError } from '../common/authentication'; -import { QueryOptions, MutationOptions, ApolloQueryResult, NetworkStatus, FetchResult } from 'apollo-boost'; -import { PRCommentController } from '../view/prCommentController'; -import { convertRESTPullRequestToRawPullRequest, parseGraphQLPullRequest, parseGraphQLIssue, parseMilestone, parseGraphQLViewerPermission } from './utils'; -import { PullRequestResponse, MentionableUsersResponse, AssignableUsersResponse, MilestoneIssuesResponse, IssuesResponse, IssuesSearchResponse, MaxIssueResponse, ForkDetailsResponse, ViewerPermissionResponse } from './graphql'; -import { IssueModel } from './issueModel'; -import { Protocol } from '../common/protocol'; -import { ITelemetry } from '../common/telemetry'; -const defaultSchema = require('./queries.gql'); - -export const PULL_REQUEST_PAGE_SIZE = 20; - -const GRAPHQL_COMPONENT_ID = 'GraphQL'; - -export interface ItemsData { - items: any[]; - hasMorePages: boolean; -} - -export interface IssueData extends ItemsData { - items: IssueModel[]; - hasMorePages: boolean; -} - -export interface PullRequestData extends IssueData { - items: PullRequestModel[]; -} - -export interface MilestoneData extends ItemsData { - items: { milestone: IMilestone, issues: IssueModel[] }[]; - hasMorePages: boolean; -} - -export enum ViewerPermission { - Unknown = 'unknown', - Admin = 'ADMIN', - Maintain = 'MAINTAIN', - Read = 'READ', - Triage = 'TRIAGE', - Write = 'WRITE' -} - -export interface ForkDetails { - isFork: boolean; - parent: { - owner: string, - name: string - }; -} - -export interface IMetadata extends OctokitTypes.ReposGetResponseData { - currentUser: any; -} - -export class GitHubRepository implements vscode.Disposable { - static ID = 'GitHubRepository'; - protected _initialized: boolean; - protected _hub: GitHub | undefined; - protected _metadata: IMetadata; - private _toDispose: vscode.Disposable[] = []; - public commentsController?: vscode.CommentController; - public commentsHandler?: PRCommentController; - public readonly isGitHubDotCom: boolean; - - 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.'); - } - } - return this._hub; - } - - public async ensureCommentsController(): Promise { - try { - if (this.commentsController) { - return; - } - - await this.ensure(); - this.commentsController = vscode.comments.createCommentController(`browse-${this.remote.normalizedHost}`, `GitHub Pull Request for ${this.remote.normalizedHost}`); - this.commentsHandler = new PRCommentController(this.commentsController); - this._toDispose.push(this.commentsController); - this._toDispose.push(this.commentsController); - } catch (e) { - console.log(e); - } - - } - - dispose() { - this._toDispose.forEach(d => d.dispose()); - } - - public get octokit(): Octokit { - return this.hub && this.hub.octokit; - } - - constructor(public remote: Remote, private readonly _credentialStore: CredentialStore, private readonly _telemetry: ITelemetry) { - this.isGitHubDotCom = remote.host.toLowerCase() === 'github.com'; - } - - query = async (query: QueryOptions): Promise> => { - const gql = this.hub && this.hub.graphql; - if (!gql) { - Logger.debug(`Not available for query: ${query}`, GRAPHQL_COMPONENT_ID); - return { - data: null, - loading: false, - networkStatus: NetworkStatus.error, - stale: false - } as any; - } - - Logger.debug(`Request: ${JSON.stringify(query, null, 2)}`, GRAPHQL_COMPONENT_ID); - const rsp = await gql.query(query); - Logger.debug(`Response: ${JSON.stringify(rsp, null, 2)}`, GRAPHQL_COMPONENT_ID); - return rsp; - } - - mutate = async (mutation: MutationOptions): Promise> => { - const gql = 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(`Request: ${JSON.stringify(mutation, null, 2)}`, GRAPHQL_COMPONENT_ID); - const rsp = await gql.mutate(mutation); - Logger.debug(`Response: ${JSON.stringify(rsp, null, 2)}`, GRAPHQL_COMPONENT_ID); - return rsp; - } - - get schema() { - return defaultSchema; - } - - 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); - return this._metadata; - } - const { octokit, remote } = await this.ensure(); - const result = await octokit.repos.get({ - owner: remote.owner, - repo: remote.repositoryName - }); - Logger.debug(`Fetch metadata ${remote.owner}/${remote.repositoryName} - done`, GitHubRepository.ID); - this._metadata = Object.assign(result.data, { currentUser: (octokit as any).currentUser }); - return this._metadata; - } - - async resolveRemote(): Promise { - try { - const { clone_url } = await this.getMetadata(); - this.remote = parseRemote(this.remote.remoteName, clone_url, this.remote.gitProtocol)!; - } catch (e) { - Logger.appendLine(`Unable to resolve remote: ${e}`); - } - } - - async ensure(): Promise { - this._initialized = true; - - if (!this._credentialStore.isAuthenticated()) { - this._hub = await this._credentialStore.showSignInNotification(); - } else { - this._hub = await this._credentialStore.getHub(); - } - - return this; - } - - async getDefaultBranch(): Promise { - try { - Logger.debug(`Fetch default branch - enter`, GitHubRepository.ID); - const { octokit, remote } = await this.ensure(); - const { data } = await octokit.repos.get({ - owner: remote.owner, - repo: remote.repositoryName - }); - Logger.debug(`Fetch default branch - done`, GitHubRepository.ID); - - return data.default_branch; - } catch (e) { - Logger.appendLine(`GitHubRepository> Fetching default branch failed: ${e}`); - } - - return 'master'; - } - - async getRepoAccessAndMergeMethods(): Promise { - try { - Logger.debug(`Fetch repo permissions and available merge methods - enter`, GitHubRepository.ID); - const { octokit, remote } = await this.ensure(); - const { data } = await octokit.repos.get({ - owner: remote.owner, - repo: remote.repositoryName - }); - Logger.debug(`Fetch repo permissions and available merge methods - done`, GitHubRepository.ID); - - return { - // Users with push access to repo have rights to merge/close PRs, - // edit title/description, assign reviewers/labels etc. - hasWritePermission: data.permissions.push, - mergeMethodsAvailability: { - merge: data.allow_merge_commit, - squash: data.allow_squash_merge, - rebase: data.allow_rebase_merge - } - }; - } catch (e) { - Logger.appendLine(`GitHubRepository> Fetching repo permissions and available merge methods failed: ${e}`); - } - - return { - hasWritePermission: true, - mergeMethodsAvailability: { - merge: true, - squash: true, - rebase: true - } - }; - } - - async getAllPullRequests(page?: number): Promise { - try { - Logger.debug(`Fetch all pull requests - enter`, GitHubRepository.ID); - const { octokit, remote } = await this.ensure(); - const result = await octokit.pulls.list({ - owner: remote.owner, - repo: remote.repositoryName, - per_page: PULL_REQUEST_PAGE_SIZE, - page: page || 1 - }); - - const hasMorePages = !!result.headers.link && result.headers.link.indexOf('rel="next"') > -1; - if (!result.data) { - // We really don't expect this to happen, but it seems to (see #574). - // Log a warning and return an empty set. - Logger.appendLine(`Warning: no result data for ${remote.owner}/${remote.repositoryName} Status: ${result.status}`); - return { - items: [], - hasMorePages: false, - }; - } - - const pullRequests = result.data - .map( - pullRequest => { - if (!pullRequest.head.repo) { - Logger.appendLine( - 'GitHubRepository> The remote branch for this PR was already deleted.' - ); - return null; - } - - return new PullRequestModel(this._telemetry, this, this.remote, convertRESTPullRequestToRawPullRequest(pullRequest, this)); - } - ) - .filter(item => item !== null) as PullRequestModel[]; - - Logger.debug(`Fetch all pull requests - done`, GitHubRepository.ID); - return { - items: pullRequests, - hasMorePages - }; - } catch (e) { - Logger.appendLine(`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}' failed, please check if the url ${this.remote.url} is valid.`); - } else { - throw e; - } - } - } - - 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(remote, this._credentialStore, this._telemetry); - } - return githubRepository; - } - - 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.GetMilestones, - variables: { - owner: remote.owner, - name: remote.repositoryName, - assignee: this._credentialStore.getCurrentUser().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.appendLine(`GithubRepository> Unable to fetch issues: ${e}`); - 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: this._credentialStore.getCurrentUser().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.appendLine(`GithubRepository> Unable to fetch issues without milestone: ${e}`); - return; - } - } - - async getIssues(page?: number, queryString?: string): Promise { - try { - Logger.debug(`Fetch issues with query - enter`, GitHubRepository.ID); - const { query, schema } = await this.ensure(); - const { data } = await query({ - query: schema.Issues, - variables: { - query: queryString - } - }); - Logger.debug(`Fetch issues with query - done`, GitHubRepository.ID); - - const issues: IssueModel[] = []; - let githubRepository: GitHubRepository = this; - if (data && data.search.edges) { - data.search.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.search.pageInfo.hasNextPage - }; - } catch (e) { - Logger.appendLine(`GithubRepository> Unable to fetch issues with query: ${e}`); - return; - } - } - - async getMaxIssue(): Promise { - try { - Logger.debug(`Fetch max issue - enter`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - const { data } = await query({ - query: schema.MaxIssue, - variables: { - owner: remote.owner, - name: remote.repositoryName - } - }); - Logger.debug(`Fetch max issue - done`, GitHubRepository.ID); - - if (data && data.repository.issues.edges.length === 1) { - return data.repository.issues.edges[0].node.number; - } - return; - } catch (e) { - Logger.appendLine(`GithubRepository> Unable to fetch issues with query: ${e}`); - return; - } - } - - async getViewerPermission(): Promise { - try { - Logger.debug(`Fetch viewer permission - enter`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - const { data } = await query({ - query: schema.GetViewerPermission, - variables: { - owner: remote.owner, - name: remote.repositoryName - } - }); - Logger.debug(`Fetch viewer permission - done`, GitHubRepository.ID); - return parseGraphQLViewerPermission(data); - } catch (e) { - Logger.appendLine(`GithubRepository> Unable to fetch viewer permission: ${e}`); - return ViewerPermission.Unknown; - } - } - - async fork(): Promise { - try { - Logger.debug(`Fork repository`, GitHubRepository.ID); - const { octokit, remote } = await this.ensure(); - const result = await octokit.repos.createFork({ owner: remote.owner, repo: remote.repositoryName }); - return result.data.clone_url; - } catch (e) { - Logger.appendLine(`GitHubRepository> Forking repository failed: ${e}`); - return undefined; - } - } - - async getRepositoryForkDetails(): Promise { - try { - Logger.debug(`Fetch viewer permission - enter`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - const { data } = await query({ - query: schema.GetViewerPermission, - variables: { - owner: remote.owner, - name: remote.repositoryName - } - }); - Logger.debug(`Fetch viewer permission - done`, GitHubRepository.ID); - return data.repository; - } catch (e) { - Logger.appendLine(`GithubRepository> Unable to fetch viewer permission: ${e}`); - return; - } - } - - async getAuthenticatedUser(): Promise { - const { octokit } = await this.ensure(); - const user = await octokit.users.getAuthenticated({}); - return user.data.login; - } - - async getPullRequestsForCategory(categoryQuery: string, page?: number): Promise { - try { - Logger.debug(`Fetch pull request category ${categoryQuery} - enter`, GitHubRepository.ID); - const { octokit, remote } = await this.ensure(); - const user = await octokit.users.getAuthenticated({}); - // Search api will not try to resolve repo that redirects, so get full name first - const repo = await octokit.repos.get({ owner: this.remote.owner, repo: this.remote.repositoryName }); - const { data, headers } = await octokit.search.issuesAndPullRequests({ - q: this.getPRFetchQuery(repo.data.full_name, user.data.login, categoryQuery), - per_page: PULL_REQUEST_PAGE_SIZE, - page: page || 1 - }); - const promises: Promise>[] = []; - data.items.forEach((item: any /** unluckily Octokit.AnyResponse */) => { - promises.push(new Promise(async (resolve, reject) => { - const prData = await octokit.pulls.get({ - owner: remote.owner, - repo: remote.repositoryName, - pull_number: item.number - }); - resolve(prData); - })); - }); - - const hasMorePages = !!headers.link && headers.link.indexOf('rel="next"') > -1; - const pullRequestResponses = await Promise.all(promises); - - const pullRequests = pullRequestResponses.map(response => { - if (!response.data.head.repo) { - Logger.appendLine('GitHubRepository> The remote branch for this PR was already deleted.'); - return null; - } - - return new PullRequestModel(this._telemetry, this, this.remote, convertRESTPullRequestToRawPullRequest(response.data, this)); - }).filter(item => item !== null) as PullRequestModel[]; - - Logger.debug(`Fetch pull request category ${categoryQuery} - done`, GitHubRepository.ID); - - return { - items: pullRequests, - hasMorePages - }; - } catch (e) { - Logger.appendLine(`GitHubRepository> Fetching all pull requests failed: ${e}`); - 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; - } - } - } - - async getPullRequest(id: number): Promise { - try { - Logger.debug(`Fetch pull request ${id} - enter`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - - const { data } = await query({ - query: schema.PullRequest, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: id - } - }); - Logger.debug(`Fetch pull request ${id} - done`, GitHubRepository.ID); - - return new PullRequestModel(this._telemetry, this, remote, parseGraphQLPullRequest(data, this)); - } catch (e) { - Logger.appendLine(`GithubRepository> Unable to fetch PR: ${e}`); - return; - } - } - - async getIssue(id: number, withComments: boolean = false): Promise { - try { - Logger.debug(`Fetch issue ${id} - enter`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - - const { data } = await query({ - query: withComments ? schema.IssueWithComments : schema.Issue, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: id - } - }); - Logger.debug(`Fetch issue ${id} - done`, GitHubRepository.ID); - - return new IssueModel(this, remote, parseGraphQLPullRequest(data, this)); - } catch (e) { - Logger.appendLine(`GithubRepository> Unable to fetch PR: ${e}`); - return; - } - } - - async deleteBranch(pullRequestModel: PullRequestModel): Promise { - const { octokit } = await this.ensure(); - - if (!pullRequestModel.validatePullRequestModel('Unable to delete branch')) { - return; - } - - try { - await octokit.git.deleteRef({ - owner: pullRequestModel.head.repositoryCloneUrl.owner, - repo: pullRequestModel.head.repositoryCloneUrl.repositoryName, - ref: `heads/${pullRequestModel.head.ref}` - }); - } catch (e) { - Logger.appendLine(`GithubRepository> Unable to delete branch: ${e}`); - return; - } - } - - async getMentionableUsers(): Promise { - Logger.debug(`Fetch mentionable users - enter`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - - let after = null; - let hasNextPage = false; - const ret: IAccount[] = []; - - do { - try { - const result: { data: MentionableUsersResponse } = await query({ - query: schema.GetMentionableUsers, - variables: { - owner: remote.owner, - name: remote.repositoryName, - first: 100, - after: after - } - }); - - ret.push(...result.data.repository.mentionableUsers.nodes.map(node => { - return { - login: node.login, - avatarUrl: node.avatarUrl, - name: node.name, - url: node.url, - email: node.email, - }; - })); - - 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); - return ret; - } - } while (hasNextPage); - - return ret; - } - - async getAssignableUsers(): Promise { - Logger.debug(`Fetch assignable users - enter`, GitHubRepository.ID); - const { query, remote, schema } = await this.ensure(); - - let after = null; - let hasNextPage = false; - const ret: IAccount[] = []; - - do { - try { - const result: { data: AssignableUsersResponse } = await query({ - query: schema.GetAssignableUsers, - variables: { - owner: remote.owner, - name: remote.repositoryName, - first: 100, - after: after - } - }); - - ret.push(...result.data.repository.assignableUsers.nodes.map(node => { - return { - login: node.login, - avatarUrl: node.avatarUrl, - name: node.name, - url: node.url, - email: node.email, - }; - })); - - hasNextPage = result.data.repository.assignableUsers.pageInfo.hasNextPage; - after = result.data.repository.assignableUsers.pageInfo.endCursor; - } catch (e) { - Logger.debug(`Unable to fetch assignable users: ${e}`, GitHubRepository.ID); - return ret; - } - } while (hasNextPage); - - return ret; - } - - isCurrentUser(login: string): boolean { - return this._credentialStore.isCurrentUser(login); - } - - private getPRFetchQuery(repo: string, user: string, query: string) { - const filter = query.replace('${user}', user); - return `is:pull-request ${filter} type:pr repo:${repo}`; - } -} +/*--------------------------------------------------------------------------------------------- + * 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 { ApolloQueryResult, DocumentNode, FetchResult, MutationOptions, NetworkStatus, OperationVariables, QueryOptions } from 'apollo-boost'; +import LRUCache from 'lru-cache'; +import * as vscode from 'vscode'; +import { mergeQuerySchemaWithShared, OctokitCommon } from './common'; +import { CredentialStore, GitHub } from './credentials'; +import { + AssignableUsersResponse, + CheckSuiteForRollup, + CreatePullRequestResponse, + FileContentResponse, + ForkDetailsResponse, + GetBranchResponse, + GetChecksResponse, + isCheckRun, + 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, + IProject, + Issue, + ITeam, + MergeMethod, + PullRequest, + PullRequestChecks, + PullRequestCheckStatus, + PullRequestReviewRequirement, + RepoAccessAndMergeMethods, + User, +} from './interface'; +import { IssueChangeEvent, IssueModel } from './issueModel'; +import { getErrorCode, GraphQLError, 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, + 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: T[]; + hasMorePages: boolean; + totalCount?: number; +} + +export interface IssueData extends ItemsData { + items: Issue[]; + hasMorePages: boolean; +} + +export interface PullRequestData extends ItemsData { + items: PullRequestModel[]; +} + +export interface MilestoneData extends ItemsData<{ milestone: IMilestone; issues: IssueModel[] }> { + items: { milestone: IMilestone; issues: IssueModel[] }[]; + hasMorePages: boolean; +} + +export enum ViewerPermission { + Unknown = 'unknown', + Admin = 'ADMIN', + Maintain = 'MAINTAIN', + Read = 'READ', + Triage = 'TRIAGE', + Write = 'WRITE', +} + +export class RateLimitError extends Error { + constructor(message?: string) { + super(message ?? 'GitHub API rate limit exceeded'); + this.name = 'RateLimitError'; + } +} + +export function isRateLimitError(e: unknown): boolean { + if (e instanceof RateLimitError) { + return true; + } + if (e instanceof Error) { + const msg = e.message.toLowerCase(); + if (msg.includes('rate limit') || msg.includes('secondary rate') || msg.includes('abuse detection')) { + return true; + } + } + return false; +} + +export enum TeamReviewerRefreshKind { + None, + Try, + Force +} + +export interface ForkDetails { + isFork: boolean; + parent: { + owner: { + login: string; + }; + name: string; + }; +} + +export type IMetadata = OctokitCommon.ReposGetResponseData; + +export enum CopilotWorkingStatus { + NotCopilotIssue = 'NotCopilotIssue', + InProgress = 'InProgress', + Error = 'Error', + Done = 'Done', +} + +export interface PullRequestChangeEvent { + model: IssueModel; + event: IssueChangeEvent; +} + +export class GitHubRepository extends Disposable { + static ID = 'GitHubRepository'; + private static _allRepoIds: Set = new Set(); + private static _succeededPullRequests: Map> = new Map(); + protected _initialized: boolean = false; + protected _hub: GitHub | undefined; + protected _metadata: Promise | undefined; + public commentsController?: vscode.CommentController; + public commentsHandler?: PRCommentControllerRegistry; + 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; + + private static readonly MAX_ITEM_NUMBER_TTL_MS = 60 * 60 * 1000; /* 1 hour */ + private static readonly MAX_ITEM_NUMBER_BUFFER = 50; + private _maxItemNumberCache: { value: number; fetchedAt: number } | undefined; + private _maxItemNumberPromise: Promise | undefined; + get areQueriesLimited(): boolean { return this._areQueriesLimited; } + + private _branchesCache: Map = new Map(); + + 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(); + } + } + return this._hub; + } + + public equals(repo: GitHubRepository): boolean { + return this.remote.equals(repo.remote); + } + + 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; + } + this.commentsController = vscode.comments.createCommentController( + `${PullRequestCommentController.PREFIX}-${this.remote.gitProtocol.normalizeUri()?.authority}-${this.remote.remoteName}-${this.remote.owner}-${this.remote.repositoryName}`, + `Pull Request (${this.remote.owner}/${this.remote.repositoryName})`, + ); + this.commentsHandler = new PRCommentControllerRegistry(this.commentsController, this.telemetry); + this._register(this.commentsHandler); + this._register(this.commentsController); + } catch (e) { + console.log(e); + } + } + + override dispose() { + super.dispose(); + GitHubRepository._allRepoIds.delete(this._id); + for (const repoIds of GitHubRepository._succeededPullRequests.values()) { + repoIds.delete(this._id); + } + this.commentsController = undefined; + this.commentsHandler = undefined; + } + + public get octokit(): LoggingOctokit { + 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, + public readonly telemetry: ITelemetry, + silent: boolean = false + ) { + super(); + GitHubRepository._allRepoIds.add(this._id); + this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default, defaultSchema); + // kick off the comments controller early so that the Comments view is visible and doesn't pop up later in an way that's jarring + if (!silent) { + this.ensureCommentsController(); + } + } + + 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.githubEnterprise)) { + return true; + } else { + // Not good. We have a mismatch between auth type and server type. + return false; + } + } + + 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) { + 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, + } satisfies ApolloQueryResult; + return empty; + } + + let rsp; + try { + rsp = await gql.query(query); + } catch (e) { + 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; + } + } + return rsp; + }; + + 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.context as string}`, GRAPHQL_COMPONENT_ID); + const empty: FetchResult = { + data: null + }; + return empty; + } + + 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 this._queriesSchema; + } + + 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; + } + + Logger.debug(`Fetch metadata for repo - enter`, this.id); + const { octokit } = await this.ensure(); + const result = await octokit.call(octokit.api.repos.get, { + owner, + repo + }); + 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; + } + + /** + * Resolves remotes with redirects. + * @returns + */ + async resolveRemote(): Promise { + try { + const { clone_url } = await this.getMetadata(); + this.remote = GitHubRemote.remoteAsGitHub(parseRemote(this.remote.remoteName, clone_url, this.remote.gitProtocol)!, this.remote.githubServerType); + } catch (e) { + Logger.warn(`Unable to resolve remote: ${e}`); + if (isSamlError(e)) { + return false; + } + } + return true; + } + + 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(undefined, additionalScopes); + if (!this._credentialStore.isAuthenticated(this.remote.authProviderId)) { + this._hub = await this._credentialStore.showSignInNotification(this.remote.authProviderId); + } + } else { + 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 { + const data = await this.getMetadata(); + return data.default_branch; + } catch (e) { + 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) { + Logger.error(`Fetching pull request templates failed: ${e}`, this.id); + const properties: { errorCode?: string } = {}; + const errorCode = getErrorCode(e); + if (errorCode) { + properties.errorCode = errorCode; + } + /* __GDPR__ + "pr.getPullRequestTemplatesFailed" : { + "errorCode": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" } + } + */ + this.telemetry.sendTelemetryErrorEvent('pr.getPullRequestTemplatesFailed', properties); + } + } + + 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`, this.id); + const data = await this.getMetadata(); + + 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, + // edit title/description, assign reviewers/labels etc. + hasWritePermission, + mergeMethodsAvailability: { + merge: data.allow_merge_commit ?? false, + squash: data.allow_squash_merge ?? false, + rebase: data.allow_rebase_merge ?? false, + }, + viewerCanAutoMerge: (data.allow_auto_merge && hasWritePermission) ?? false + }; + } + return this._repoAccessAndMergeMethods; + } catch (e) { + Logger.warn(`GitHubRepository> Fetching repo permissions and available merge methods failed: ${e}`); + } + + return { + hasWritePermission: true, + mergeMethodsAvailability: { + merge: true, + squash: true, + rebase: true, + }, + viewerCanAutoMerge: false + }; + } + + 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 { + 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, + per_page: PULL_REQUEST_PAGE_SIZE, + page: page || 1, + }); + + const hasMorePages = !!result.headers.link && result.headers.link.indexOf('rel="next"') > -1; + if (!result.data) { + // We really don't expect this to happen, but it seems to (see #574). + // Log a warning and return an empty set. + Logger.warn( + `No result data for ${remote.owner}/${remote.repositoryName} Status: ${result.status}`, + ); + 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.', this.id); + return null; + } + + return this.createOrUpdatePullRequestModel( + convertRESTPullRequestToRawPullRequest(pullRequest, this), + ); + }) + .filter(item => item !== null) as PullRequestModel[]; + + Logger.debug(`Fetch all pull requests - done`, this.id); + return { + items: pullRequests, + hasMorePages + }; + } catch (e) { + Logger.error(`Fetching all pull requests failed: ${e}`, this.id); + if (e.status === 404) { + // not found + vscode.window.showWarningMessage( + `Fetching all pull requests for remote '${remote?.remoteName}' failed, please check if the repository ${remote?.owner}/${remote?.repositoryName} is valid.`, + ); + } else { + throw e; + } + } + return undefined; + } + + async getPullRequestNumbers(): Promise { + let remote: GitHubRemote | undefined; + try { + 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: { + owner: remote.owner, + name: remote.repositoryName, + headRefName: branch, + }, + }); + Logger.debug(`Fetch pull requests for branch - done`, this.id); + + 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 request for branch failed: ${e}`, this.id); + if (e.status === 404) { + // not found + vscode.window.showWarningMessage( + `Fetching pull request for branch for remote '${remote?.remoteName}' failed, please check if the repository ${remote?.owner}/${remote?.repositoryName} is valid.`, + ); + } + } + return undefined; + } + + 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; + } + } + + async getMilestones(includeClosed: boolean = false): Promise { + try { + Logger.debug(`Fetch milestones - enter`, this.id); + const { query, remote, schema } = await this.ensure(); + const states = ['OPEN']; + if (includeClosed) { + states.push('CLOSED'); + } + const { data } = await query({ + query: schema.GetMilestones, + variables: { + owner: remote.owner, + name: remote.repositoryName, + states: states, + }, + }); + Logger.debug(`Fetch milestones - done`, this.id); + + const milestones: IMilestone[] = []; + if (data && data.repository?.milestones && data.repository.milestones.nodes) { + data.repository.milestones.nodes.forEach(raw => { + const milestone = parseMilestone(raw); + if (milestone) { + milestones.push(milestone); + } + }); + } + return milestones; + } catch (e) { + 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`, this.id); + const { query, remote, schema } = await this.ensure(); + const { data } = await query({ + query: schema.GetFileContent, + variables: { + owner: remote.owner, + name: remote.repositoryName, + expression: `${sha}:${file}` + } + }); + + if (!data.repository?.object.text) { + return undefined; + } + + return data.repository.object.text.split('\n').slice(lineStart - 1, lineEnd).join('\n'); + } + + async getIssues(page?: number, queryString?: string): Promise { + try { + Logger.debug(`Fetch issues with query - enter`, this.id); + const { query, schema } = await this.ensure(); + const { data } = await query({ + query: schema.Issues, + variables: { + query: `${queryString} type:issue`, + }, + }); + Logger.debug(`Fetch issues with query - done`, this.id); + + let issues: Issue[] = []; + if (data && data.search.edges) { + // Preserve the order returned by the server (e.g. sort:created-desc). + // Using `Promise.all(map(async ... push))` would reorder results by completion time. + const parsed = await Promise.all(data.search.edges.map(async raw => { + if (raw.node.id) { + return parseGraphQLIssue(raw.node, this); + } + return undefined; + })); + issues = parsed.filter((issue): issue is Issue => issue !== undefined); + } + return { + items: issues, + hasMorePages: data.search.pageInfo.hasNextPage, + totalCount: data.search.issueCount + }; + } catch (e) { + Logger.error(`Unable to fetch issues with query: ${e}`, this.id); + return; + } + } + + private async _getMaxItem(isIssue: boolean): Promise { + try { + Logger.debug(`Fetch max ${isIssue ? 'issue' : 'pull request'} - enter`, this.id); + const { query, remote, schema } = await this.ensure(); + const { data } = await query({ + query: isIssue ? schema.MaxIssue : schema.MaxPullRequest, + variables: { + owner: remote.owner, + name: remote.repositoryName, + }, + }); + Logger.debug(`Fetch max ${isIssue ? 'issue' : 'pull request'} - done`, this.id); + + 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 ${isIssue ? 'issues' : 'pull requests'} with query: ${e}`, this.id); + return; + } + } + + async getMaxIssue(): Promise { + return this._getMaxItem(true); + } + + async getMaxPullRequest(): Promise { + return this._getMaxItem(false); + } + + /** + * Returns the highest known issue or pull request number for this repository. + * Issues and pull requests share the same number sequence on GitHub, so the + * larger of the two latest items is an upper bound for any valid number. + * Result is cached for a short TTL to avoid extra round-trips. + */ + private async getCachedMaxItemNumber(forceRefresh: boolean = false): Promise { + const now = Date.now(); + if (!forceRefresh && this._maxItemNumberCache && (now - this._maxItemNumberCache.fetchedAt) < GitHubRepository.MAX_ITEM_NUMBER_TTL_MS) { + return this._maxItemNumberCache.value; + } + if (this._maxItemNumberPromise) { + return this._maxItemNumberPromise; + } + this._maxItemNumberPromise = (async () => { + const [maxIssue, maxPr] = await Promise.all([this._getMaxItem(true), this._getMaxItem(false)]); + const max = Math.max(maxIssue ?? 0, maxPr ?? 0); + if (max > 0) { + this._maxItemNumberCache = { value: max, fetchedAt: Date.now() }; + return max; + } + return undefined; + })(); + try { + return await this._maxItemNumberPromise; + } finally { + this._maxItemNumberPromise = undefined; + } + } + + /** + * Returns false when `number` is implausibly higher than the latest known + * issue/PR number for this repository. Used to short-circuit fetches for + * numbers that came from arbitrary text (e.g. `#1234567890` in source code) + * before they hit the network and produce noisy error telemetry. + */ + private async isPlausibleItemNumber(itemNumber: number): Promise { + if (!Number.isFinite(itemNumber) || itemNumber <= 0) { + return false; + } + let max = await this.getCachedMaxItemNumber(); + if (max === undefined) { + // Couldn't determine the max (e.g. network error); allow through. + return true; + } + if (itemNumber <= max + GitHubRepository.MAX_ITEM_NUMBER_BUFFER) { + return true; + } + // Number is above the cached max; refresh once before deciding to handle newly-created items. + max = await this.getCachedMaxItemNumber(true); + if (max === undefined) { + return true; + } + return itemNumber <= max + GitHubRepository.MAX_ITEM_NUMBER_BUFFER; + } + + async getViewerPermission(): Promise { + try { + Logger.debug(`Fetch viewer permission - enter`, this.id); + const { query, remote, schema } = await this.ensure(); + const { data } = await query({ + query: schema.GetViewerPermission, + variables: { + owner: remote.owner, + name: remote.repositoryName, + }, + }); + Logger.debug(`Fetch viewer permission - done`, this.id); + return parseGraphQLViewerPermission(data); + } catch (e) { + Logger.error(`Unable to fetch viewer permission: ${e}`, this.id); + if (isRateLimitError(e)) { + throw new RateLimitError(); + } + 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 getCheckRunLogs(checkRunDatabaseId: number): Promise { + Logger.debug(`Fetch check run logs - enter`, this.id); + const { octokit, remote } = await this.ensure(); + + // Try GitHub Actions logs first (works for Actions workflow runs) + try { + const result = await octokit.call(octokit.api.actions.downloadJobLogsForWorkflowRun, { + owner: remote.owner, + repo: remote.repositoryName, + job_id: checkRunDatabaseId, + }); + Logger.debug(`Fetch check run logs via Actions API - done`, this.id); + return result.data as string; + } catch { + // Not a GitHub Actions job - fall through to Checks API + } + + // Fall back to Checks API output (works for any GitHub App, e.g. Azure Pipelines) + try { + const result = await octokit.call(octokit.api.checks.get, { + owner: remote.owner, + repo: remote.repositoryName, + check_run_id: checkRunDatabaseId, + }); + const output = result.data.output; + const parts: string[] = []; + if (output.title) { + parts.push(output.title); + parts.push(''); + } + if (output.summary) { + parts.push(output.summary); + parts.push(''); + } + if (output.text) { + parts.push(output.text); + } + if (parts.length === 0) { + return 'No log output available for this check run.'; + } + Logger.debug(`Fetch check run logs via Checks API - done`, this.id); + return parts.join('\n'); + } catch (e) { + Logger.error(`Unable to fetch check run logs: ${e}`, this.id); + throw e; + } + } + + async fork(): Promise { + try { + 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}`, this.id); + return undefined; + } + } + + async getRepositoryForkDetails(): Promise { + try { + Logger.debug(`Fetch repository fork details - enter`, this.id); + const { query, remote, schema } = await this.ensure(); + const { data } = await query({ + query: schema.GetRepositoryForkDetails, + variables: { + owner: remote.owner, + name: remote.repositoryName, + }, + }); + Logger.debug(`Fetch repository fork details - done`, this.id); + return data.repository; + } catch (e) { + Logger.error(`Unable to fetch repository fork details: ${e}`, this.id); + return; + } + } + + async getAuthenticatedUser(): Promise { + return await this._credentialStore.getCurrentUser(this.remote.authProviderId); + } + + async getAuthenticatedUserEmails(): Promise { + try { + 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(`Unable to fetch authenticated user emails: ${e}`, this.id); + return []; + } + } + + 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._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`, this.id); + const metadata = await this.getMetadata(); + const { mutate, schema } = await this.ensure(); + + const { data } = await mutate({ + mutation: schema.CreatePullRequest, + variables: { + input: { + repositoryId: metadata.node_id, + baseRefName: params.base, + headRefName: params.head, + title: params.title, + body: params.body, + draft: params.draft + } + } + }); + Logger.debug(`Create pull request - done`, this.id); + if (!data) { + throw new Error('Failed to create pull request.'); + } + return this.createOrUpdatePullRequestModel(await parseGraphQLPullRequest(data.createPullRequest.pullRequest, this)); + } catch (e) { + Logger.error(`Unable to create PR: ${e}`, this.id); + throw e; + } + } + + 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, callerName: string, useCache: boolean = false, silent: 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; + } + + if (!(await this.isPlausibleItemNumber(id))) { + Logger.debug(`Skipping pull request fetch for implausible number ${id} (caller: ${callerName})`, this.id); + return; + } + + try { + 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, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: id, + }, + }, 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), silent); + await pr.getLastUpdateTime(new Date(pr.item.updatedAt)); + let repoIds = GitHubRepository._succeededPullRequests.get(id); + if (!repoIds) { + repoIds = new Set(); + GitHubRepository._succeededPullRequests.set(id, repoIds); + } + repoIds.add(this._id); + return pr; + } catch (e) { + Logger.error(`Unable to fetch PR: ${e}`, this.id); + const succeededRepos = GitHubRepository._succeededPullRequests.get(id); + const succeededInOtherRepo = succeededRepos ? succeededRepos.size > 0 && !succeededRepos.has(this._id) : false; + const properties: { succeededInOtherRepo: string; callerName: string; errorCode?: string } = { + succeededInOtherRepo: String(succeededInOtherRepo), + callerName + }; + const errorCode = getErrorCode(e); + if (errorCode) { + properties.errorCode = errorCode; + } + /* __GDPR__ + "pr.getPullRequestFailed" : { + "prNumber": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" }, + "gitHubRepoCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" }, + "succeededInOtherRepo": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" }, + "errorCode": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" }, + "callerName": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" } + } + */ + this.telemetry.sendTelemetryErrorEvent('pr.getPullRequestFailed', properties, { + prNumber: id, + gitHubRepoCount: GitHubRepository._allRepoIds.size + }); + return; + } + } + + 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; + } + } + if (!(await this.isPlausibleItemNumber(id))) { + Logger.debug(`Skipping issue fetch for implausible number ${id}`, this.id); + return undefined; + } + try { + Logger.debug(`Fetch issue ${id} - enter`, this.id); + const { query, remote, schema } = await this.ensure(); + + 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 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); + + const issue = this.createOrUpdateIssueModel(await parseGraphQLIssue(data.repository.issue, this)); + await issue.getLastUpdateTime(new Date(issue.item.updatedAt)); + return issue; + } catch (e) { + Logger.error(`Unable to fetch issue: ${e}`, this.id); + return; + } + } + + /** + * 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; + } + + private branchesCacheKey(owner: string, repositoryName: string): string { + return `${owner}/${repositoryName}`; + } + + getCachedBranches(owner: string, repositoryName: string): string[] | undefined { + return this._branchesCache.get(this.branchesCacheKey(owner, repositoryName)); + } + + 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`, 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 { + const { data } = await query({ + query: schema.ListBranches, + variables: { + owner: remote.owner, + 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 > 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`, this.id); + throw e; + } + } while (hasNextPage); + + Logger.debug(`List branches for ${owner}/${repositoryName} - done`, this.id); + if (!branches.includes(defaultBranch)) { + branches.unshift(defaultBranch); + } + // Cache results for unprefixed queries + if (!prefix) { + this._branchesCache.set(this.branchesCacheKey(owner, repositoryName), branches); + } + return branches; + } + + async deleteBranch(pullRequestModel: PullRequestModel): Promise { + const { octokit } = await this.ensure(); + + if (!pullRequestModel.validatePullRequestModel('Unable to delete branch')) { + return; + } + + try { + await octokit.call(octokit.api.git.deleteRef, { + owner: pullRequestModel.head.repositoryCloneUrl.owner, + repo: pullRequestModel.head.repositoryCloneUrl.repositoryName, + ref: `heads/${pullRequestModel.head.ref}`, + }); + } catch (e) { + Logger.error(`Unable to delete branch: ${e}`, this.id); + return; + } + } + + async getMentionableUsers(): Promise { + Logger.debug(`Fetch mentionable users - enter`, this.id); + const { query, remote, schema } = await this.ensure(); + + let after: string | null = null; + let hasNextPage = false; + const ret: IAccount[] = []; + + do { + try { + const result: { data: MentionableUsersResponse } = await query({ + query: schema.GetMentionableUsers, + variables: { + owner: remote.owner, + name: remote.repositoryName, + first: 100, + after: after, + }, + }); + + 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 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}`, this.id); + return ret; + } + } while (hasNextPage); + + 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`, this.id); + const { query, remote, schema } = await this.ensure(); + + let after: string | null = null; + let hasNextPage = false; + const ret: IAccount[] = []; + // Once we fall back to the legacy assignableUsers query, the cursors are not compatible + // with suggestedActors, so stay on the legacy query for the rest of the pagination. + let useLegacyAssignableUsers = false; + + do { + try { + let result: { data: AssignableUsersResponse | SuggestedActorsResponse | null } | undefined; + if (schema.GetSuggestedActors && !useLegacyAssignableUsers) { + 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; + + // If we got assignableUsers back (either because we already used the legacy query, or + // because the legacy fallback kicked in inside query()), the cursor is incompatible with + // suggestedActors. Stay on the legacy query for subsequent pages. + if ((result.data as AssignableUsersResponse).repository?.assignableUsers) { + useLegacyAssignableUsers = true; + } + + ret.push( + ...(users?.nodes.map(node => { + return parseAccount(node, this); + }) || []), + ); + + hasNextPage = users?.pageInfo.hasNextPage; + after = users?.pageInfo.endCursor; + } catch (e) { + Logger.debug(`Unable to fetch assignable users: ${e}`, this.id); + const properties: { errorCode?: string; usedSuggestedActors: string } = { + usedSuggestedActors: String(!!schema.GetSuggestedActors), + }; + const errorCode = getErrorCode(e); + if (errorCode) { + properties.errorCode = errorCode; + } + /* __GDPR__ + "pr.getAssignableUsersFailed" : { + "errorCode": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" }, + "usedSuggestedActors": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" } + } + */ + this.telemetry.sendTelemetryErrorEvent('pr.getAssignableUsersFailed', properties); + if ( + e.graphQLErrors && + e.graphQLErrors.length > 0 && + e.graphQLErrors[0].type === 'INSUFFICIENT_SCOPES' + ) { + vscode.window.showWarningMessage( + `GitHub user features will not work. ${e.graphQLErrors[0].message}`, + ); + } + return ret; + } + } while (hasNextPage); + + 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`, this.id); + const { query, remote, schema } = await this.ensure(); + + const ret: IAccount[] = []; + + try { + const result: { data: PullRequestParticipantsResponse } = await query({ + query: schema.GetParticipants, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: pullRequestNumber, + 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 parseAccount(node, this); + }), + ); + } catch (e) { + Logger.debug(`Unable to fetch participants from a PullRequest: ${e}`, this.id); + if ( + e.graphQLErrors && + e.graphQLErrors.length > 0 && + e.graphQLErrors[0].type === 'INSUFFICIENT_SCOPES' + ) { + vscode.window.showWarningMessage( + `GitHub user features will not work. ${e.graphQLErrors[0].message}`, + ); + } + } + + return ret; + } + + /** + * Compare across commits. + * @param base The base branch. Must be a branch name. If comparing across repositories, use the format :branch. + * @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, { + repo: remote.repositoryName, + owner: remote.owner, + base, + head, + }); + Logger.debug('Compare commits - done', this.id); + return data; + } catch (e) { + Logger.error(`Unable to compare commits between ${base} and ${head}: ${e}`, this.id); + } + } + + isCurrentUser(authProviderId: AuthProvider, login: string): Promise { + return this._credentialStore.isCurrentUser(authProviderId, login); + } + + /** + * Get the status checks of the pull request, those for the last commit. + * + * 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<[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: ApolloQueryResult; + try { + result = await query({ + query: captureUseFallbackChecks ? schema.GetChecksWithoutSuite : schema.GetChecks, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: number, + }, + }); + } catch (e) { + // 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) { + this._useFallbackChecks = true; + 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) { + checks = { + state: CheckState.Success, + statuses: [] + }; + } else { + const dedupedStatuses = this.deduplicateStatusChecks(statusCheckRollup.contexts.nodes.map(context => { + if (isCheckRun(context)) { + return { + id: context.id, + databaseId: context.databaseId, + url: context.checkSuite?.app?.url, + 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, + workflowName: context.checkSuite?.workflowRun?.workflow.name, + event: context.checkSuite?.workflowRun?.event, + targetUrl: context.detailsUrl, + isRequired: context.isRequired, + isCheckRun: true, + }; + } else { + return { + id: context.id, + databaseId: undefined, + 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, + workflowName: undefined, + event: undefined, + targetUrl: context.targetUrl, + isRequired: context.isRequired, + isCheckRun: false, + }; + } + })); + + checks = { + state: this.computeOverallCheckState(dedupedStatuses), + statuses: dedupedStatuses + }; + } + + // Workflows triggered from forks (e.g. by first-time contributors) can sit in an + // "awaiting approval" state where GitHub has created the workflow run but no check + // runs yet. These don't appear in the statusCheckRollup, which would otherwise leave + // the PR with no reported checks (surfaced as "Unknown"). Surface them as pending so + // the status reflects that something is waiting. + const checkSuites = result.data.repository.pullRequest.commits.nodes[0].commit.checkSuites?.nodes; + const awaitingApprovalStatuses = this.computeAwaitingApprovalStatuses( + checkSuites, + checks.statuses, + result.data.repository.pullRequest.url, + ); + if (awaitingApprovalStatuses.length) { + checks.statuses = checks.statuses.concat(awaitingApprovalStatuses); + checks.state = this.computeOverallCheckState(checks.statuses); + } + + 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: '', + databaseId: undefined, + 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, + isCheckRun: false + }); + } + } + + 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) { + // Include event and workflowName in the key so that checks from different + // workflow events (e.g. "push" vs "pull_request") or different workflows + // are not incorrectly merged during deduplication. + const key = `${status.context}\0${status.event ?? ''}\0${status.workflowName ?? ''}`; + const existing = statusByContext.get(key); + if (!existing) { + statusByContext.set(key, 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(key, 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(key, 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; + } + + /** + * Build synthetic pending statuses for workflow runs that are awaiting approval + * (typically for pull requests from forks). Such runs exist as check suites but have + * not produced any check runs yet, so they are absent from the statusCheckRollup. + * Only suites that aren't already represented by an existing status are included, to + * avoid duplicating checks that GitHub has already reported. + */ + private computeAwaitingApprovalStatuses( + checkSuites: CheckSuiteForRollup[] | undefined, + existingStatuses: PullRequestCheckStatus[], + prUrl: string, + ): PullRequestCheckStatus[] { + if (!checkSuites?.length) { + return []; + } + + const existingWorkflowNames = new Set( + existingStatuses.map(status => status.workflowName).filter((name): name is string => !!name), + ); + + const awaitingApproval: PullRequestCheckStatus[] = []; + for (const suite of checkSuites) { + // A suite that is waiting or has only been requested and hasn't concluded is + // awaiting approval before it can run. + if (suite.conclusion !== null || (suite.status !== 'WAITING' && suite.status !== 'REQUESTED')) { + continue; + } + + const workflowName = suite.workflowRun?.workflow.name; + if (workflowName && existingWorkflowNames.has(workflowName)) { + continue; + } + + awaitingApproval.push({ + id: `awaiting-approval:${workflowName ?? awaitingApproval.length}`, + databaseId: undefined, + url: suite.app?.url, + avatarUrl: + suite.app?.logoUrl && + getAvatarWithEnterpriseFallback(suite.app.logoUrl, undefined, this.remote.isEnterprise), + state: CheckState.Pending, + description: vscode.l10n.t('Waiting for approval'), + context: workflowName ?? vscode.l10n.t('Workflow awaiting approval'), + workflowName, + event: suite.workflowRun?.event, + targetUrl: prUrl, + isRequired: false, + isCheckRun: true, + }); + } + + return awaitingApproval; + } + + /** + * Upload a file to GitHub via the mobile upload policy API. Returns a markdown + * snippet appropriate for embedding in an issue/PR comment. + */ + public async uploadFile(uri: vscode.Uri, fileName: string): Promise { + // Guard against very large files: check size before reading the bytes into memory. + let fileSize: number | undefined; + try { + const stat = await vscode.workspace.fs.stat(uri); + fileSize = stat.size; + } catch { + // Fall through; readFile will surface a more specific error if needed. + } + if (fileSize !== undefined && fileSize > MAX_UPLOAD_SIZE_BYTES) { + throw new Error(`File "${fileName}" is too large to upload (${Math.round(fileSize / (1024 * 1024))} MB). The maximum allowed size is ${MAX_UPLOAD_SIZE_BYTES / (1024 * 1024)} MB.`); + } + + const fileBytes = await vscode.workspace.fs.readFile(uri); + return this.uploadFileBytes(fileBytes, fileName); + } + + /** + * Upload a file's raw bytes to GitHub via the mobile upload policy API. + * Returns a markdown snippet appropriate for embedding in an issue/PR comment. + */ + public async uploadFileBytes(fileBytes: Uint8Array, fileName: string): Promise { + if (fileBytes.byteLength > MAX_UPLOAD_SIZE_BYTES) { + throw new Error(`File "${fileName}" is too large to upload (${Math.round(fileBytes.byteLength / (1024 * 1024))} MB). The maximum allowed size is ${MAX_UPLOAD_SIZE_BYTES / (1024 * 1024)} MB.`); + } + const contentType = guessContentType(fileName); + + const { octokit } = await this.ensure(); + const metadata = await this.getMetadata(); + const repositoryId = metadata.id; + + // Step 1: Get upload policy + const policyResponse = await octokit.api.request('POST /mobile/upload/policy', { + name: fileName, + size: fileBytes.byteLength, + content_type: contentType, + repository_id: repositoryId, + headers: { accept: 'application/json' }, + }); + const policy = policyResponse.data as { + upload_url: string; + form: Record; + asset: { id: number; name: string; href: string }; + asset_upload_url: string; + }; + + // Step 2: Upload bytes to the storage location returned by the policy. + // Pass the Uint8Array directly to Blob to avoid an extra full-size copy. + const formData = new FormData(); + for (const [key, value] of Object.entries(policy.form)) { + formData.append(key, value); + } + // The DOM Blob types require Uint8Array, but vscode.workspace.fs.readFile + // returns Uint8Array. The runtime accepts it, so cast via unknown to avoid a copy. + formData.append('file', new Blob([fileBytes as unknown as BlobPart], { type: contentType }), policy.asset.name); + const s3Response = await fetch(policy.upload_url, { method: 'POST', body: formData }); + if (s3Response.status !== 204 && s3Response.status !== 201 && s3Response.status !== 200) { + throw new Error(`Storage upload failed with status ${s3Response.status}`); + } + + // Step 3: Confirm the upload with GitHub + await octokit.api.request(`PUT ${policy.asset_upload_url}`, { + headers: { accept: 'application/json' }, + }); + + const url = policy.asset.href; + const safeName = escapeMarkdownLinkText(fileName); + if (contentType.startsWith('image/')) { + return `![${safeName}](${url})`; + } + if (contentType.startsWith('video/')) { + return url; + } + return `[${safeName}](${url})`; + } +} + +const MAX_UPLOAD_SIZE_BYTES = 25 * 1024 * 1024; // 25 MB + +/** + * Escape characters that would break a markdown link's text segment (`[text](url)`). + * Filenames may legally contain `[`, `]`, `\`, etc., which can corrupt the rendered link. + */ +function escapeMarkdownLinkText(text: string): string { + return text.replace(/([\\\[\]`])/g, '\\$1'); +} + +function guessContentType(fileName: string): string { + const lastDot = fileName.lastIndexOf('.'); + const ext = lastDot >= 0 ? fileName.substring(lastDot).toLowerCase() : ''; + switch (ext) { + case '.png': return 'image/png'; + case '.jpg': + case '.jpeg': return 'image/jpeg'; + case '.gif': return 'image/gif'; + case '.webp': return 'image/webp'; + case '.svg': return 'image/svg+xml'; + case '.bmp': return 'image/bmp'; + case '.heic': return 'image/heic'; + case '.mp4': return 'video/mp4'; + case '.mov': return 'video/quicktime'; + case '.webm': return 'video/webm'; + case '.pdf': return 'application/pdf'; + case '.zip': return 'application/zip'; + case '.gz': return 'application/gzip'; + case '.tar': return 'application/x-tar'; + case '.txt': return 'text/plain'; + case '.md': return 'text/markdown'; + case '.json': return 'application/json'; + case '.log': return 'text/plain'; + case '.docx': return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + case '.xlsx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + case '.pptx': return 'application/vnd.openxmlformats-officedocument.presentationml.presentation'; + default: return 'application/octet-stream'; + } +} diff --git a/src/github/graphql.ts b/src/github/graphql.ts index 7e76d9faa1..9515920a5c 100644 --- a/src/github/graphql.ts +++ b/src/github/graphql.ts @@ -4,16 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import { ForkDetails } from './githubRepository'; +import { DiffSide, SubjectType, ViewedState } from '../common/comment'; + +interface PageInfo { + hasNextPage: boolean; + endCursor: string; +} export interface MergedEvent { __typename: string; id: string; - databaseId: number; - actor: { - login: string; - avatarUrl: string; - url: string; - }; + actor: Actor; createdAt: string; mergeRef: { name: string; @@ -28,28 +29,68 @@ 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; + 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 { __typename: string; - authorAssocation: string; + authorAssociation: string; id: string; url: string; bodyHTML: string; @@ -63,21 +104,65 @@ export interface IssueComment extends AbbreviatedIssueComment { export interface ReactionGroup { content: string; viewerHasReacted: boolean; - users: { + reactors: { + nodes: { + login: string; + }[] totalCount: number; }; } +export interface Node { + id: string; +} + +export interface Actor { + __typename: string; + id: string; + login: string; + avatarUrl: 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; @@ -106,14 +191,9 @@ export interface ReviewComment { export interface Commit { __typename: string; id: string; - databaseId: number; commit: { author: { - user: { - login: string; - avatarUrl: string; - url: string; - } + user: Account; }; committer: { avatarUrl: string; @@ -121,6 +201,23 @@ export interface Commit { }; oid: string; message: string; + committedDate: Date; + signature?: { + isValid: boolean; + state: string; + wasSignedByGitHub?: boolean; + signer?: { + login: string; + name?: string | null; + avatarUrl?: string; + } | null; + keyId?: string | null; + keyFingerprint?: string | null; + email?: string | null; + } | null; + statusCheckRollup?: { + state: 'EXPECTED' | 'ERROR' | 'FAILURE' | 'PENDING' | 'SUCCESS'; + }; }; url: string; @@ -128,17 +225,26 @@ export interface Commit { export interface AssignedEvent { __typename: string; - databaseId: number; - actor: { - login: string; - avatarUrl: string; - url: string; - }; - user: { - login: string; - avatarUrl: string; + id: number; + 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 { @@ -147,104 +253,243 @@ 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 { + id: string; + isResolved: boolean; + viewerCanResolve: boolean; + viewerCanUnresolve: boolean; + path: string; + diffSide: DiffSide; + startLine: number | null; + line: number; + originalStartLine: number | null; + originalLine: number; + isOutdated: boolean; + subjectType?: SubjectType; + comments: { + nodes: ReviewComment[]; + edges: [{ + node: { + pullRequestReview?: { + databaseId: number + } + } + }] + }; } 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; + } | null)[]; + } +} + +export interface LatestUpdatesResponse { + repository: { + pullRequest: { + reactions: { + nodes: ({ + createdAt: string; + } | null)[]; + } + updatedAt: string; + comments: { + nodes: { + updatedAt: string; + reactions: { + nodes: ({ + createdAt: string; + } | null)[]; + } + }[]; + } + timelineItems: { + nodes: ({ + createdAt: string; + } | LatestCommit | LatestReviewThread | null)[]; } } - }; - rateLimit: RateLimit; + } +} + +export interface LatestReviewCommitResponse { + repository: { + pullRequest: { + reviews: { + nodes: { + commit: { + oid: string; + } + }[]; + }; + }; + } | null; } export interface PendingReviewIdResponse { node: { reviews: { nodes: Review[]; - } + }; }; 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: { title: string; number: number; state: 'OPEN' | 'CLOSED' | 'MERGED'; - } - }; + }; + } | null; +} + +export interface PullRequestTemplatesResponse { + repository: { + pullRequestTemplates: { + body: string; + }[] + } } export interface PullRequestCommentsResponse { repository: { pullRequest: { - reviews: { - nodes: [ - { - comments: { - nodes: ReviewComment[]; - } - } - ] - } - } - }; - rateLimit: RateLimit; + reviewThreads: { + nodes: ReviewThread[]; + pageInfo: PageInfo; + }; + }; + } | null; } export interface MentionableUsersResponse { repository: { mentionableUsers: { - nodes: { - login: string; - avatarUrl: string; - name: string; - url: string; - email: string; - }[]; - pageInfo: { - hasNextPage: boolean; - endCursor: string; - }; - } - }; + nodes: Account[]; + pageInfo: PageInfo; + }; + } | null; rateLimit: RateLimit; } export interface AssignableUsersResponse { repository: { assignableUsers: { - nodes: { - login: string; - avatarUrl: string; - name: string; - url: string; - email: string; - }[]; - pageInfo: { - hasNextPage: boolean; - endCursor: string; - }; - } + nodes: Account[]; + 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; } +export interface PullRequestParticipantsResponse { + repository: { + pullRequest: { + participants: { + nodes: Account[]; + }; + }; + } | null; +} + +export interface CreatePullRequestResponse { + createPullRequest: { + pullRequest: PullRequest + } +} + +export interface RevertPullRequestResponse { + revertPullRequest: { + revertPullRequest: PullRequest + } +} + +export interface AddReviewThreadResponse { + addPullRequestReviewThread: { + thread: ReviewThread; + } +} + export interface AddCommentResponse { addPullRequestReviewComment: { comment: ReviewComment; @@ -254,8 +499,8 @@ export interface AddCommentResponse { export interface AddIssueCommentResponse { addComment: { commentEdge: { - node: IssueComment - } + node: IssueComment; + }; }; } @@ -274,11 +519,54 @@ export interface EditIssueCommentResponse { export interface MarkPullRequestReadyForReviewResponse { markPullRequestReadyForReview: { pullRequest: { - isDraft: boolean + 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[]; @@ -297,8 +585,8 @@ export interface DeleteReviewResponse { databaseId: number; comments: { nodes: ReviewComment[]; - } - } + }; + }; }; } @@ -306,10 +594,10 @@ export interface AddReactionResponse { addReaction: { reaction: { content: string; - } + }; subject: { reactionGroups: ReactionGroup[]; - } + }; }; } @@ -317,31 +605,78 @@ export interface DeleteReactionResponse { removeReaction: { reaction: { content: string; - } + }; subject: { reactionGroups: ReactionGroup[]; - } + }; }; } -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 Ref { - name: string; +export interface AddPullRequestToProjectResponse { + addProjectV2ItemById: { + item: { + id: string; + }; + }; +} + +export interface GetBranchResponse { repository: { - owner: { - login: string; - } - url: string; + ref: { + target: { + oid: string; + } + } | null; + } | null; +} + +export interface ListBranchesResponse { + repository: { + refs: { + nodes: { + name: 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; target: { oid: string; }; @@ -350,114 +685,247 @@ 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; - author: { - login: string; - url: string; - avatarUrl: string; + titleHTML: string; + assignees?: { + nodes: Account[]; }; - comments?: { - nodes: AbbreviatedIssueComment[]; + author: Account; + comments: { + nodes?: AbbreviatedIssueComment[]; + totalCount: number; }; createdAt: string; updatedAt: string; - headRef?: Ref; - baseRef?: Ref; labels: { nodes: { name: string; color: string; }[]; }; - merged: boolean; - mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'; - isDraft?: boolean; - suggestedReviewers: SuggestedReviewerResponse[]; + viewerCanUpdate: boolean; + projectItems?: { + nodes: { + project: { + id: string; + title: string; + }, + id: string + }[]; + }; milestone?: { - title: string, - dueOn?: string, - id: string, - createdAt: string + title: string; + dueOn?: string; + id: string; + createdAt: string; + number: number; }; repository?: { - name: string, + name: string; owner: { - login: string - }, - url: string + login: string; + }; + url: string; }; + reactions: { + totalCount: number; + } + reactionGroups: ReactionGroup[]; + issueType?: { + name: string; + } | null; +} + + +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; + closingIssuesReferences?: { + nodes: { + databaseId: number, + title: string, + number: number, + state: 'CLOSED' | 'OPEN', + url: string, + }[]; + }; +} + +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; +} + +export interface PullRequestMergabilityResponse { + repository: { + 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 - }, + issueCount: number; + pageInfo: PageInfo; edges: { - node: PullRequest - }[] + node: PullRequest; + }[]; }; 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: { nodes: { - dueOn: string, - createdAt: string, - title: string, - id: string, + dueOn: string; + createdAt: string; + title: string; + id: string; + number: number issues: { edges: { - node: PullRequest - }[] - } - }[], - pageInfo: { - hasNextPage: boolean; - endCursor: string; - } - } - }; + node: PullRequest; + }[]; + }; + }[]; + pageInfo: PageInfo; + }; + } | null; } export interface IssuesResponse { repository: { issues: { edges: { - node: PullRequest - }[], - pageInfo: { - hasNextPage: boolean; - endCursor: string; - } + node: PullRequest; + }[]; + pageInfo: PageInfo; + }; + } | null; +} + +export interface PullRequestsResponse { + repository: { + pullRequests: { + nodes: PullRequest[] } + } | null; +} + +export interface PullRequestNumbersResponse { + repository: { + pullRequests: { + nodes: PullRequestNumberData[] + } + } | null; + rateLimit: RateLimit; +} + +export interface PullRequestNumberData { + number: number; + title: string; + author: { + login: string; }; } @@ -466,17 +934,17 @@ export interface MaxIssueResponse { issues: { edges: { node: { - number: number - } - }[] - } - }; + number: number; + }; + }[]; + }; + } | null; } export interface ViewerPermissionResponse { repository: { - viewerPermission: string - }; + viewerPermission: string; + } | null; } export interface ForkDetailsResponse { @@ -516,61 +984,188 @@ export interface UserResponse { name: string; contributionsCollection: ContributionsCollection; url: string; + id: string; + __typename: string; }; } +export interface FileContentResponse { + repository: { + object: { + text: string | undefined; + } + } | null; +} + export interface StartReviewResponse { addPullRequestReview: { pullRequestReview: { - comments: { - nodes: ReviewComment[] - } + id: string; }; }; } 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?: 'ACTION_REQUIRED' | 'CANCELLED' | 'FAILURE' | 'NEUTRAL' | 'SKIPPED' | 'STALE' | 'SUCCESS' | 'TIMED_OUT'; + databaseId: number | null; + conclusion: + | 'ACTION_REQUIRED' + | 'CANCELLED' + | 'FAILURE' + | 'NEUTRAL' + | 'SKIPPED' + | 'STALE' + | 'SUCCESS' + | 'TIMED_OUT' + | null; name: string; - title?: string; - detailsUrl?: string; - checkSuite: { - app?: { + title: string | null; + detailsUrl: string | null; + checkSuite?: { + 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 CheckRun).conclusion; + return x.__typename === 'CheckRun'; +} + +export interface CheckSuiteForRollup { + status: 'COMPLETED' | 'IN_PROGRESS' | 'PENDING' | 'QUEUED' | 'REQUESTED' | 'WAITING' | null; + conclusion: + | 'ACTION_REQUIRED' + | 'CANCELLED' + | 'FAILURE' + | 'NEUTRAL' + | 'SKIPPED' + | 'STALE' + | 'SUCCESS' + | 'TIMED_OUT' + | null; + workflowRun?: { + event: string; + workflow: { + name: string; + }; + } | null; + app?: { + logoUrl: string; + url: string; + } | null; +} + +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)[] - } - } - } + nodes: (StatusContext | CheckRun)[]; + }; + }; + checkSuites?: { + nodes: CheckSuiteForRollup[]; + } | null; + }; + }[] | undefined; + }; + }; + } | null; +} + +export interface ResolveReviewThreadResponse { + resolveReviewThread: { + thread: ReviewThread; + } +} + +export interface UnresolveReviewThreadResponse { + unresolveReviewThread: { + thread: ReviewThread; + } +} + +export interface PullRequestFilesResponse { + repository: { + pullRequest: { + files: { + nodes: { + path: string; + viewerViewedState: ViewedState }[] + pageInfo: { + hasNextPage: boolean; + endCursor: string; + }; } } - }; + } | 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)[] + } + }; + } } diff --git a/src/github/interface.ts b/src/github/interface.ts index 9b80b58699..30fa68149d 100644 --- a/src/github/interface.ts +++ b/src/github/interface.ts @@ -1,165 +1,382 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export enum PRType { - Query, - All, - LocalPullRequest -} - -export enum ReviewEvent { - Approve = 'APPROVE', - RequestChanges = 'REQUEST_CHANGES', - Comment = 'COMMENT' -} - -export enum GithubItemStateEnum { - Open, - Merged, - Closed, -} - -export enum PullRequestMergeability { - Mergeable, - NotMergeable, - Unknown -} - -export interface ReviewState { - reviewer: IAccount; - state: string; -} - -export interface IAccount { - login: string; - name?: string; - avatarUrl?: string; - url: string; - email?: string; -} - -export interface ISuggestedReviewer extends IAccount { - isAuthor: boolean; - isCommenter: boolean; -} - -export interface IMilestone { - title: string; - dueOn?: string | null; - createdAt: string; - id: string; -} - -export interface MergePullRequest { - sha: string; - merged: boolean; - message: string; - documentation_url: string; -} - -export interface IRepository { - cloneUrl: string; -} - -export interface IGitHubRef { - label: string; - ref: string; - sha: string; - repo: IRepository; -} - -export interface ILabel { - name: string; - color: string; -} - -export interface Issue { - id: number; - graphNodeId: string; - url: string; - number: number; - state: string; - body: string; - bodyHTML?: string; - title: string; - assignee?: IAccount; - createdAt: string; - updatedAt: string; - user: IAccount; - labels: ILabel[]; - milestone?: IMilestone; - repositoryOwner?: string; - repositoryName?: string; - repositoryUrl?: string; - comments?: { - author: IAccount; - body: string; - databaseId: number; - }[]; -} - -export interface PullRequest extends Issue { - isDraft?: boolean; - head?: IGitHubRef; - base?: IGitHubRef; - merged?: boolean; - mergeable?: PullRequestMergeability; - suggestedReviewers?: ISuggestedReviewer[]; -} - -export interface IRawFileChange { - filename: string; - previous_filename?: string; - additions: number; - deletions: number; - changes: number; - status: string; - raw_url: string; - blob_url: string; - patch: string; -} - -export interface IPullRequestsPagingOptions { - fetchNextPage: boolean; -} - -export interface IPullRequestEditData { - body?: string; - title?: string; -} - -export type MergeMethod = 'merge' | 'squash' | 'rebase'; - -export type MergeMethodsAvailability = { - [method in MergeMethod]: boolean; -}; - -export type RepoAccessAndMergeMethods = { - hasWritePermission: boolean; - mergeMethodsAvailability: MergeMethodsAvailability -}; - -export interface User extends IAccount { - company?: string; - location?: string; - bio?: string; - commitContributions: { - createdAt: Date; - repoNameWithOwner: string; - }[]; -} - -export interface PullRequestChecks { - state: string; - statuses: { - id: string; - url?: string; - avatar_url?: string; - state?: string; - description?: string; - target_url?: string; - context: string; - }[]; -} \ 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 { ReviewStateValue } from '../common/timelineEvent'; + +export enum PRType { + Query, + All, + LocalPullRequest, +} + +export enum ReviewEventEnum { + Approve = 'APPROVE', + RequestChanges = 'REQUEST_CHANGES', + Comment = 'COMMENT', +} + +export enum GithubItemStateEnum { + Open = 'OPEN', + Merged = 'MERGED', + Closed = 'CLOSED', +} + +export enum PullRequestMergeability { + Mergeable, + NotMergeable, + Conflict, + Unknown, + Behind, +} + +export enum MergeQueueState { + AwaitingChecks, + Locked, + Mergeable, + Queued, + Unmergeable +} + +export interface ReviewState { + reviewer: IAccount | ITeam; + state: ReviewStateValue; +} + +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 { + isAuthor: boolean; + 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 { + sha: string; + merged: boolean; + message: string; + documentation_url: string; +} + +export interface IRepository { + cloneUrl: string; + isInOrganization: boolean; + owner: string; + name: string; +} + +export interface IGitHubRef { + label: string; + ref: string; + sha: string; + repo: IRepository; +} + +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; + titleHTML: string; + assignees?: IAccount[]; + createdAt: string; + updatedAt: string; + user: IAccount; + labels: ILabel[]; + projectItems?: IProjectItem[]; + milestone?: IMilestone; + repositoryOwner?: string; + repositoryName?: string; + repositoryUrl?: string; + comments?: IIssueComment[]; + commentCount: number; + reactionCount: number; + reactions: Reaction[]; + issueType?: string; +} + +export interface IssueReference { + id: number; + number: number; + title: string; + state: GithubItemStateEnum; + url: string; +} + +export interface PullRequest extends Issue { + isDraft?: boolean; + isRemoteHeadDeleted?: boolean; + 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[]; + closingIssues?: IssueReference[]; + 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 | undefined; + additions: number; + deletions: number; + changes: number; + status: 'added' | 'removed' | 'modified' | 'renamed' | 'copied' | 'changed' | 'unchanged'; + raw_url: string; + blob_url: 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 { + fetchNextPage: boolean; + fetchOnePagePerRepo?: boolean; +} + +export interface IIssueEditData { + body?: string; + title?: string; +} + +export type MergeMethod = 'merge' | 'squash' | 'rebase'; + +export type MergeMethodsAvailability = { + [method in MergeMethod]: boolean; +}; + +export type RepoAccessAndMergeMethods = { + hasWritePermission: boolean; + mergeMethodsAvailability: MergeMethodsAvailability; + viewerCanAutoMerge: boolean; +}; + +export interface User extends IAccount { + company?: string; + location?: string; + bio?: string; + commitContributions: { + createdAt: Date; + repoNameWithOwner: string; + }[]; +} + +export enum CheckState { + Success = 'success', + Failure = 'failure', + Neutral = 'neutral', + Pending = 'pending', + Unknown = 'unknown' +} + +export interface PullRequestCheckStatus { + id: string; + databaseId: number | null | undefined; + 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; + isCheckRun: boolean; +} + +export interface PullRequestChecks { + state: CheckState; + 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 eab012a14e..ff6b910ac7 100644 --- a/src/github/issueModel.ts +++ b/src/github/issueModel.ts @@ -4,46 +4,115 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import * as OctokitTypes from '@octokit/types'; -import { IComment } from '../common/comment'; -import { Remote } from '../common/remote'; -import { GitHubRepository } from './githubRepository'; -import { AddIssueCommentResponse, AddReactionResponse, DeleteReactionResponse, EditIssueCommentResponse, TimelineEventsResponse, UpdatePullRequestResponse } from './graphql'; -import { IAccount, Issue, GithubItemStateEnum, IMilestone, IPullRequestEditData } from './interface'; -import { getReactionGroup, parseGraphQlIssueComment, parseGraphQLTimelineEvents } from './utils'; -import { formatError } from '../common/utils'; +import { OctokitCommon } from './common'; +import { CopilotWorkingStatus, GitHubRepository } from './githubRepository'; +import { + AddIssueCommentResponse, + AddPullRequestToProjectResponse, + EditIssueCommentResponse, + LatestCommit, + LatestReviewThread, + LatestUpdatesResponse, + TimelineEventsResponse, + UpdateIssueResponse, +} from './graphql'; +import { GithubItemStateEnum, IAccount, IIssueEditData, IMilestone, IProject, IProjectItem, Issue, StateReason } from './interface'; +import { convertRESTIssueToRawPullRequest, eventTime, parseCombinedTimelineEvents, parseGraphQlIssueComment, parseMilestone, parseSelectRestTimelineEvents, restPaginate } from './utils'; +import { COPILOT_ACCOUNTS, IComment } from '../common/comment'; +import { Disposable } from '../common/lifecycle'; import Logger from '../common/logger'; -import { TimelineEvent } from '../common/timelineEvent'; +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; public number: number; public title: string; + public titleHTML: string; public html_url: string; public state: GithubItemStateEnum = GithubItemStateEnum.Open; + public stateReason?: StateReason; public author: IAccount; - public assignee: 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: Issue; + public item: TItem; + public body: string; public bodyHTML?: string; - constructor(githubRepository: GitHubRepository, remote: Remote, item: Issue) { + private _lastCheckedForUpdatesAt?: Date; + + 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; - this.update(item); + + if (!skipUpdate) { + this.update(item); + } + } + + 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 { return this.state === GithubItemStateEnum.Open; } + public get isClosed(): boolean { + return this.state === GithubItemStateEnum.Closed; + } + + public get isMerged(): boolean { + return this.state === GithubItemStateEnum.Merged; + } + public get userAvatar(): string | undefined { if (this.item) { return this.item.user.avatarUrl; @@ -60,7 +129,7 @@ export class IssueModel { // hack, to ensure queries are not wrongly encoded. const originalToStringFn = uri.toString; - uri.toString = function (skipEncoding?: boolean | undefined) { + uri.toString = function (_skipEncoding?: boolean | undefined) { return originalToStringFn.call(uri, true); }; @@ -71,41 +140,70 @@ 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: Issue): 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; - 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 (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; + } - if (issue.assignee) { - this.assignee = issue.assignee; + 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 { + equals(other: IssueModel | undefined): boolean { if (!other) { return false; } @@ -121,45 +219,53 @@ export class IssueModel { return true; } - async edit(toEdit: IPullRequestEditData): Promise<{ body: string, bodyHTML: string, title: 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 - } - } + title: toEdit.title, + }, + }, }); - - return data!.updatePullRequest.pullRequest; + 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!.updateIssue.issue; } catch (e) { throw new Error(formatError(e)); } } - canEdit(): boolean { + 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.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 { @@ -169,12 +275,13 @@ export class IssueModel { variables: { input: { subjectId: this.graphNodeId, - body: text - } - } + body: text, + }, + }, }); - return parseGraphQlIssueComment(data!.addComment.commentEdge.node); + this._onDidChange.fire({ timeline: true }); + return parseGraphQlIssueComment(data!.addComment.commentEdge.node, this.githubRepository); } async editIssueComment(comment: IComment, text: string): Promise { @@ -186,12 +293,13 @@ export class IssueModel { variables: { input: { id: comment.graphNodeId, - body: text - } - } + body: text, + }, + }, }); - return parseGraphQlIssueComment(data!.updateIssueComment.issueComment); + this._onDidChange.fire({ timeline: true }); + return parseGraphQlIssueComment(data!.updateIssueComment.issueComment, this.githubRepository); } catch (e) { throw new Error(formatError(e)); } @@ -201,40 +309,171 @@ export class IssueModel { try { const { octokit, remote } = await this.githubRepository.ensure(); - await octokit.issues.deleteComment({ + await octokit.call(octokit.api.issues.deleteComment, { owner: remote.owner, repo: remote.repositoryName, - comment_id: Number(commentId) + comment_id: Number(commentId), }); + this._onDidChange.fire({ timeline: true }); } catch (e) { throw new Error(formatError(e)); } } - async addLabels(labels: string[]): Promise { + async setLabels(labels: string[]): Promise { const { octokit, remote } = await this.githubRepository.ensure(); - await octokit.issues.addLabels({ - owner: remote.owner, - repo: remote.repositoryName, - issue_number: this.number, - labels - }); + try { + 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. + Logger.error(`Failed to add labels to PR #${this.number}`, IssueModel.ID); + vscode.window.showWarningMessage(vscode.l10n.t('Some, or all, labels could not be added to the pull request.')); + } } async removeLabel(label: string): Promise { const { octokit, remote } = await this.githubRepository.ensure(); - await octokit.issues.removeLabel({ + const result = await octokit.call(octokit.api.issues.removeLabel, { owner: remote.owner, repo: remote.repositoryName, issue_number: this.number, - name: label + 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.filter((node): node is { createdAt: string } => !!node).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.filter((reaction): reaction is { createdAt: string } => !!reaction).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) && latestReviewThread!.comments!.nodes[0]) { + return new Date(latestReviewThread!.comments!.nodes[0].createdAt); + } else if (node) { + return new Date((node as { createdAt: string }).createdAt); + } + })) + ]; + + // Sort times and return the most recent one + return new Date(Math.max(...times.filter((t): t is Date => !!t).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({ @@ -242,12 +481,32 @@ export class IssueModel { variables: { owner: remote.owner, name: remote.repositoryName, - number: this.number - } + 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, 'IssueModel.getTimelineEvents'); + } + this.timelineEvents = events; return events; } catch (e) { console.log(e); @@ -255,41 +514,174 @@ export class IssueModel { } } - async addCommentReaction(graphNodeId: string, reaction: vscode.CommentReaction): Promise { - const reactionEmojiToContent = getReactionGroup().reduce((prev, curr) => { - prev[curr.label] = curr.title; - return prev; - }, {} as { [key: string]: string }); - const { mutate, schema } = await this.githubRepository.ensure(); - const { data } = await mutate({ - mutation: schema.AddReaction, - variables: { - input: { - subjectId: graphNodeId, - content: reactionEmojiToContent[reaction.label!] + /** + * 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 []; + } + } - return data!; + 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 deleteCommentReaction(graphNodeId: string, reaction: vscode.CommentReaction): Promise { - const reactionEmojiToContent = getReactionGroup().reduce((prev, curr) => { - prev[curr.label] = curr.title; - return prev; - }, {} as { [key: string]: string }); + async updateMilestone(id: string): Promise { const { mutate, schema } = await this.githubRepository.ensure(); - const { data } = await mutate({ - mutation: schema.DeleteReaction, - variables: { - input: { - subjectId: graphNodeId, - content: reactionEmojiToContent[reaction.label!] + 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, }); + } - return data!; + 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 94ccda5027..57b9f51e7d 100644 --- a/src/github/issueOverview.ts +++ b/src/github/issueOverview.ts @@ -4,221 +4,415 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; -import * as path from 'path'; import * as vscode from 'vscode'; -import * as OctokitTypes from '@octokit/types'; -import { ILabel } from './interface'; -import { formatError } from '../common/utils'; -import { IComment } from '../common/comment'; -import Logger from '../common/logger'; -import { DescriptionNode } from '../view/treeNodes/descriptionNode'; +import { CloseResult, OpenLocalFileArgs } from '../../common/views'; +import { openPullRequestOnGitHub } from '../commands'; +import { decodeBase64, guessExtensionFromMime, pickFilesForUpload, placeholdersForNames, runFileUploads, runPendingUploads } from './fileUpload'; import { FolderRepositoryManager } from './folderRepositoryManager'; +import { GithubItemStateEnum, IAccount, IMilestone, IProject, IProjectItem, RepoAccessAndMergeMethods } from './interface'; import { IssueModel } from './issueModel'; - -export interface IRequestMessage { - req: string; - command: string; - args: T; -} - -interface IReplyMessage { - seq?: string; - err?: any; - res?: any; +import { getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, getProjectFromQuickPick } from './quickPicks'; +import { isInCodespaces, processPermalinks, vscodeDevPrLink } from './utils'; +import { ChangeAssigneesReply, DisplayLabel, FileUploadCompletedMessage, Issue, ProjectItemsReply, SubmitReviewArgs, SubmitReviewReply, UnresolvedIdentity, UploadFilesReply, UploadPastedFilesArgs } 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 function panelKey(owner: string, repo: string, number: number): string { + return `${owner}/${repo}#${number}`; } -export class IssueOverviewPanel { - public static ID: string = 'PullRequestOverviewPanel'; +export class IssueOverviewPanel extends WebviewBase { + public static ID: string = 'IssueOverviewPanel'; /** - * Track the currently panel. Only allow a single panel to exist at a time. + * All open panels, keyed by "owner/repo#number". */ - public static currentPanel?: IssueOverviewPanel; + protected static _panels: Map = new Map(); - protected static readonly _viewType: string = 'IssueOverview'; + public static readonly viewType: string = 'IssueOverview'; protected readonly _panel: vscode.WebviewPanel; - private readonly _extensionPath: string; - protected _disposables: vscode.Disposable[] = []; - protected _descriptionNode: DescriptionNode; - protected _item: IssueModel; + protected _item: TItem; + protected _identity: UnresolvedIdentity; protected _folderRepositoryManager: FolderRepositoryManager; protected _scrollPosition = { x: 0, y: 0 }; - private _waitForReady: Promise; - private _onIsReady: vscode.EventEmitter = new vscode.EventEmitter(); - - protected readonly MESSAGE_UNHANDLED: string = 'message not handled'; - - public static async createOrShow(extensionPath: string, folderRepositoryManager: FolderRepositoryManager, issue: IssueModel, descriptionNode: DescriptionNode, toTheSide: Boolean = false) { - const activeColumn = toTheSide ? - vscode.ViewColumn.Beside : - vscode.window.activeTextEditor ? - vscode.window.activeTextEditor.viewColumn : - vscode.ViewColumn.One; - - // If we already have a panel, show it. - // Otherwise, create a new panel. - if (IssueOverviewPanel.currentPanel) { - IssueOverviewPanel.currentPanel._panel.reveal(activeColumn, true); + + protected static _getViewColumn(toTheSide: boolean, panel?: IssueOverviewPanel): number | undefined { + const tabViewColumn = vscode.window.tabGroups.activeTabGroup.viewColumn; + const activeColumn = toTheSide + ? vscode.ViewColumn.Beside + : (panel ? undefined : tabViewColumn); + return activeColumn; + } + + public static async createOrShow( + telemetry: ITelemetry, + extensionUri: vscode.Uri, + folderRepositoryManager: FolderRepositoryManager, + identity: UnresolvedIdentity, + issue?: IssueModel, + toTheSide: boolean = false, + _preserveFocus: boolean = true, + existingPanel?: vscode.WebviewPanel + ) { + await ensureEmojis(folderRepositoryManager.context); + + const key = panelKey(identity.owner, identity.repo, identity.number); + let panel = this._panels.get(key); + const activeColumn = IssueOverviewPanel._getViewColumn(toTheSide, panel); + + if (panel) { + panel._panel.reveal(activeColumn, true); } else { - const title = `Issue #${issue.number.toString()}`; - IssueOverviewPanel.currentPanel = new IssueOverviewPanel(extensionPath, activeColumn || vscode.ViewColumn.Active, title, folderRepositoryManager, descriptionNode); + const title = `#${identity.number.toString()}`; + panel = new IssueOverviewPanel( + telemetry, + extensionUri, + activeColumn || vscode.ViewColumn.Active, + title, + folderRepositoryManager, + undefined, + existingPanel, + undefined + ); + this._panels.set(key, panel); + } + + await panel.updateWithIdentity(folderRepositoryManager, identity, issue); + } + + public static refresh(owner: string, repo: string, number: number): void { + const panel = this.findPanel(owner, repo, number); + if (panel) { + panel.refreshPanel(); + } + } + + /** + * Return the panel whose webview is currently active (focused), + * or `undefined` when no issue/PR panel is active. + */ + public static getActivePanel(): IssueOverviewPanel | undefined { + for (const panel of this._panels.values()) { + if (panel._panel.active) { + return panel; + } } + return undefined; + } + + /** + * Find the panel showing a specific issue. + */ + public static findPanel(owner: string, repo: string, number: number): IssueOverviewPanel | undefined { + return this._panels.get(panelKey(owner, repo, number)); + } - await IssueOverviewPanel.currentPanel!.update(folderRepositoryManager, issue, descriptionNode); + /** + * Build a short panel title: `# `. + * The item title is truncated to approximately `maxLength` characters on a + * word boundary and suffixed with "..." when it doesn't fit in full. + */ + protected buildPanelTitle(itemNumber: number, itemTitle: string, maxLength: number = 20): string { + let truncated = itemTitle; + if (itemTitle.length > maxLength) { + const lastSpace = itemTitle.lastIndexOf(' ', maxLength); + const cutOff = lastSpace > 0 ? lastSpace : maxLength; + truncated = itemTitle.substring(0, cutOff) + '...'; + } + return `#${itemNumber} ${truncated}`; } - public static refresh(): void { - if (this.currentPanel) { - this.currentPanel.refreshPanel(); + 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(extensionPath: string, column: vscode.ViewColumn, title: string, folderRepositoryManager: FolderRepositoryManager, descriptionNode: DescriptionNode, type: string = IssueOverviewPanel._viewType) { - this._extensionPath = extensionPath; + protected constructor( + protected readonly _telemetry: ITelemetry, + protected readonly _extensionUri: vscode.Uri, + column: vscode.ViewColumn, + title: string, + folderRepositoryManager: FolderRepositoryManager, + private readonly type: string = IssueOverviewPanel.viewType, + existingPanel?: vscode.WebviewPanel, + iconSubpath: { + light: string, + dark: string, + } = { + light: 'resources/icons/issue_webview.svg', + dark: 'resources/icons/dark/issue_webview.svg', + } + ) { + super(); this._folderRepositoryManager = folderRepositoryManager; - this._descriptionNode = descriptionNode; // 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 `media` directory. - localResourceRoots: [ - vscode.Uri.file(path.join(this._extensionPath, 'media')) - ] - }); + // And restrict the webview to only loading content from our extension's `dist` directory. + localResourceRoots: [vscode.Uri.joinPath(_extensionUri, 'dist')], + enableFindWidget: true + })); - this._waitForReady = new Promise(resolve => { - const disposable = this._onIsReady.event(() => { - disposable.dispose(); - resolve(); - }); - }); + 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 programatically - this._panel.onDidDispose(() => this.dispose(), null, this._disposables); - - // Handle messages from the webview - this._panel.webview.onDidReceiveMessage(async message => { - await this._onDidReceiveMessage(message); - }, null, this._disposables); - - this._folderRepositoryManager.onDidChangeActiveIssue(_ => { - if (this._folderRepositoryManager && this._item) { - const isCurrentlyCheckedOut = this._item.equals(this._folderRepositoryManager.activeIssue); - this._postMessage({ - command: 'pr.update-checkout-status', - isCurrentlyCheckedOut: isCurrentlyCheckedOut - }); + // This happens when the user closes the panel or when the panel is closed programmatically + this._register(this._panel.onDidDispose(() => this.dispose())); + + this._register(this._folderRepositoryManager.onDidChangeActiveIssue( + _ => { + if (this._folderRepositoryManager && this._item) { + const isCurrentlyCheckedOut = this._item.equals(this._folderRepositoryManager.activeIssue); + this._postMessage({ + command: 'pr.update-checkout-status', + isCurrentlyCheckedOut: isCurrentlyCheckedOut, + }); + } + })); + + 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(); } - }, null, this._disposables); + }; + + 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, this._descriptionNode); + await this.updateItem(this._item); } } - public async updateIssue(issueModel: IssueModel, descriptionNode: DescriptionNode): 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; + protected continueOnGitHub() { + return isInCodespaces(); + } + + protected async getInitializeContext(currentUser: IAccount, issue: IssueModel, timelineEvents: TimelineEvent[], repositoryAccess: RepoAccessAndMergeMethods, viewerCanEdit: boolean, assignableUsers: IAccount[]): Promise { + 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: await this.processLinksInBodyHtml(issue.bodyHTML), + labels: labels, + author: issue.author, + state: issue.state, + stateReason: issue.stateReason, + events: await this.processTimelineEvents(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}`); + throw new Error( + `Fail to resolve issue #${issueModel.number} in ${issueModel.remote.owner}/${issueModel.remote.repositoryName}`, + ); } - this._item = issue; - this._panel.title = `Pull Request #${issueModel.number.toString()}`; + this._item = issue as TItem; + this.setPanelTitle(this.buildPanelTitle(issueModel.number, issueModel.title)); + + // Process permalinks in bodyHTML before sending to webview + const context = await this.getInitializeContext(currentUser, issue, timelineEvents, repositoryAccess, viewerCanEdit, assignableUsers[this._item.remote.remoteName] ?? []); 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, - status: status ? status : { statuses: [] }, - isIssue: true - } + pullrequest: context, }); - }).catch(e => { - vscode.window.showErrorMessage(formatError(e)); - }); + + } catch (e) { + vscode.window.showErrorMessage(`Error updating issue description: ${formatError(e)}`); + } + } + + 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'; } - public async update(foldersManager: FolderRepositoryManager, issueModel: IssueModel, descriptionNode: DescriptionNode): Promise { + /** + * 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; this._folderRepositoryManager = foldersManager; - this._descriptionNode = descriptionNode; + this._postMessage({ command: 'set-scroll', scrollPosition: this._scrollPosition, }); - this._panel.webview.html = this.getHtmlForWebview(issueModel.number.toString()); - return this.updateIssue(issueModel, descriptionNode); - } + if (!this._panel.webview.html) { + this._panel.webview.html = this.getHtmlForWebview(); + if (this._item) { + this._postMessage({ command: 'pr.clear' }); + } + } - protected async _postMessage(message: any) { - // Without the following ready check, we can end up in a state where the message handler in the webview - // isn't ready for any of the messages we post. - await this._waitForReady; - this._panel.webview.postMessage({ - res: message - }); - } + // 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; + } - protected async _replyMessage(originalMessage: IRequestMessage, message: any) { - const reply: IReplyMessage = { - seq: originalMessage.req, - res: message - }; - this._panel.webview.postMessage(reply); + if (progressLocation) { + return vscode.window.withProgress({ location: { viewId: progressLocation } }, () => this.updateItem(issueModel!)); + } else { + return this.updateItem(issueModel); + } } - protected async _throwError(originalMessage: IRequestMessage, error: any) { - const reply: IReplyMessage = { - seq: originalMessage.req, - err: error - }; - this._panel.webview.postMessage(reply); - } + protected override async _onDidReceiveMessage(message: IRequestMessage) { + const result = await super._onDidReceiveMessage(message); + if (result !== this.MESSAGE_UNHANDLED) { + return; + } - protected async _onDidReceiveMessage(message: IRequestMessage) { switch (message.command) { case 'alert': vscode.window.showErrorMessage(message.args); 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); @@ -235,54 +429,146 @@ export class IssueOverviewPanel { 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.open-local-file': + return this.openLocalFile(message); case 'pr.debug': return this.webviewDebug(message); - case 'ready': - this._onIsReady.fire(); - return; + case 'pr.upload-files': + return this.uploadFiles(message); + case 'pr.upload-pasted-files': + return this.uploadPastedFiles(message); default: return this.MESSAGE_UNHANDLED; } } - private async addLabels(message: IRequestMessage): Promise { - try { - let newLabels: ILabel[] = []; - async function getLabelOptions(folderRepoManager: FolderRepositoryManager, issue: IssueModel): Promise { - const allLabels = await folderRepoManager.getLabels(issue); - newLabels = allLabels.filter(l => !issue.item.labels.some(label => label.name === l.name)); - - return newLabels.map(label => { - return { - label: label.name - }; + protected async submitReviewMessage(message: IRequestMessage) { + const body = message.args?.body ?? ''; + const comment = await this._item.createIssueComment(body); + 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; } - const labelsToAdd = await vscode.window.showQuickPick(await getLabelOptions(this._folderRepositoryManager, this._item), { canPickMany: true }); + 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(); + } + } - if (labelsToAdd && labelsToAdd.length) { - await this._item.addLabels(labelsToAdd.map(r => r.label)); - const addedLabels: ILabel[] = labelsToAdd.map(label => newLabels.find(l => l.name === label.label)!); + 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.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; + const acceptPromise = asPromise(quickPick.onDidAccept).then(() => { + return quickPick.selectedItems; + }); + const hidePromise = asPromise(quickPick.onDidHide); + const labelsToAdd = await Promise.race([acceptPromise, hidePromise]); + quickPick.busy = true; + quickPick.enabled = false; - this._item.item.labels = this._item.item.labels.concat(...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)!); - this._replyMessage(message, { - added: addedLabels + await this._replyMessage(message, { + added: addedLabels, }); } } catch (e) { vscode.window.showErrorMessage(formatError(e)); + } finally { + quickPick.hide(); + quickPick.dispose(); } } 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)); @@ -293,124 +579,382 @@ export class IssueOverviewPanel { Logger.debug(message.args, IssueOverviewPanel.ID); } - private editDescription(message: IRequestMessage<{ text: string }>) { - this._item.edit({ body: message.args.text }).then(result => { - this._replyMessage(message, { body: result.body, bodyHTML: result.bodyHTML }); - }).catch(e => { + private async uploadFiles(message: IRequestMessage): Promise { + const uploads = await pickFilesForUpload(); + if (!uploads) { + const empty: UploadFilesReply = { uploads: [] }; + return this._replyMessage(message, empty); + } + + const reply: UploadFilesReply = { uploads: uploads.map(u => ({ name: u.name, placeholder: u.placeholder })) }; + await this._replyMessage(message, reply); + + runFileUploads( + this._item.githubRepository, + uploads, + IssueOverviewPanel.ID, + (placeholder, name, markdown) => this._postMessage({ + command: 'pr.file-upload-completed', + placeholder, + name, + markdown, + } satisfies FileUploadCompletedMessage), + (placeholder, name, error) => this._postMessage({ + command: 'pr.file-upload-completed', + placeholder, + name, + error, + } satisfies FileUploadCompletedMessage), + ); + } + + private async uploadPastedFiles(message: IRequestMessage): Promise { + const files = message.args?.files ?? []; + if (files.length === 0) { + const empty: UploadFilesReply = { uploads: [] }; + return this._replyMessage(message, empty); + } + + const names = files.map(f => f.name.includes('.') ? f.name : `${f.name}${guessExtensionFromMime(f.type)}`); + const placeholders = placeholdersForNames(names); + const reply: UploadFilesReply = { uploads: placeholders }; + await this._replyMessage(message, reply); + + runPendingUploads( + this._item.githubRepository, + files.map((f, i) => ({ + name: placeholders[i].name, + placeholder: placeholders[i].placeholder, + getBytes: () => Promise.resolve(decodeBase64(f.bytesBase64)), + })), + IssueOverviewPanel.ID, + (placeholder, name, markdown) => this._postMessage({ + command: 'pr.file-upload-completed', + placeholder, + name, + markdown, + } satisfies FileUploadCompletedMessage), + (placeholder, name, error) => this._postMessage({ + command: 'pr.file-upload-completed', + placeholder, + name, + error, + } satisfies FileUploadCompletedMessage), + ); + } + + + /** + * Process code reference links in bodyHTML. Can be overridden by subclasses (e.g., PullRequestOverviewPanel) + * to provide custom processing logic for different item types. + * Returns undefined if bodyHTML is undefined. + */ + protected async processLinksInBodyHtml(bodyHTML: string | undefined): Promise { + if (!bodyHTML) { + return bodyHTML; + } + return processPermalinks( + bodyHTML, + this._item.githubRepository, + this._item.githubRepository.rootUri + ); + } + + /** + * Process code reference links in timeline events (comments, reviews, commits). + * Updates bodyHTML fields for all events that contain them. + */ + protected async processTimelineEvents(events: TimelineEvent[] | undefined): Promise { + if (!events) { + return []; + } + return Promise.all(events.map(async (event) => { + // Create a shallow copy to avoid mutating the original + const processedEvent = { ...event }; + + if (processedEvent.event === EventType.Commented || processedEvent.event === EventType.Reviewed || processedEvent.event === EventType.Committed) { + processedEvent.bodyHTML = await this.processLinksInBodyHtml(processedEvent.bodyHTML); + // ReviewEvent also has comments array + if (processedEvent.event === EventType.Reviewed && processedEvent.comments) { + processedEvent.comments = await Promise.all(processedEvent.comments.map(async (comment: IComment) => ({ + ...comment, + bodyHTML: await this.processLinksInBodyHtml(comment.bodyHTML) + }))); + } + } + return processedEvent; + })); + } + + private async editDescription(message: IRequestMessage<{ text: string }>) { + try { + const result = await this._item.edit({ body: message.args.text }); + const bodyHTML = await this.processLinksInBodyHtml(result.bodyHTML); + this._replyMessage(message, { body: result.body, bodyHTML }); + } catch (e) { this._throwError(message, e); vscode.window.showErrorMessage(`Editing description failed: ${formatError(e)}`); - }); - + } } private editTitle(message: IRequestMessage<{ text: string }>) { - this._item.edit({ title: message.args.text }).then(result => { - this._replyMessage(message, { text: result.title }); - }).catch(e => { - this._throwError(message, e); - vscode.window.showErrorMessage(`Editing title failed: ${formatError(e)}`); - }); + return this._item + .edit({ title: message.args.text }) + .then(result => { + return this._replyMessage(message, { titleHTML: result.titleHTML }); + }) + .catch(e => { + this._throwError(message, e); + vscode.window.showErrorMessage(`Editing title failed: ${formatError(e)}`); + }); } - protected editCommentPromise(comment: IComment, text: string): Promise { - return this._item.editIssueComment(comment, text); + protected async _getTimeline(): Promise { + const events = await this._item.getIssueTimelineEvents(); + return this.processTimelineEvents(events); } - private editComment(message: IRequestMessage<{ comment: IComment, text: string }>) { - this.editCommentPromise(message.args.comment, message.args.text).then(result => { - this._replyMessage(message, { - body: result.body, - bodyHTML: result.bodyHTML + 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; }); - }).catch(e => { - this._throwError(message, e); + 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, }); } - protected deleteCommentPromise(comment: IComment): Promise { - return this._item.deleteIssueComment(comment.id.toString()); + private async removeMilestone(message: IRequestMessage): Promise { + try { + await this._item.updateMilestone('null'); + this._replyMessage(message, {}); + } catch (e) { + vscode.window.showErrorMessage(formatError(e)); + } } - private deleteComment(message: IRequestMessage) { - vscode.window.showWarningMessage('Are you sure you want to delete this comment?', { modal: true }, 'Delete').then(value => { - if (value === 'Delete') { - this.deleteCommentPromise(message.args).then(result => { - this._replyMessage(message, {}); - }).catch(e => { - this._throwError(message, 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 close(message: IRequestMessage): void { - vscode.commands.executeCommand('pr.close', this._item, message.args).then(comment => { - if (comment) { - this._replyMessage(message, { - value: comment - }); + 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 createComment(message: IRequestMessage) { - this._item.createIssueComment(message.args).then(comment => { - this._replyMessage(message, { - value: comment - }); - }); + 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); } - protected set _currentPanel(panel: IssueOverviewPanel | undefined) { - IssueOverviewPanel.currentPanel = panel; + private async editComment(message: IRequestMessage<{ comment: IComment; text: string }>) { + try { + const result = await this.editCommentPromise(message.args.comment, message.args.text); + const bodyHTML = await this.processLinksInBodyHtml(result.bodyHTML); + this._replyMessage(message, { body: result.body, bodyHTML }); + } catch (e) { + this._throwError(message, e); + vscode.window.showErrorMessage(formatError(e)); + } } - public dispose() { - this._currentPanel = undefined; + protected deleteCommentPromise(comment: IComment): Promise { + return this._item.deleteIssueComment(comment.id.toString()); + } - // Clean up our resources - this._panel.dispose(); + private deleteComment(message: IRequestMessage) { + vscode.window + .showWarningMessage(vscode.l10n.t('Are you sure you want to delete this comment?'), { modal: true }, 'Delete') + .then(value => { + if (value === 'Delete') { + this.deleteCommentPromise(message.args) + .then(_ => { + this._replyMessage(message, {}); + }) + .catch(e => { + this._throwError(message, e); + vscode.window.showErrorMessage(formatError(e)); + }); + } + }); + } - while (this._disposables.length) { - const x = this._disposables.pop(); - if (x) { - x.dispose(); - } + protected async openLocalFile(message: IRequestMessage): Promise { + try { + const { file, startLine, endLine } = message.args; + // Resolve relative path to absolute using repository root + const fileUri = vscode.Uri.joinPath( + this._item.githubRepository.rootUri, + file + ); + const selection = new vscode.Range( + new vscode.Position(startLine - 1, 0), + new vscode.Position(endLine - 1, Number.MAX_SAFE_INTEGER) + ); + await vscode.window.showTextDocument(fileUri, { + selection, + viewColumn: vscode.ViewColumn.One + }); + } catch (e) { + Logger.error(`Open local file failed: ${formatError(e)}`, IssueOverviewPanel.ID); + // Fallback to opening external URL + await vscode.env.openExternal(vscode.Uri.parse(message.args.href)); } } - protected getHtmlForWebview(number: string) { - const scriptPathOnDisk = vscode.Uri.file(path.join(this._extensionPath, 'media', 'index.js')); - const scriptUri = this._panel.webview.asWebviewUri(scriptPathOnDisk); - const nonce = getNonce(); + 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); + } - return ` - - - - + protected _removeFromPanels(): void { + if (this._identity) { + const key = panelKey(this._identity.owner, this._identity.repo, this._identity.number); + // Use the subclass's own static _panels map via this.constructor + (this.constructor as unknown as typeof IssueOverviewPanel)._panels.delete(key); + } + } - - Pull Request #${number} - - -
- - - `; + public override dispose() { + super.dispose(); + this._removeFromPanels(); + this._webview = undefined; + } + + protected getHtmlForWebview() { + const nonce = generateUuid(); + + const uri = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview-pr-description.js'); + + return ` + + + + + + + + +
+ + +`; } public getCurrentTitle(): string { return this._panel.title; } -} -function getNonce() { - let text = ''; - const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - for (let i = 0; i < 32; i++) { - text += possible.charAt(Math.floor(Math.random() * possible.length)); + public getCurrentItem(): TItem | undefined { + return this._item; } - return text; } diff --git a/src/github/loggingOctokit.ts b/src/github/loggingOctokit.ts new file mode 100644 index 0000000000..a6c9295ab6 --- /dev/null +++ b/src/github/loggingOctokit.ts @@ -0,0 +1,331 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Octokit } from '@octokit/rest'; +import { ApolloClient, ApolloQueryResult, FetchResult, MutationOptions, NormalizedCacheObject, OperationVariables, QueryOptions } from 'apollo-boost'; +import { bulkhead, BulkheadPolicy } from 'cockatiel'; +import * as vscode from 'vscode'; +import { RateLimit } from './graphql'; +import { IRawFileChange } from './interface'; +import { restPaginate } from './utils'; +import { GitHubRef } from '../common/githubRef'; +import Logger from '../common/logger'; +import { GitHubRemote } from '../common/remote'; +import { ITelemetry } from '../common/telemetry'; + +interface RestResponse { + headers: { + 'x-ratelimit-limit': string; + 'x-ratelimit-remaining': string; + } +} + +interface RateLimitResult { + data: { + rateLimit: RateLimit | undefined + } | undefined; +} + +export enum GraphQLErrorType { + Unprocessable = 'UNPROCESSABLE', +} + +export interface GraphQLError { + extensions?: { + code: string; + }; + type?: GraphQLErrorType; + message?: string; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** + * Detects whether an error from a REST (Octokit) or GraphQL (Apollo) call + * indicates that the GitHub authentication token is no longer valid. This + * happens when the token has been revoked or has expired and surfaces as + * either a 401 status, a "Bad credentials" message (REST), or a + * "401 Unauthorized" network error (GraphQL). + */ +export function isAuthError(e: unknown): boolean { + if (!isObject(e)) { + return false; + } + if (e.status === 401) { + return true; + } + const networkError = e.networkError; + if (isObject(networkError) && networkError.statusCode === 401) { + return true; + } + if (typeof e.message === 'string') { + if (e.message.includes('Bad credentials')) { + return true; + } + if (e.message.includes('401 Unauthorized')) { + return true; + } + } + return false; +} + +export function getErrorCode(e: unknown): string | undefined { + if (!isObject(e)) { + return undefined; + } + + if (e.status !== undefined) { + return String(e.status); + } + + const networkError = e.networkError; + if (isObject(networkError) && networkError.statusCode !== undefined) { + return String(networkError.statusCode); + } + + const graphQLErrors = e.graphQLErrors; + if (Array.isArray(graphQLErrors) && graphQLErrors.length > 0) { + const firstGraphQLError = graphQLErrors[0] as GraphQLError | undefined; + if (firstGraphQLError) { + if (firstGraphQLError.extensions?.code !== undefined) { + return String(firstGraphQLError.extensions.code); + } + if (firstGraphQLError.type !== undefined) { + return String(firstGraphQLError.type); + } + } + } + + if (e.code !== undefined) { + return String(e.code); + } + + if (typeof e.name === 'string' && e.name) { + const message = typeof e.message === 'string' ? e.message : ''; + if (e.name !== 'Error') { + return message ? `${e.name}: ${message}` : e.name; + } + if (message) { + return message; + } + } + + return undefined; +} + +export class RateLogger { + private bulkhead: BulkheadPolicy = bulkhead(140); + private static ID = 'RateLimit'; + private hasLoggedLowRateLimit: boolean = false; + private readonly _isInsiders: boolean; + + constructor(private readonly telemetry: ITelemetry, private readonly errorOnFlood: boolean, private readonly authErrorHandler?: (e: unknown) => void) { + this._isInsiders = vscode.env.appName.toLowerCase().includes('insider'); + } + + private static sanitizeOperationName(info: string): string { + // REST URLs like /repos/{owner}/{repo}/pulls get redacted because they look + // like file paths. Convert slashes to dots to avoid redaction. + return info.replace(/\/+/g, '.').replace(/^\.+|\.+$/g, ''); + } + + public logAndLimit>(info: string | undefined, apiRequest: () => T): T | undefined { + if (this._isInsiders && info) { + /* __GDPR__ + "pr.apiCall" : { + "operation" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this.telemetry.sendTelemetryEvent('pr.apiCall', { operation: RateLogger.sanitizeOperationName(info) }); + } + 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'); + + 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(log, RateLogger.ID); + } + + return this.bulkhead.execute(() => apiRequest()) as T; + } + + public async logRateLimit(info: string | undefined, result: Promise, isRest: boolean = false) { + let rateLimitInfo: { limit: number, remaining: number, cost: number } | undefined; + try { + const resolvedResult = await result; + 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) { + 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 = `${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 logApiError(info: string | undefined, apiResult: Promise): void { + apiResult.catch(e => { + const properties: { operation: string; errorCode?: string } = { + operation: RateLogger.sanitizeOperationName(info ?? 'unknown'), + }; + const errorCode = getErrorCode(e); + if (errorCode) { + properties.errorCode = errorCode; + } + /* __GDPR__ + "pr.apiCallFailed" : { + "operation": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" }, + "errorCode": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" } + } + */ + this.telemetry.sendTelemetryErrorEvent('pr.apiCallFailed', properties); + + if (this.authErrorHandler && isAuthError(e)) { + try { + this.authErrorHandler(e); + } catch { + // Ignore errors from the auth error handler so they don't propagate. + } + } + }); + } + + public async logRestRateLimit(info: string | undefined, restResponse: Promise) { + let result; + try { + result = await restResponse; + } catch (e) { + // Ignore errors here since we're just trying to log the rate limit. + return; + } + const rateLimit: RateLimit = { + cost: -1, + limit: Number(result.headers['x-ratelimit-limit']), + remaining: Number(result.headers['x-ratelimit-remaining']), + resetAt: '' + }; + this.logRateLimit(info, Promise.resolve({ data: { rateLimit } }), true); + } +} + +export class LoggingApolloClient { + constructor(private readonly _graphql: ApolloClient, private _rateLogger: RateLogger) { } + + query(options: QueryOptions): Promise> { + 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); + this._rateLogger.logApiError(logInfo, result); + return result; + } + + mutate(options: MutationOptions): Promise> { + const logInfo = String(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); + this._rateLogger.logApiError(logInfo, result); + return result; + } +} + +export class LoggingOctokit { + constructor(public readonly api: Octokit, private _rateLogger: RateLogger) { } + + 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); + this._rateLogger.logApiError(logInfo, result); + 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..4d5f77cce9 --- /dev/null +++ b/src/github/markdownUtils.ts @@ -0,0 +1,359 @@ +/*--------------------------------------------------------------------------------------------- + * 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.Unknown) { + return; + } 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/overviewRestorer.ts b/src/github/overviewRestorer.ts new file mode 100644 index 0000000000..550379fb75 --- /dev/null +++ b/src/github/overviewRestorer.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 { 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); + } + + if (!repo || !folderManager) { + webviewPanel.dispose(); + return; + } + + 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, 'OverviewRestorer.deserializeWebviewPanel', 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 0be2c53dd7..eec7df13a0 100644 --- a/src/github/prComment.ts +++ b/src/github/prComment.ts @@ -3,13 +3,20 @@ * 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 { IComment } from '../common/comment'; +import { GitHubRepository } from './githubRepository'; import { IAccount } from './interface'; import { updateCommentReactions } from './utils'; +import { COPILOT_ACCOUNTS, IComment } from '../common/comment'; +import { emojify, ensureEmojis } from '../common/emoji'; +import Logger from '../common/logger'; +import { DataUri } from '../common/uri'; +import { ALLOWED_USERS, JSDOC_NON_USERS, PHPDOC_NON_USERS } from '../common/user'; +import { escapeRegExp, stringReplaceAsync } from '../common/utils'; -export interface GHPRCommentThread { - threadId: string; +export interface GHPRCommentThread extends vscode.CommentThread2 { + gitHubThreadId: string; /** * The uri of the document the thread has been created on. @@ -20,7 +27,7 @@ export interface GHPRCommentThread { * 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. @@ -38,18 +45,26 @@ export interface GHPRCommentThread { */ label?: string; - dispose: () => void; -} + canReply: boolean | vscode.CommentAuthorInformation; -/** - * Used to optimistically render updates to comment threads. Temporary comments are immediately - * set when a command is run, and then replaced with real data when the operation finishes. - */ -export class TemporaryComment implements vscode.Comment { /** - * The id of the comment + * Whether the thread has been marked as resolved. */ - public id: number; + state?: { resolved: vscode.CommentThreadState; applicability?: vscode.CommentThreadApplicability }; + + reveal(comment?: vscode.Comment, options?: vscode.CommentThreadRevealOptions): Promise; + + dispose: () => void; +} + +export namespace GHPRCommentThread { + export function is(value: any): value is GHPRCommentThread { + return (value && (typeof (value as GHPRCommentThread).gitHubThreadId) === 'string'); + } +} + +abstract class CommentBase implements vscode.Comment { + public abstract commentId: undefined | string; /** * The comment thread the comment is from @@ -57,14 +72,10 @@ export class TemporaryComment implements vscode.Comment { public parent: GHPRCommentThread; /** - * The text of the comment - */ - public body: string | vscode.MarkdownString; - - /** - * If the temporary comment is in place for an edit, the original text value of the comment + * The text of the comment as from GitHub */ - public originalBody?: string; + public abstract get body(): string | vscode.MarkdownString; + public abstract set body(body: string | vscode.MarkdownString); /** * Whether the comment is in edit mode or not @@ -74,7 +85,12 @@ export class TemporaryComment 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 @@ -84,33 +100,29 @@ export class TemporaryComment implements vscode.Comment { /** * The list of reactions to the comment */ - public commentReactions?: vscode.CommentReaction[] | undefined; + public reactions?: vscode.CommentReaction[] | undefined; /** * The context value, used to determine whether the command should be visible/enabled based on clauses in package.json */ public contextValue: string; - static idPool = 0; + /** + * The state of the comment (Published or Draft) + */ + public state?: vscode.CommentState; - constructor(parent: GHPRCommentThread, input: string, isDraft: boolean, currentUser: IAccount, originalComment?: GHPRComment) { + constructor( + parent: GHPRCommentThread, + ) { this.parent = parent; - this.body = new vscode.MarkdownString(input); - this.mode = vscode.CommentMode.Preview; - this.author = { - name: currentUser.login, - iconPath: currentUser.avatarUrl ? vscode.Uri.parse(`${currentUser.avatarUrl}&s=64`) : undefined - }; - this.label = isDraft ? 'Pending' : undefined; - this.contextValue = 'canEdit,canDelete'; - this.originalBody = originalComment ? originalComment._rawComment.body : undefined; - this.commentReactions = originalComment ? originalComment.reactions : undefined; - this.id = TemporaryComment.idPool++; } + public abstract commentEditId(): number | string; + startEdit() { this.parent.comments = this.parent.comments.map(cmt => { - if (cmt instanceof TemporaryComment && cmt.id === this.id) { + if (cmt instanceof CommentBase && cmt.commentEditId() === this.commentEditId()) { cmt.mode = vscode.CommentMode.Editing; } @@ -118,11 +130,14 @@ export class TemporaryComment 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 TemporaryComment && cmt.id === this.id) { + if (cmt instanceof CommentBase && cmt.commentEditId() === this.commentEditId()) { cmt.mode = vscode.CommentMode.Preview; - cmt.body = cmt.originalBody || cmt.body; + this.doSetBody(this.getCancelEditBody(), true); } return cmt; @@ -130,64 +145,163 @@ export class TemporaryComment implements vscode.Comment { } } -export class GHPRComment implements vscode.Comment { - /** - * The database id of the comment - */ - public commentId: string; +/** + * Used to optimistically render updates to comment threads. Temporary comments are immediately + * set when a command is run, and then replaced with real data when the operation finishes. + */ +export class TemporaryComment extends CommentBase { + public commentId: undefined; /** - * The comment thread the comment is from + * The id of the comment */ - public parent: GHPRCommentThread; + public id: number; /** - * The text of the comment + * If the temporary comment is in place for an edit, the original text value of the comment */ - public body: string | vscode.MarkdownString; + public originalBody?: string; - /** - * Whether the comment is in edit mode or not - */ - public mode: vscode.CommentMode; + static idPool = 0; - /** - * The author of the comment - */ - public author: vscode.CommentAuthorInformation; + constructor( + parent: GHPRCommentThread, + private input: string, + isDraft: boolean, + currentUser: IAccount, + originalComment?: GHPRComment, + ) { + super(parent); + this.mode = vscode.CommentMode.Preview; + this.originalAuthor = { + name: currentUser.specialDisplayName ?? currentUser.login, + iconPath: (currentUser.avatarUrl && DataUri.isGitHubDotComAvatar(currentUser.avatarUrl)) ? vscode.Uri.parse(`${currentUser.avatarUrl}&s=64`) : undefined, + }; + this.label = isDraft ? vscode.l10n.t('Pending') : undefined; + 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++; + } - /** - * The label to display on the comment, 'Pending' or nothing - */ - public label: string | undefined; + protected async doSetBody(input: string | vscode.MarkdownString): Promise { + if (typeof input === 'string') { + this.input = input; + } + } - /** - * The list of reactions to the comment - */ - public reactions?: vscode.CommentReaction[] | undefined; + set body(input: string | vscode.MarkdownString) { + this.doSetBody(input); + } + + get body(): string | vscode.MarkdownString { + const s = new vscode.MarkdownString(this.input); + s.supportAlertSyntax = true; + return s; + } + + get author(): vscode.CommentAuthorInformation { + return this.originalAuthor; + } + + commentEditId() { + return this.id; + } + + protected getCancelEditBody() { + return this.originalBody || this.body; + } +} + +const SUGGESTION_EXPRESSION = /```suggestion(\u0020*(\r\n|\n))((?[\s\S]*?)(\r\n|\n))?```/; +const IMG_EXPRESSION = /]*?\ssrc\s*=\s*['"](?[^'"]+?)['"][^>]*?>/gi; +const IMG_ALT_EXPRESSION = /\salt\s*=\s*(['"])(?[\s\S]*?)\1/i; +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.isGitHubDotComAvatar(comment.user.avatarUrl)) ? 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(); - /** - * The context value, used to determine whether the command should be visible/enabled based on clauses in package.json - */ - public contextValue: string; + updateCommentReactions(this, comment.reactions); - constructor(comment: IComment, parent: GHPRCommentThread) { - this._rawComment = comment; - this.commentId = comment.id.toString(); - this.body = new vscode.MarkdownString(comment.body); - this.body.isTrusted = true; - this.author = { - name: comment.user!.login, - iconPath: comment.user && comment.user.avatarUrl ? vscode.Uri.parse(comment.user.avatarUrl) : undefined + 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) { + contextValues.push('canEdit'); + } + + if (comment.canDelete) { + contextValues.push('canDelete'); + } + + if (this.suggestion !== undefined) { + contextValues.push('hasSuggestion'); + } + + this.contextValue = contextValues.join(','); + 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, }; - updateCommentReactions(this, comment.reactions); + } + + update(comment: IComment) { + const oldRawComment = this.rawComment; + this.rawComment = comment; + let refresh: boolean = false; + + if (updateCommentReactions(this, comment.reactions)) { + refresh = true; + } - this.label = comment.isDraft ? 'Pending' : undefined; + 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) { @@ -198,28 +312,247 @@ export class GHPRComment implements vscode.Comment { contextValues.push('canDelete'); } + if (this.suggestion !== undefined) { + contextValues.push('hasSuggestion'); + } + + const oldContextValue = this.contextValue; this.contextValue = contextValues.join(','); - this.parent = parent; + 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(); + } } - startEdit() { - this.parent.comments = this.parent.comments.map(cmt => { - if (cmt instanceof GHPRComment && cmt.commentId === this.commentId) { - cmt.mode = vscode.CommentMode.Editing; + 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 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 }) => { + const altMatch = substring.match(IMG_ALT_EXPRESSION); + const alt = (altMatch?.groups?.alt ?? '').replace(/[\r\n]+/g, ' ').trim(); + const safeAlt = alt.replace(/([\\\[\]`])/g, '\\$1'); + return `![${safeAlt || 'image'}](${src})`; + }); + } + + private replaceSuggestion(body: string) { + return body.replace(new RegExp(SUGGESTION_EXPRESSION, 'g'), (_substring: string, ...args: any[]) => { + return `*** +Suggested change: +\`\`\` +${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; + } + } - return cmt; + private async replacePermalink(body: string): Promise { + const githubRepository = this.githubRepository; + if (!githubRepository) { + return body; + } + + 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 = end ? parseInt(end) : startLine + 1; + const lineContents = await githubRepository.getLines(sha, file, startLine, endLine); + if (!lineContents) { + return 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}))` : ''} + +${lineMessage} +\`\`\` +${lineContents} +\`\`\` +***`; }); } - cancelEdit() { - this.parent.comments = this.parent.comments.map(cmt => { - if (cmt instanceof GHPRComment && cmt.commentId === this.commentId) { - cmt.mode = vscode.CommentMode.Preview; - cmt.body = cmt._rawComment.body; + 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; } - return cmt; + // 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.replaceImg(this.replaceSuggestion(permalinkReplaced)); + } + 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); + 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 commitShasReplaced = this.replaceCommitShas(linkified); + const permalinkReplaced = await this.replacePermalink(commitShasReplaced); + await emojiPromise; + return this.postpendSpecialAuthorComment(emojify(this.replaceImg(this.replaceSuggestion(permalinkReplaced)))); + } + + protected async doSetBody(body: string | vscode.MarkdownString, refresh: boolean) { + this._rawBody = body; + const replacedBody = await this.replaceBody(body); + + if (replacedBody !== this.replacedBody) { + this.replacedBody = replacedBody; + 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; + } + const s = new vscode.MarkdownString(this.replacedBody); + s.supportAlertSyntax = true; + return s; + } + + protected getCancelEditBody() { + 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 d69e693933..6d93d2b176 100644 --- a/src/github/pullRequestGitHelper.ts +++ b/src/github/pullRequestGitHelper.ts @@ -6,15 +6,18 @@ /* * Inspired by and includes code from GitHub/VisualStudio project, obtained from https://github.com/github/VisualStudio/blob/165a97bdcab7559e0c4393a571b9ff2aed4ba8a7/src/GitHub.App/Services/PullRequestService.cs */ - +import * as vscode from 'vscode'; +import { IResolvedPullRequestModel, PullRequestModel } from './pullRequestModel'; +import { Branch, Repository } from '../api/api'; import Logger from '../common/logger'; import { Protocol } from '../common/protocol'; -import { Remote, parseRepositoryRemotes } from '../common/remote'; -import { Repository, Branch } from '../api/api'; -import { PullRequestModel, IResolvedPullRequestModel } from './pullRequestModel'; +import { parseRepositoryRemotesAsync, Remote } from '../common/remote'; +import { PR_SETTINGS_NAMESPACE, PULL_PR_BRANCH_BEFORE_CHECKOUT, PullPRBranchVariants } from '../common/settingKeys'; const PullRequestRemoteMetadataKey = 'github-pr-remote'; -const PullRequestMetadataKey = 'github-pr-owner-number'; +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/; @@ -24,31 +27,63 @@ 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(repository: Repository, pullRequest: PullRequestModel & IResolvedPullRequestModel, remoteName: string | undefined) { + static async checkoutFromFork( + repository: Repository, + pullRequest: PullRequestModel & IResolvedPullRequestModel, + remoteName: string | undefined, + progress: vscode.Progress<{ message?: string; increment?: number }> + ) { // the branch is from a fork const localBranchName = await PullRequestGitHelper.calculateUniqueBranchNameForPR(repository, pullRequest); // create remote for this fork if (!remoteName) { - Logger.appendLine(`Branch ${localBranchName} is from a fork. Create a remote first.`, PullRequestGitHelper.ID); - remoteName = await PullRequestGitHelper.createRemote(repository, pullRequest.remote, pullRequest.head.repositoryCloneUrl); + Logger.appendLine( + `Branch ${localBranchName} is from a fork. Create a remote first.`, + PullRequestGitHelper.ID, + ); + progress.report({ message: vscode.l10n.t('Creating git remote for {0}', `${pullRequest.remote.owner}/${pullRequest.remote.repositoryName}`) }); + remoteName = await PullRequestGitHelper.createRemote( + repository, + pullRequest.remote, + pullRequest.head.repositoryCloneUrl, + ); } // fetch the branch const ref = `${pullRequest.head.ref}:${localBranchName}`; Logger.debug(`Fetch ${remoteName}/${pullRequest.head.ref}:${localBranchName} - start`, PullRequestGitHelper.ID); - await repository.fetch(remoteName, ref, 1); + progress.report({ message: vscode.l10n.t('Fetching branch {0}', ref) }); + 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}`); - await this.unshallow(repository); - PullRequestGitHelper.associateBranchWithPullRequest(repository, pullRequest, localBranchName); + await PullRequestGitHelper.associateBranchWithPullRequest(repository, pullRequest, localBranchName); } - static async fetchAndCheckout(repository: Repository, remotes: Remote[], pullRequest: PullRequestModel): Promise { + static async fetchAndCheckout( + repository: Repository, + remotes: Remote[], + pullRequest: PullRequestModel, + progress: vscode.Progress<{ message?: string; increment?: number }> + ): Promise { if (!pullRequest.validatePullRequestModel('Checkout pull request failed')) { return; } @@ -56,82 +91,113 @@ export class PullRequestGitHelper { const remote = PullRequestGitHelper.getHeadRemoteForPullRequest(remotes, pullRequest); const isFork = pullRequest.head.repositoryCloneUrl.owner !== pullRequest.base.repositoryCloneUrl.owner; if (!remote || isFork) { - return PullRequestGitHelper.checkoutFromFork(repository, pullRequest, remote && remote.remoteName); + 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); - Logger.debug(`Checkout ${branchName}`, PullRequestGitHelper.ID); - await repository.checkout(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 ${localBranchName}, but branch is already checked out.`, PullRequestGitHelper.ID); + await PullRequestGitHelper.associateBranchWithPullRequest(repository, pullRequest, localBranchName); + return; + } + + 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}', 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. - Logger.appendLine(`Branch ${remoteName}/${branchName} doesn't exist on local disk yet.`, PullRequestGitHelper.ID); - const trackedBranchName = `refs/remotes/${remoteName}/${branchName}`; - Logger.appendLine(`Fetch tracked branch ${trackedBranchName}`, PullRequestGitHelper.ID); - await repository.fetch(remoteName, branchName, 1); - const trackedBranch = await repository.getBranch(trackedBranchName); + // there is no local branch with the same name, so we are good to create and checkout the remote branch. + Logger.appendLine( + `Branch ${localBranchName} doesn't exist on local disk yet. Creating from remote.`, + PullRequestGitHelper.ID, + ); // create branch - await repository.createBranch(branchName, true, trackedBranch.commit); - await repository.setBranchUpstream(branchName, trackedBranchName); - await 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 { - try { - await repository.pull(true); - } catch (e) { - Logger.appendLine(`Unshallowing failed: ${e}. Falling back to git pull`); - await repository.pull(); - } + await PullRequestGitHelper.associateBranchWithPullRequest(repository, pullRequest, localBranchName); } - static async checkoutExistingPullRequestBranch(repository: Repository, pullRequest: PullRequestModel) { + static async checkoutExistingPullRequestBranch(repository: Repository, pullRequest: PullRequestModel, progress: vscode.Progress<{ message?: string; increment?: number }>) { const key = PullRequestGitHelper.buildPullRequestMetadata(pullRequest); const configs = await repository.getConfigs(); const readConfig = (searchKey: string): string | undefined => configs.filter(({ key: k }) => searchKey === k).map(({ value }) => value)[0]; - const branchInfos = configs.map(config => { - const matches = PullRequestBranchRegex.exec(config.key); - return { - branch: matches && matches.length ? matches[1] : null, - value: config.value - }; - }).filter(c => c.branch && c.value === key); + const branchInfos = configs + .map(config => { + const matches = PullRequestBranchRegex.exec(config.key); + return { + branch: matches && matches.length ? matches[1] : null, + value: config.value, + }; + }) + .filter(c => c.branch && c.value === key); if (branchInfos && branchInfos.length) { // let's immediately checkout to branchInfos[0].branch 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`); - 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) }); await repository.pull(); } @@ -141,28 +207,29 @@ 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; + static async getBranchNRemoteForPullRequest( + repository: Repository, + pullRequest: PullRequestModel, + ): 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; @@ -173,7 +240,9 @@ export class PullRequestGitHelper { let createdForPullRequest = false; if (remoteName) { const remoteCreatedForPullRequestKey = `remote.${remoteName}.github-pr-remote`; - const remoteCreatedForPullRequest = configs.filter(config => config.key === remoteCreatedForPullRequestKey && config.value); + const remoteCreatedForPullRequest = configs.filter( + config => config.key === remoteCreatedForPullRequestKey && config.value, + ); if (remoteCreatedForPullRequest.length) { // it's created for pull request @@ -199,21 +268,43 @@ export class PullRequestGitHelper { branch: branchName!, remote: remoteName, createdForPullRequest, - remoteInUse + remoteInUse, }; - } catch (_) { + } + + } catch (e) { + if (branchName) { return { - branch: branchName! + branch: branchName!, }; + } else { + Logger.error(`getBranchNRemoteForPullRequest failed ${e}`, PullRequestGitHelper.ID); + return null; } + } + return null; + } + 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; } + } - return null; + private static buildPullRequestMetadata(pullRequest: PullRequestModel) { + return `${pullRequest.base.repositoryCloneUrl.owner}#${pullRequest.base.repositoryCloneUrl.repositoryName}#${pullRequest.number}`; } - 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 { @@ -224,17 +315,37 @@ export class PullRequestGitHelper { return { owner: owner, repositoryName: repo, - prNumber: Number(prNumber) + prNumber: Number(prNumber), }; } } + return undefined; + } + + private static getMetadataKeyForBranch(branchName: string): string { + return `branch.${branchName}.${PullRequestMetadataKey}`; } - static async getMatchingPullRequestMetadataForBranch(repository: Repository, branchName: string): Promise { + static async getMatchingPullRequestMetadataForBranch( + repository: Repository, + branchName: string, + ): Promise { try { - const configKey = `branch.${branchName}.${PullRequestMetadataKey}`; - const configValue = await repository.getConfig(configKey); - return PullRequestGitHelper.parsePullRequestMetadata(configValue); + const configKey = this.getMetadataKeyForBranch(branchName); + const allConfigs = await repository.getConfigs(); + // When the same branch name has been associated with multiple PRs over + // time (resulting in duplicate config entries), prefer the most recent + // association: parse the trailing PR number and sort numerically so the + // highest PR number wins. Entries that fail to parse, or whose PR + // number is not a finite integer (e.g. malformed config values like + // `owner#repo#abc`), are filtered out so they cannot poison the sort. + // Returns `undefined` when no entries parse to valid metadata. + const matchingConfigs = allConfigs + .filter(config => config.key === configKey) + .map(config => ({ config, metadata: PullRequestGitHelper.parsePullRequestMetadata(config.value) })) + .filter((entry): entry is { config: { key: string; value: string }, metadata: PullRequestMetadata } => !!entry.metadata && Number.isFinite(entry.metadata.prNumber)) + .sort((a, b) => b.metadata.prNumber - a.metadata.prNumber); + return matchingConfigs[0]?.metadata; } catch (_) { return; } @@ -243,16 +354,16 @@ export class PullRequestGitHelper { static async createRemote(repository: Repository, baseRemote: Remote, cloneUrl: Protocol) { Logger.appendLine(`create remote for ${cloneUrl}.`, PullRequestGitHelper.ID); - const remotes = parseRepositoryRemotes(repository); + const remotes = await parseRepositoryRemotesAsync(repository); for (const remote of remotes) { if (new Protocol(remote.url).equals(cloneUrl)) { return remote.remoteName; } } - const remoteName = PullRequestGitHelper.getUniqueRemoteName(repository, cloneUrl.owner); + const remoteName = await PullRequestGitHelper.getUniqueRemoteName(repository, cloneUrl.owner); cloneUrl.update({ - type: baseRemote.gitProtocol.type + type: baseRemote.gitProtocol.type, }); await repository.addRemote(remoteName, cloneUrl.toString()!); await repository.setConfig(`remote.${remoteName}.${PullRequestRemoteMetadataKey}`, 'true'); @@ -270,7 +381,10 @@ export class PullRequestGitHelper { } } - static async calculateUniqueBranchNameForPR(repository: Repository, pullRequest: PullRequestModel): Promise { + static async calculateUniqueBranchNameForPR( + repository: Repository, + pullRequest: PullRequestModel, + ): Promise { const branchName = `pr/${pullRequest.author.login}/${pullRequest.number}`; let result = branchName; let number = 1; @@ -278,7 +392,7 @@ export class PullRequestGitHelper { while (true) { try { await repository.getBranch(result); - result = branchName + '-' + number++; + result = `${branchName}-${number++}`; } catch (err) { break; } @@ -287,25 +401,71 @@ export class PullRequestGitHelper { return result; } - static getUniqueRemoteName(repository: Repository, name: string) { + static async getUniqueRemoteName(repository: Repository, name: string) { let uniqueName = name; let number = 1; - const remotes = parseRepositoryRemotes(repository); + const remotes = await parseRepositoryRemotesAsync(repository); + // eslint-disable-next-line no-loop-func while (remotes.find(e => e.remoteName === uniqueName)) { - uniqueName = name + number++; + uniqueName = `${name}${number++}`; } return uniqueName; } - static getHeadRemoteForPullRequest(remotes: Remote[], pullRequest: PullRequestModel & IResolvedPullRequestModel): Remote | undefined { - return remotes.find(remote => remote.gitProtocol && remote.gitProtocol.equals(pullRequest.head.repositoryCloneUrl)); + static getHeadRemoteForPullRequest( + remotes: Remote[], + pullRequest: PullRequestModel & IResolvedPullRequestModel, + ): Remote | undefined { + return remotes.find( + remote => remote.gitProtocol && (remote.gitProtocol.owner.toLowerCase() === pullRequest.head.repositoryCloneUrl.owner.toLowerCase()) && (remote.gitProtocol.repositoryName.toLowerCase() === pullRequest.head.repositoryCloneUrl.repositoryName.toLowerCase()) + ); } - static async associateBranchWithPullRequest(repository: Repository, pullRequest: PullRequestModel, 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)); + static async associateBranchWithPullRequest( + repository: Repository, + pullRequest: PullRequestModel | undefined, + branchName: string, + ) { + 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); + } } -} \ No newline at end of file +} diff --git a/src/github/pullRequestModel.ts b/src/github/pullRequestModel.ts index d962d9207c..12baa0c246 100644 --- a/src/github/pullRequestModel.ts +++ b/src/github/pullRequestModel.ts @@ -3,21 +3,102 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +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 * as OctokitTypes from '@octokit/types'; -import { GitHubRef } from '../common/githubRef'; -import { Remote } from '../common/remote'; +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 { PullRequest, GithubItemStateEnum, ISuggestedReviewer, PullRequestChecks, IAccount, IRawFileChange, PullRequestMergeability } from './interface'; -import { IssueModel } from './issueModel'; -import { isReviewEvent, ReviewEvent as CommonReviewEvent, TimelineEvent } from '../common/timelineEvent'; -import { ReviewEvent } from './interface'; -import { convertPullRequestsGetCommentsResponseItemToComment, convertRESTPullRequestToRawPullRequest, convertRESTReviewEvent, convertRESTUserToAccount, parseGraphQLComment, parseGraphQLReviewEvent, parseGraphQLTimelineEvents, parseMergeability } from './utils'; -import { AddCommentResponse, DeleteReviewResponse, EditCommentResponse, GetChecksResponse, isCheckRun, MarkPullRequestReadyForReviewResponse, PendingReviewIdResponse, PullRequestCommentsResponse, PullRequestResponse, StartReviewResponse, SubmitReviewResponse, TimelineEventsResponse } from './graphql'; +import { + AddCommentResponse, + AddReactionResponse, + AddReviewRequestResponse as AddReviewsResponse, + AddReviewThreadResponse, + ConvertPullRequestToDraftResponse, + DeleteReactionResponse, + DeleteReviewResponse, + DequeuePullRequestResponse, + EditCommentResponse, + EnqueuePullRequestResponse, + FileContentResponse, + GetReviewRequestsResponse, + MergeMethod as GraphQLMergeMethod, + LatestReviewCommitResponse, + MarkPullRequestReadyForReviewResponse, + MergePullRequestInput, + MergePullRequestResponse, + PendingReviewIdResponse, + PullRequestCommentsResponse, + PullRequestFilesResponse, + PullRequestMergabilityResponse, + ReactionGroup, + ResolveReviewThreadResponse, + ReviewThread, + StartReviewResponse, + SubmitReviewResponse, + TimelineEventsResponse, + UnresolveReviewThreadResponse, + UpdateIssueResponse, + UpdatePullRequestBranchResponse, +} from './graphql'; +import { + AccountType, + ConvertToDraft, + GithubItemStateEnum, + IAccount, + IGitTreeItem, + IRawFileChange, + IRawFileContent, + IssueReference, + ISuggestedReviewer, + ITeam, + MergeMethod, + MergeQueueEntry, + PullRequest, + PullRequestChecks, + PullRequestMergeability, + PullRequestReviewRequirement, + ReadyForReview, + ReviewEventEnum, +} from './interface'; +import { IssueChangeEvent, IssueModel } from './issueModel'; +import { compareCommits, GraphQLError, GraphQLErrorType } from './loggingOctokit'; +import { + convertRESTPullRequestToRawPullRequest, + convertRESTReviewEvent, + getReactionGroup, + insertNewCommitsSinceReview, + parseAccount, + parseCombinedTimelineEvents, + parseGraphQLComment, + parseGraphQLReaction, + parseGraphQLReviewers, + parseGraphQLReviewEvent, + parseGraphQLReviewThread, + 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 { IComment } from '../common/comment'; -import { formatError } from '../common/utils'; +import { Remote } from '../common/remote'; +import { DEFAULT_MERGE_METHOD, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; import { ITelemetry } from '../common/telemetry'; +import { ClosedEvent, EventType, ReviewEvent, ReviewResolveInfo, 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; @@ -27,40 +108,104 @@ export interface IResolvedPullRequestModel extends IPullRequestModel { head: GitHubRef; } -interface NewCommentPosition { - path: string; - position: number; +export interface ReviewThreadChangeEvent { + added: IReviewThread[]; + changed: IReviewThread[]; + removed: IReviewThread[]; } -interface ReplyCommentPosition { - inReplyTo: string; +export interface FileViewedStateChangeEvent { + changed: { + fileName: string; + viewed: ViewedState; + }[]; } -export class PullRequestModel extends IssueModel implements IPullRequestModel { - static ID = 'PullRequestModel'; +export type FileViewedState = { [key: string]: ViewedState }; + +type TreeDataMode = '100644' | '100755' | '120000'; + +const BATCH_SIZE = 50; + +export class PullRequestModel extends IssueModel implements IPullRequestModel { + static override ID = 'PullRequestModel'; public isDraft?: boolean; - public item: PullRequest; + public reviewers?: (IAccount | ITeam)[]; public localBranchName?: string; public mergeBase?: string; + public mergeQueueEntry?: MergeQueueEntry; + public conflicts?: string[]; public suggestedReviewers?: ISuggestedReviewer[]; + public hasChangesSinceLastReview?: boolean; + public closingIssues: IssueReference[] = []; + 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[] | undefined; + private _reviewThreadsCacheInitialized = false; + 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 = this._register(new vscode.EventEmitter()); + public onDidChangeFileViewedState = this._onDidChangeFileViewedState.event; + + private _onDidChangeChangesSinceReview = this._register(new vscode.EventEmitter()); + public onDidChangeChangesSinceReview = this._onDidChangeChangesSinceReview.event; + + private _hasComments: boolean; + private _comments: readonly IComment[] | undefined; + // Whether the pull request is currently checked out locally - public isActive: boolean; - _telemetry: ITelemetry; + private _isActive: boolean; + public get isActive(): boolean { + return this._isActive; + } + public set isActive(isActive: boolean) { + this._isActive = isActive; + } + - constructor(telemetry: ITelemetry, githubRepository: GitHubRepository, remote: Remote, item: PullRequest, isActive?: boolean) { - super(githubRepository, remote, item); + constructor( + private readonly credentialStore: CredentialStore, + telemetry: ITelemetry, + githubRepository: GitHubRepository, + remote: Remote, + item: PullRequest, + isActive?: boolean, + ) { + super(telemetry, githubRepository, remote, item, true); - this._telemetry = telemetry; this.isActive = !!isActive; + + this._showChangesSinceReview = false; + + this.update(item); } - public get isMerged(): boolean { - return this.state === GithubItemStateEnum.Merged; + public clear() { + this.comments = []; + this._reviewThreadsCacheInitialized = false; + this._reviewThreadsCache = undefined; + } + + public async initializeReviewThreadCache(): Promise { + const threads = await this.getReviewThreads(); + this._reviewThreadsCacheInitialized = true; + return threads; + } + + public get reviewThreadsCache(): IReviewThread[] { + return this._reviewThreadsCache ?? []; + } + + public get reviewThreadsCacheReady(): boolean { + return this._reviewThreadsCacheInitialized; } public get hasPendingReview(): boolean { @@ -74,29 +219,75 @@ export class PullRequestModel extends IssueModel implements IPullRequestModel { } } + public get showChangesSinceReview() { + return this._showChangesSinceReview; + } + + public set showChangesSinceReview(isChangesSinceReview: boolean) { + if (this._showChangesSinceReview !== isChangesSinceReview) { + this._showChangesSinceReview = isChangesSinceReview; + this._fileChanges.clear(); + this._onDidChangeChangesSinceReview.fire(); + } + } + + get comments(): readonly IComment[] { + return this._comments ?? []; + } + + set comments(comments: readonly IComment[]) { + this._comments = comments; + this._onDidChange.fire({ comments: true }); + } + + get fileChangeViewedState(): FileViewedState { + return this._fileChangeViewedState; + } + + public isRemoteHeadDeleted?: boolean; public head: GitHubRef | null; + 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; - this.suggestedReviewers = item.suggestedReviewers; + 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; + this.closingIssues = item.closingIssues ?? []; + 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); + 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); + 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; } /** @@ -116,7 +307,7 @@ export class PullRequestModel extends IssueModel implements IPullRequestModel { return true; } - const reason = `There is no upstream branch for Pull Request #${this.number}. View it on GitHub for more details`; + const reason = vscode.l10n.t('There is no upstream branch for Pull Request #{0}. View it on GitHub for more details', this.number); if (message) { message += `: ${reason}`; @@ -124,8 +315,9 @@ export class PullRequestModel extends IssueModel implements IPullRequestModel { message = reason; } - vscode.window.showWarningMessage(message, 'Open in GitHub').then(action => { - if (action && action === 'Open in GitHub') { + const openString = vscode.l10n.t('Open on GitHub'); + vscode.window.showWarningMessage(message, openString).then(action => { + if (action && action === openString) { vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(this.html_url)); } }); @@ -133,20 +325,68 @@ export class PullRequestModel extends IssueModel implements IPullRequestModel { 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. + * @param expectedRemoteHead When the caller just pushed a commit + * themselves (e.g. an attestation commit), pass the sha they expect + * GitHub's PR API to now report as the head. The head check will poll + * briefly for the API to reflect this specific sha before proceeding - + * a third-party push during the same interval will still be caught and + * rejected because GitHub would report *that* sha instead. */ - 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, expectedRemoteHead?: 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, 'PullRequestModel.approve'))?.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, 'PullRequestModel.approve'))?.head?.sha; + rejectMessage = vscode.l10n.t('The remote head of the pull request branch has changed. Please refresh the pull request before approving.'); + } + + // If the caller just pushed a commit, GitHub's PR API can briefly lag + // behind. Poll for it to catch up to *that specific sha* - never + // bypass the check. A concurrent third-party push would leave + // `remoteHead` at some other sha and we'd still reject below. + if (expectedRemoteHead && remoteHead !== expectedRemoteHead) { + const maxAttempts = 5; + const delayMs = 750; + for (let attempt = 0; attempt < maxAttempts && remoteHead !== expectedRemoteHead; attempt++) { + await new Promise(resolve => setTimeout(resolve, delayMs)); + remoteHead = (await this.githubRepository.getPullRequest(this.number, 'PullRequestModel.approve'))?.head?.sha; + } + } + + 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._onDidChange.fire({ comments: true, timeline: true }); return x; }); } @@ -155,39 +395,148 @@ export class PullRequestModel extends IssueModel implements IPullRequestModel { * 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); + + /* __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); - return action - .then(x => { /* __GDPR__ - "pr.requestChanges" : {} + "pr.merge.success" : {} */ - this._telemetry.sendTelemetryEvent('pr.requestChanges'); - 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.pulls.update({ + const ret = await octokit.call(octokit.api.pulls.update, { owner: remote.owner, repo: remote.repositoryName, pull_number: this.number, - state: 'closed' + state: 'closed', }); /* __GDPR__ "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 + } + } + }; } /** @@ -195,10 +544,10 @@ export class PullRequestModel extends IssueModel implements IPullRequestModel { * @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.pulls.createReview({ + const { data } = await octokit.call(octokit.api.pulls.createReview, { owner: remote.owner, repo: remote.repositoryName, pull_number: this.number, @@ -206,7 +555,8 @@ export class PullRequestModel extends IssueModel implements IPullRequestModel { body: message, }); - return convertRESTReviewEvent(data, this.githubRepository); + this._onDidChange.fire({ timeline: true }); + return convertRESTReviewEvent(data as OctokitCommon.PullsCreateReviewResponseData, this.githubRepository); } /** @@ -214,24 +564,39 @@ export class PullRequestModel extends IssueModel implements IPullRequestModel { * @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 { - const pendingReviewId = await this.getPendingReviewId(); + async submitReview(event?: ReviewEventEnum, body?: string): Promise { + let pendingReviewId = await this.getPendingReviewId(); const { mutate, schema } = await this.githubRepository.ensure(); + if (!pendingReviewId && (event === ReviewEventEnum.Comment)) { + // Create a new review so that we can comment on it. + pendingReviewId = await this.startReview(); + } + if (pendingReviewId) { const { data } = await mutate({ mutation: schema.SubmitReview, variables: { id: pendingReviewId, - event: event || ReviewEvent.Comment, - body - } + event: event || ReviewEventEnum.Comment, + body, + }, }); this.hasPendingReview = false; await this.updateDraftModeContext(); - - return parseGraphQLReviewEvent(data!.submitPullRequestReview.pullRequestReview, this.githubRepository); + const reviewEvent = parseGraphQLReviewEvent(data!.submitPullRequestReview.pullRequestReview, this.githubRepository); + + const threadWithComment = (this._reviewThreadsCache ?? []).find(thread => + thread.comments.length ? (thread.comments[0].pullRequestReviewId === reviewEvent.id) : undefined, + ); + if (threadWithComment) { + threadWithComment.comments = reviewEvent.comments; + 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}.`); } @@ -242,50 +607,116 @@ export class PullRequestModel extends IssueModel implements IPullRequestModel { */ 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, variables: { pullRequestId: this.item.graphNodeId, - author: currentUser - } + author: currentUser, + }, }); - return data.node.reviews.nodes[0].id; + return data.node.reviews.nodes.length > 0 ? data.node.reviews.nodes[0].id : undefined; } catch (error) { return; } } + 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({ + query: schema.LatestReviewCommit, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: this.number, + author: currentUser, + }, + }); + + 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) { + return undefined; + } + } + /** * Delete an existing in progress review. */ - async deleteReview(): Promise<{ deletedReviewId: number, deletedReviewComments: IComment[] }> { + 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, variables: { - input: { pullRequestReviewId: pendingReviewId } - } + input: { pullRequestReviewId: pendingReviewId }, + }, }); 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(); + // Fire change event to update timeline & comment views + this._onDidChange.fire({ timeline: true, comments: true }); return { deletedReviewId: databaseId, - deletedReviewComments: comments.nodes.map(parseGraphQLComment) + deletedReviewComments, }; } /** * Start a new review. * @param initialComment The comment text and position information to begin the review with + * @param commitId The optional commit id to start the review on. Defaults to using the current head commit. */ - async startReview(initialComment: { body: string, path: string, position: number }): Promise { + async startReview(commitId?: string): Promise { const { mutate, schema } = await this.githubRepository.ensure(); const { data } = await mutate({ mutation: schema.StartReview, @@ -293,107 +724,150 @@ export class PullRequestModel extends IssueModel implements IPullRequestModel { input: { body: '', pullRequestId: this.item.graphNodeId, - comments: initialComment - } - } + commitOID: commitId || this.head?.sha, + }, + }, }); if (!data) { throw new Error('Failed to start review'); } - this.hasPendingReview = true; - await this.updateDraftModeContext(); - - return parseGraphQLComment(data.addPullRequestReview.pullRequestReview.comments.nodes[0]); + return data.addPullRequestReview.pullRequestReview.id; } /** - * Create a new review comment. Adds to an existing review if there is one or creates a single review comment. - * @param body The text of the new comment - * @param commentPath The file path where the comment should be made - * @param position The line number within the file to add the comment + * Creates a new review thread, either adding it to an existing pending review, or creating + * a new review. + * @param body The body of the thread's first comment. + * @param commentPath The path to the file being commented on. + * @param startLine The start line on which to add the comment. + * @param endLine The end line on which to add the comment. + * @param side The side the comment should be deleted on, i.e. the original or modified file. + * @param suppressDraftModeUpdate If a draft mode change should event should be suppressed. In the + * case of a single comment add, the review is created and then immediately submitted, so this prevents + * a "Pending" label from flashing on the comment. + * @returns The new review thread object. */ - async createReviewComment(body: string, commentPath: string, position: number): Promise { + async createReviewThread( + body: string, + commentPath: string, + startLine: number | undefined, + endLine: number | undefined, + side: DiffSide, + suppressDraftModeUpdate?: boolean, + ): Promise { if (!this.validatePullRequestModel('Creating comment failed')) { return; } - const pendingReviewId = await this.getPendingReviewId(); - if (pendingReviewId) { - return this.addCommentToPendingReview(pendingReviewId, body, { path: commentPath, position }); + + const { mutate, schema } = await this.githubRepository.ensure(); + const { data } = await mutate({ + mutation: schema.AddReviewThread, + variables: { + input: { + path: commentPath, + body, + pullRequestId: this.graphNodeId, + pullRequestReviewId: pendingReviewId, + startLine: startLine === endLine ? undefined : startLine, + line: 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.'); } - const githubRepository = this.githubRepository; - const { octokit, remote } = await githubRepository.ensure(); + if (!data.addPullRequestReviewThread.thread) { + throw new Error('File has been deleted.'); + } - try { - const ret = await octokit.pulls.createReviewComment({ - owner: remote.owner, - repo: remote.repositoryName, - pull_number: this.number, - body: body, - commit_id: this.head.sha, - path: commentPath, - position: position - }); + if (!suppressDraftModeUpdate) { + this.hasPendingReview = true; + await this.updateDraftModeContext(); + } - return this.addCommentPermissions(convertPullRequestsGetCommentsResponseItemToComment(ret.data, githubRepository)); - } catch (e) { - throw formatError(e); + 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; } /** - * Creates a review comment in reply to an existing review comment. - * @param body The text of the new comment - * @param reply_to The comment to reply to + * Creates a new comment in reply to an existing comment + * @param body The text of the comment to be created + * @param inReplyTo The id of the comment this is in reply to + * @param isSingleComment Whether this is a single comment, i.e. one that + * will be immediately submitted and so should not show a pending label + * @param commitId The commit id the comment was made on + * @returns The new comment */ - async createReviewCommentReply(body: string, reply_to: IComment): Promise { - const pendingReviewId = await this.getPendingReviewId(); - if (pendingReviewId) { - return this.addCommentToPendingReview(pendingReviewId, body, { inReplyTo: reply_to.graphNodeId }); + async createCommentReply( + body: string, + inReplyTo: string, + isSingleComment: boolean, + commitId?: string, + ): Promise { + if (!this.validatePullRequestModel('Creating comment failed')) { + return; } - const { octokit, remote } = await this.githubRepository.ensure(); - - try { - const ret = await octokit.pulls.createReplyForReviewComment({ - owner: remote.owner, - repo: remote.repositoryName, - pull_number: this.number, - body: body, - comment_id: Number(reply_to.id) - }); - - return this.addCommentPermissions(convertPullRequestsGetCommentsResponseItemToComment(ret.data, this.githubRepository)); - } catch (e) { - throw formatError(e); + let pendingReviewId = await this.getPendingReviewId(); + if (!pendingReviewId) { + pendingReviewId = await this.startReview(commitId); } - } - private async addCommentToPendingReview(reviewId: string, body: string, position: NewCommentPosition | ReplyCommentPosition): Promise { const { mutate, schema } = await this.githubRepository.ensure(); const { data } = await mutate({ mutation: schema.AddComment, variables: { input: { - pullRequestReviewId: reviewId, + pullRequestReviewId: pendingReviewId, body, - ...position - } - } + inReplyTo, + commitOID: commitId || this.head?.sha, + }, + }, }); - const { comment } = data!.addPullRequestReviewComment; - return parseGraphQLComment(comment); + if (!data) { + throw new Error('Creating comment reply failed.'); + } + + const { comment } = data.addPullRequestReviewComment; + const newComment = parseGraphQLComment(comment, false, false, this.githubRepository); + + if (isSingleComment) { + newComment.isDraft = false; + } + + const threadWithComment = this._reviewThreadsCache?.find(thread => + thread.comments.some(comment => comment.graphNodeId === inReplyTo), + ); + if (threadWithComment) { + threadWithComment.comments.push(newComment); + this._onDidChangeReviewThreads.fire({ added: [], changed: [threadWithComment], removed: [] }); + } + + this._onDidChange.fire({ timeline: true, comments: true }); + return newComment; } /** * Check whether there is an existing pending review and update the context key to control what comment actions are shown. */ async validateDraftMode(): Promise { - const inDraftMode = !!await this.getPendingReviewId(); + const inDraftMode = !!(await this.getPendingReviewId()); if (inDraftMode !== this.hasPendingReview) { this.hasPendingReview = inDraftMode; } @@ -409,13 +883,143 @@ export class PullRequestModel extends IssueModel implements IPullRequestModel { } } - private addCommentPermissions(rawComment: IComment): IComment { - const isCurrentUser = this.githubRepository.isCurrentUser(rawComment.user!.login); - const notOutdated = rawComment.position !== null; - rawComment.canEdit = isCurrentUser && notOutdated; - rawComment.canDelete = isCurrentUser && notOutdated; + /** + * 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]; + const resolveInfo: ReviewResolveInfo = { + threadId: thread.id, + canResolve: thread.viewerCanResolve, + canUnresolve: thread.viewerCanUnresolve, + isResolved: thread.isResolved + }; + // A single review can have comments on multiple threads. Track each + // thread's resolve info by its id so the webview can resolve the + // correct thread when the user clicks resolve/unresolve on any of + // the comment groups within the review. + if (!prReviewThreadEvent.reviewThreads) { + prReviewThreadEvent.reviewThreads = {}; + } + prReviewThreadEvent.reviewThreads[thread.id] = resolveInfo; + }); - return rawComment; + 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); + } } /** @@ -425,18 +1029,42 @@ export class PullRequestModel extends IssueModel implements IPullRequestModel { */ async editReviewComment(comment: IComment, text: string): Promise { const { mutate, schema } = await this.githubRepository.ensure(); + let threadWithComment = this._reviewThreadsCache?.find(thread => + thread.comments.some(c => c.graphNodeId === comment.graphNodeId), + ); + + if (!threadWithComment) { + return this.editIssueComment(comment, text); + } const { data } = await mutate({ mutation: schema.EditComment, variables: { input: { pullRequestReviewCommentId: comment.graphNodeId, - body: text - } - } + body: text, + }, + }, }); - return parseGraphQLComment(data!.updatePullRequestReviewComment.pullRequestReviewComment); + if (!data) { + throw new Error('Editing review comment failed.'); + } + + 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; } /** @@ -446,102 +1074,473 @@ export class PullRequestModel extends IssueModel implements IPullRequestModel { async deleteReviewComment(commentId: string): Promise { 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)) ?? -1; - await octokit.pulls.deleteReviewComment({ - owner: remote.owner, - repo: remote.repositoryName, - comment_id: Number(commentId) - }); + if (threadIndex === -1) { + this.deleteIssueComment(commentId); + } else { + await octokit.call(octokit.api.pulls.deleteReviewComment, { + owner: remote.owner, + repo: remote.repositoryName, + comment_id: id, + }); + + if (threadIndex > -1) { + 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._onDidChangeReviewThreads.fire({ added: [], changed: [], removed: [threadWithComment] }); + } else { + this._onDidChangeReviewThreads.fire({ added: [], changed: [threadWithComment], removed: [] }); + } + this._onDidChange.fire({ timeline: true }); + } + } } catch (e) { throw new Error(formatError(e)); } } + 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}` + } + }); + + if (!data.repository?.object.text) { + return undefined; + } + + Logger.debug(`Fetch file content - end`, GitHubRepository.ID); + + return data.repository.object.text; + } + + public async compareBaseBranchForMerge(headOwner: string, headRef: string, baseOwner: string, baseRef: string): Promise { + const { octokit, remote } = await this.githubRepository.ensure(); + + // Get the files that would change as part of the merge + const compareData = await octokit.call(octokit.api.repos.compareCommits, { + repo: remote.repositoryName, + 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 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(); + + // 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; + } + + 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' }; + } + + 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 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 { mutate, schema } = await this.githubRepository.ensure(); + + await mutate({ + mutation: schema.UpdatePullRequestBranch, + variables: { + input: { + pullRequestId: this.graphNodeId, + expectedHeadOid: this.head.sha + } + } + }); + + 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 }); + + } catch (e) { + 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; + } + + /** + * Update the base branch of the pull request. + * @param newBaseBranch The new base branch name + */ + async updateBaseBranch(newBaseBranch: string): Promise { + Logger.debug(`Updating base branch to ${newBaseBranch} - enter`, PullRequestModel.ID); + try { + const { mutate, schema } = await this.githubRepository.ensure(); + + const { data } = await mutate({ + mutation: schema.UpdatePullRequest, + variables: { + input: { + pullRequestId: this.graphNodeId, + baseRefName: newBaseBranch, + }, + }, + }); + + 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 { + async getReviewRequests(): Promise<(IAccount | ITeam)[]> { + Logger.debug('Get Review Requests - enter', PullRequestModel.ID); + const githubRepository = this.githubRepository; - const { remote, octokit } = await githubRepository.ensure(); - const result = await octokit.pulls.listRequestedReviewers({ - owner: remote.owner, - repo: remote.repositoryName, - pull_number: this.number + 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 + }, }); - return result.data.users.map((user: any) => convertRESTUserToAccount(user, githubRepository)); + 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: string[]): Promise { - const { octokit, remote } = await this.githubRepository.ensure(); - await octokit.pulls.requestReviewers({ - owner: remote.owner, - repo: remote.repositoryName, - pull_number: this.number, - reviewers + 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(reviewer: string): Promise { + async deleteReviewRequest(reviewers: IAccount[], teamReviewers: ITeam[]): Promise { + if (reviewers.length === 0 && teamReviewers.length === 0) { + return; + } const { octokit, remote } = await this.githubRepository.ensure(); - await octokit.pulls.removeRequestedReviewers({ + await octokit.call(octokit.api.pulls.removeRequestedReviewers, { owner: remote.owner, repo: remote.repositoryName, pull_number: this.number, - reviewers: [reviewer] + 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 }); } - /** - * Get all review comments. - */ - async getReviewComments(): Promise { - const { remote, query, schema } = await this.githubRepository.ensure(); - try { - const { data } = await query({ - query: schema.PullRequestComments, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: this.number, + 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; + } - const comments = data.repository.pullRequest.reviews.nodes - .map((node: any) => node.comments.nodes.map((comment: any) => parseGraphQLComment(comment), remote)) - .reduce((prev: any, curr: any) => prev.concat(curr), []) - .sort((a: IComment, b: IComment) => { return a.createdAt > b.createdAt ? 1 : -1; }); + private async getRawReviewComments(): Promise { + Logger.debug(`Fetching review comments for PR #${this.number} - enter`, PullRequestModel.ID); - return comments; + 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.appendLine(`Failed to get pull request review comments: ${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. */ - async getCommits(): Promise { + async getCommits(): Promise { try { Logger.debug(`Fetch commits of PR #${this.number} - enter`, PullRequestModel.ID); const { remote, octokit } = await this.githubRepository.ensure(); - const commitData = await octokit.pulls.listCommits({ + const commitData = await restPaginate(octokit.api.pulls.listCommits, { pull_number: this.number, owner: remote.owner, - repo: remote.repositoryName + 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 []; @@ -552,243 +1551,405 @@ export class PullRequestModel extends IssueModel implements IPullRequestModel { * Get all changed files within a commit * @param commit The commit */ - async getCommitChangedFiles(commit: OctokitTypes.PullsListCommitsResponseData[0]): Promise { + async getCommitChangedFiles( + 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.repos.getCommit({ + const fullCommit = await octokit.call(octokit.api.repos.getCommit, { owner: remote.owner, repo: remote.repositoryName, - ref: commit.sha + 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.filter(file => !!file.patch); + return fullCommit.data.files ?? []; } catch (e) { vscode.window.showErrorMessage(`Fetching commit file changes failed: ${formatError(e)}`); return []; } } - /** - * 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.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.content ?? ''; - const buff = new Buffer(contents, fileContent.data.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(); + async getStatusChecks(): Promise<[PullRequestChecks | null, PullRequestReviewRequirement | null]> { + return this.githubRepository.getStatusChecks(this.number); + } + + 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]); + } + + /* __GDPR__ + "pr.openChanges" : {} + */ + folderManager.telemetry.sendTelemetryEvent('pr.openChanges'); + if (openToTheSide) { + if (vscode.window.tabGroups.all.length < 2) { + await vscode.commands.executeCommand('workbench.action.splitEditor'); + } + + await vscode.commands.executeCommand('workbench.action.focusSecondEditorGroup'); + } + + return vscode.commands.executeCommand('vscode.changes', vscode.l10n.t('Changes in Pull Request #{0}', pullRequestModel.number), args); + } + + static async openCommitChanges(extensionUri: vscode.Uri, githubRepository: GitHubRepository, commitSha: string) { try { - const { data } = await query({ - query: schema.TimelineEvents, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: this.number + 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 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; + } + + // 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]); } - }); - const ret = data.repository.pullRequest.timelineItems.nodes; - const events = parseGraphQLTimelineEvents(ret, this.githubRepository); - await this.addReviewTimelineEventComments(events); + } - return events; - } catch (e) { - console.log(e); - return []; + /* __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)); } } - private async addReviewTimelineEventComments(events: TimelineEvent[]): Promise { - interface CommentNode extends IComment { - childComments?: CommentNode[]; + static async openDiffFromComment( + folderManager: FolderRepositoryManager, + pullRequestModel: PullRequestModel, + comment: IComment, + ): Promise { + const contentChanges = await pullRequestModel.getFileChangesInfo(); + const change = contentChanges.find( + fileChange => fileChange.fileName === comment.path || fileChange.previousFileName === comment.path, + ); + if (!change) { + throw new Error(`Can't find matching file`); } - const reviewEvents = events.filter(isReviewEvent); - const reviewComments = await this.getReviewComments() as CommentNode[]; + const pathSegments = comment.path!.split('/'); + const line = (comment.diffHunks && comment.diffHunks.length > 0) ? comment.diffHunks[0].newLineNumber : undefined; + this.openDiff(folderManager, pullRequestModel, change, pathSegments[pathSegments.length - 1], line); + } - const reviewEventsById = reviewEvents.reduce((index, evt) => { - index[evt.id] = evt; - evt.comments = []; - return index; - }, {} as { [key: number]: CommonReviewEvent }); + static async openFirstDiff( + folderManager: FolderRepositoryManager, + pullRequestModel: PullRequestModel, + ) { + const contentChanges = await pullRequestModel.getFileChangesInfo(); + if (!contentChanges.length) { + return; + } - const commentsById = reviewComments.reduce((index, evt) => { - index[evt.id] = evt; - return index; - }, {} as { [key: number]: CommentNode }); + const firstChange = contentChanges[0]; + this.openDiff(folderManager, pullRequestModel, firstChange, firstChange.fileName); + } - 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]; + static async openDiff( + folderManager: FolderRepositoryManager, + pullRequestModel: PullRequestModel, + change: SlimFileChange | InMemFileChange, + diffTitle: string, + line?: number + ): Promise { + let headUri, baseUri: vscode.Uri; + if (!pullRequestModel.equals(folderManager.activePullRequest)) { + const headCommit = pullRequestModel.head!.sha; + const parentFileName = change.status === GitChangeType.RENAME ? change.previousFileName! : change.fileName; + headUri = toPRUri( + vscode.Uri.file(resolvePath(folderManager.repository.rootUri, change.fileName)), + pullRequestModel, + change.baseCommit, + headCommit, + change.fileName, + false, + change.status, + change.previousFileName + ); + baseUri = toPRUri( + vscode.Uri.file(resolvePath(folderManager.repository.rootUri, parentFileName)), + pullRequestModel, + change.baseCommit, + headCommit, + change.fileName, + true, + change.status, + change.previousFileName + ); + } else { + const uri = vscode.Uri.file(path.resolve(folderManager.repository.rootUri.fsPath, change.fileName)); + + headUri = + change.status === GitChangeType.DELETE + ? toReviewUri( + uri, + undefined, + undefined, + '', + false, + { base: false }, + folderManager.repository.rootUri, + ) + : uri; + + const mergeBase = pullRequestModel.mergeBase || pullRequestModel.base.sha; + baseUri = toReviewUri( + uri, + change.status === GitChangeType.RENAME ? change.previousFileName : change.fileName, + undefined, + change.status === GitChangeType.ADD ? '' : mergeBase, + false, + { base: true }, + folderManager.repository.rootUri, + ); } - roots.forEach(c => { - const review = reviewEventsById[c.pullRequestReviewId!]; - if (review) { - review.comments = review.comments.concat(c).concat(c.childComments || []); - } - }); + vscode.commands.executeCommand( + 'vscode.diff', + baseUri, + headUri, + `${diffTitle} (Pull Request)`, + line ? { selection: { start: { line, character: 0 }, end: { line, character: 0 } } } : {}, + ); + } - 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 _fileChanges: Map = new Map(); + get fileChanges(): Map { + return this._fileChanges; } - /** - * Get the status checks of the pull request, those for the last commit. - */ - async getStatusChecks(): Promise { - const { query, remote, schema } = await this.githubRepository.ensure(); - const result = await query({ - query: schema.GetChecks, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: this.number - } + private _rawFileChangesCache: IRawFileChange[] | undefined; + get rawFileChanges(): IRawFileChange[] | undefined { + return this._rawFileChangesCache; + } + + async getFileChangesInfo() { + this._fileChanges.clear(); + const data = await this.getRawFileChangesInfo(); + const mergebase = this.mergeBase || this.base.sha; + const parsed = await parseDiff(data, mergebase); + parsed.forEach(fileChange => { + this._fileChanges.set(fileChange.fileName, fileChange); }); + return parsed; + } - // 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; + async getPatch(): Promise { + const githubRepository = this.githubRepository; + const { octokit, remote } = await githubRepository.ensure(); - if (!statusCheckRollup) { - return { - state: 'pending', - statuses: [] - }; + 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'); } + } - return { - state: statusCheckRollup.state.toLowerCase(), - statuses: 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() || 'pending', - description: context.title, - context: context.name, - target_url: context.detailsUrl - }; - } else { - return { - id: context.id, - url: context.targetUrl, - avatar_url: context.avatarUrl, - state: context.state?.toLowerCase(), - description: context.description, - context: context.context, - target_url: context.targetUrl - }; - } - }) - }; + 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. */ - async getFileChangesInfo(): Promise { + 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) { - const info = await octokit.pulls.get({ + 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, - pull_number: this.number + pull_number: this.number, }); this.update(convertRESTPullRequestToRawPullRequest(info.data, githubRepository)); } + let compareWithBaseRef = this.base.sha; + const latestReview = await this.getViewerLatestReviewCommit(); + const oldHasChangesSinceReview = this.hasChangesSinceLastReview; + this.hasChangesSinceLastReview = latestReview !== undefined && this.head?.sha !== latestReview.sha; + + if (this._showChangesSinceReview && this.hasChangesSinceLastReview && latestReview != undefined) { + compareWithBaseRef = latestReview.sha; + } + if (this.item.merged) { - const repsonse = await octokit.pulls.listFiles({ + 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, - pull_number: this.number + pull_number: this.number, }); // Use the original base to compare against for merged PRs this.mergeBase = this.base.sha; + this._rawFileChangesCache = response; + return response; + } + + 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; - return repsonse.data; + if (oldHasChangesSinceReview !== undefined && oldHasChangesSinceReview !== this.hasChangesSinceLastReview && this.hasChangesSinceLastReview && this._showChangesSinceReview) { + this._onDidChangeChangesSinceReview.fire(); } - const { data } = await octokit.repos.compareCommits({ - repo: remote.repositoryName, - owner: remote.owner, - base: `${this.base.repositoryCloneUrl.owner}:${this.base.ref}`, - head: `${this.head!.repositoryCloneUrl.owner}:${this.head!.ref}`, + Logger.debug(`Fetch file changes and merge base of PR #${this.number} - done, total files ${files.length} `, PullRequestModel.ID,); + this._rawFileChangesCache = files; + return files; + } - }); + get autoMerge(): boolean { + return !!this.item.autoMerge; + } - this.mergeBase = data.merge_base_commit.sha; + get autoMergeMethod(): MergeMethod | undefined { + return this.item.autoMergeMethod; + } - Logger.debug(`Fetch file changes and merge base of PR #${this.number} - done`, PullRequestModel.ID); - return data.files; + get allowAutoMerge(): boolean { + 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 mergability of the pull request. + * Get the current mergeability of the pull request. */ - async getMergability(): 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({ + // 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 + 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); - return parseMergeability(data.repository.pullRequest.mergeable); + const mergeability = parseMergeability(data.repository?.pullRequest.mergeable, data.repository?.pullRequest.mergeStateStatus); + this.item.mergeable = 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.appendLine(`PullRequestModel> Unable to fetch PR Mergeability: ${e}`); - return PullRequestMergeability.Unknown; + Logger.error(`Unable to fetch PR Mergeability: ${e}`, PullRequestModel.ID); + 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(); @@ -797,8 +1958,8 @@ export class PullRequestModel extends IssueModel implements IPullRequestModel { variables: { input: { pullRequestId: this.graphNodeId, - } - } + }, + }, }); /* __GDPR__ @@ -806,15 +1967,487 @@ export class PullRequestModel extends IssueModel implements IPullRequestModel { */ 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" : { - "message" : { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth" } - } + "pr.readyForReview.failure" : {} + */ + this._telemetry.sendTelemetryErrorEvent('pr.readyForReview.failure'); + throw e; + } + } + + /** + * 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.sendTelemetryErrorEvent('pr.readyForReview.failure', { message: formatError(e) }); + 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 => + thread.comments.some(c => c.graphNodeId === graphNodeId), + ); + if (reviewThread) { + const updatedComment = reviewThread.comments.find(c => c.graphNodeId === graphNodeId); + if (updatedComment) { + updatedComment.reactions = parseGraphQLReaction(reactionGroups); + this._onDidChangeReviewThreads.fire({ added: [], changed: [reviewThread], removed: [] }); + } + } + } + + async addCommentReaction(graphNodeId: string, reaction: vscode.CommentReaction): Promise { + const reactionEmojiToContent = getReactionGroup().reduce((prev, curr) => { + prev[curr.label] = curr.title; + return prev; + }, {} as { [key: string]: string }); + const { mutate, schema } = await this.githubRepository.ensure(); + const { data } = await mutate({ + mutation: schema.AddReaction, + variables: { + input: { + subjectId: graphNodeId, + content: reactionEmojiToContent[reaction.label!], + }, + }, + }); + + if (!data) { + throw new Error('Add comment reaction failed.'); + } + + const reactionGroups = data.addReaction.subject.reactionGroups; + this.updateCommentReactions(graphNodeId, reactionGroups); + + return data; + } + + async deleteCommentReaction( + graphNodeId: string, + reaction: vscode.CommentReaction, + ): Promise { + const reactionEmojiToContent = getReactionGroup().reduce((prev, curr) => { + prev[curr.label] = curr.title; + return prev; + }, {} as { [key: string]: string }); + const { mutate, schema } = await this.githubRepository.ensure(); + const { data } = await mutate({ + mutation: schema.DeleteReaction, + variables: { + input: { + subjectId: graphNodeId, + content: reactionEmojiToContent[reaction.label!], + }, + }, + }); + + if (!data) { + throw new Error('Delete comment reaction failed.'); + } + + const reactionGroups = data.removeReaction.subject.reactionGroups; + this.updateCommentReactions(graphNodeId, reactionGroups); + + 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 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) { + this.undoOptimisticResolveState(oldThread); + throw new Error('Resolve review thread failed.'); + } + + 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 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) { + this.undoOptimisticResolveState(oldThread); + throw new Error('Unresolve review thread failed.'); + } + + 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); + } + } + + async enableAutoMerge(mergeMethod: MergeMethod): Promise { + try { + const { mutate, schema } = await this.githubRepository.ensure(); + const { data } = await mutate({ + mutation: schema.EnablePullRequestAutoMerge, + variables: { + input: { + mergeMethod: mergeMethod.toUpperCase(), + pullRequestId: this.graphNodeId + } + } + }); + + if (!data) { + throw new Error('Enable auto-merge failed.'); + } + this.item.autoMerge = true; + this.item.autoMergeMethod = mergeMethod; + } catch (e) { + if (e.message === 'GraphQL error: ["Pull request Pull request is in clean status"]') { + vscode.window.showWarningMessage(vscode.l10n.t('Unable to enable auto-merge. Pull request status checks are already green.')); + } else { + throw e; + } + } + } + + async disableAutoMerge(): Promise { + try { + const { mutate, schema } = await this.githubRepository.ensure(); + const { data } = await mutate({ + mutation: schema.DisablePullRequestAutoMerge, + variables: { + input: { + pullRequestId: this.graphNodeId + } + } + }); + + if (!data) { + throw new Error('Disable auto-merge failed.'); + } + this.item.autoMerge = false; + } catch (e) { + if (e.message === 'GraphQL error: ["Pull request Pull request is in clean status"]') { + vscode.window.showWarningMessage(vscode.l10n.t('Unable to enable auto-merge. Pull request status checks are already green.')); + } else { + throw e; + } + } + } + + 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(); + + const changed: { fileName: string, viewed: ViewedState }[] = []; + let after: string | null = null; + let hasNextPage = false; + + do { + const { data } = await query({ + query: schema.PullRequestFiles, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: this.number, + after: after, + }, + }); + + data.repository.pullRequest.files.nodes.forEach(n => { + if (this._fileChangeViewedState[n.path] !== n.viewerViewedState) { + changed.push({ fileName: n.path, viewed: n.viewerViewedState }); + } + // No event for setting the file viewed state here. + // Instead, wait until all the changes have been made and set the context at the end. + this.setFileViewedState(n.path, n.viewerViewedState, false); + }); + + hasNextPage = data.repository.pullRequest.files.pageInfo.hasNextPage; + after = data.repository.pullRequest.files.pageInfo.endCursor; + } while (hasNextPage); + + if (changed.length) { + this._onDidChangeFileViewedState.fire({ changed }); + } + } + + 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 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); + } + } + + // 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 this.markFiles(Array.from(this.fileChanges.keys()), true, 'unviewed'); + } + + private setFileViewedState(fileSubpath: string, viewedState: ViewedState, event: boolean) { + 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); + this._unviewedFiles.delete(filePath); + break; + } + case ViewedState.UNVIEWED: { + this._viewedFiles.delete(filePath); + this._unviewedFiles.add(filePath); + break; + } + case ViewedState.VIEWED: { + this._viewedFiles.add(filePath); + this._unviewedFiles.delete(filePath); + } + } + this._fileChangeViewedState[fileSubpath] = viewedState; + if (event) { + this._onDidChangeFileViewedState.fire({ changed: [{ fileName: fileSubpath, viewed: viewedState }] }); + } + } + + public getViewedFileStates() { + return { + viewed: this._viewedFiles, + unviewed: this._unviewedFiles + }; + } +} + +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 6d4ef99684..2ba09419a8 100644 --- a/src/github/pullRequestOverview.ts +++ b/src/github/pullRequestOverview.ts @@ -4,242 +4,516 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; -import * as path from 'path'; +import * as crypto from 'crypto'; import * as vscode from 'vscode'; -import { GithubItemStateEnum, ReviewEvent, ReviewState, IAccount, MergeMethodsAvailability, MergeMethod, ISuggestedReviewer } from './interface'; -import { formatError } from '../common/utils'; -import { GitErrorCodes } from '../api/api'; -import { IComment } from '../common/comment'; -import { writeFile, unlink } from 'fs'; -import Logger from '../common/logger'; -import { DescriptionNode } from '../view/treeNodes/descriptionNode'; -import { TreeNode, Revealable } from '../view/treeNodes/treeNode'; +import { OpenCommitChangesArgs, OpenLocalFileArgs } from '../../common/views'; +import { openPullRequestOnGitHub } from '../commands'; +import { addAttestationCommit, isAttestationCommitsEnabled } from './attestationCommit'; +import { getCopilotApi } from './copilotApi'; +import { SessionIdForPr } from './copilotRemoteAgent'; import { FolderRepositoryManager } from './folderRepositoryManager'; -import { PullRequestModel } from './pullRequestModel'; -import { TimelineEvent, ReviewEvent as CommonReviewEvent, isReviewEvent } from '../common/timelineEvent'; -import { IssueOverviewPanel, IRequestMessage } from './issueOverview'; -import { onDidUpdatePR } from '../commands'; +import { + GithubItemStateEnum, + IAccount, + isITeam, + ITeam, + MergeMethod, + MergeMethodsAvailability, + PullRequestCheckStatus, + PullRequestMergeability, + ReviewEventEnum, + ReviewState, +} from './interface'; +import { IssueOverviewPanel, panelKey } from './issueOverview'; +import { isCopilotOnMyBehalf, PullRequestModel } from './pullRequestModel'; +import { PullRequestReviewCommon, ReviewContext } from './pullRequestReviewCommon'; +import { branchPicks, pickEmail, reviewersQuickPick } from './quickPicks'; +import { getEnterpriseUri, getIssueOrURLExpression, parseIssueExpressionOutput, parseReviewers, processDiffLinks, processPermalinks } from './utils'; +import { CancelCodingAgentReply, ChangeBaseReply, ChangeReviewersReply, DeleteReviewResult, MergeArguments, MergeResult, PullRequest, ReadyForReviewAndMergeContext, ReadyForReviewContext, ReviewCommentContext, ReviewType, SubmitReviewArgs, 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 { toOpenIssueWebviewUri } from '../common/uri'; +import { asPromise, formatError } from '../common/utils'; +import { IRequestMessage, PULL_REQUEST_OVERVIEW_VIEW_TYPE } from '../common/webview'; +import { toCheckRunLogUri } from '../view/checkRunLogContentProvider'; + +export class PullRequestOverviewPanel extends IssueOverviewPanel { + public static override ID: string = 'PullRequestOverviewPanel'; + public static override readonly viewType = PULL_REQUEST_OVERVIEW_VIEW_TYPE; -export class PullRequestOverviewPanel extends IssueOverviewPanel { - public static ID: string = 'PullRequestOverviewPanel'; /** - * Track the currently panel. Only allow a single panel to exist at a time. + * All open PR panels, keyed by "owner/repo#number". */ - public static currentPanel?: PullRequestOverviewPanel; + protected static override _panels: Map = new Map(); - protected static readonly _viewType: string = 'PullRequestOverview'; + /** + * Event emitter for when a PR overview becomes active + */ + private static _onVisible = new vscode.EventEmitter(); + public static readonly onVisible = PullRequestOverviewPanel._onVisible.event; - protected _item: PullRequestModel; private _repositoryDefaultBranch: string; - private _existingReviewers: ReviewState[]; + private _existingReviewers: ReviewState[] = []; + private _teamsCount = 0; + private _assignableUsers: { [key: string]: IAccount[] } = {}; + + private _prListeners: vscode.Disposable[] = []; + private _updatingPromise: Promise | undefined; + private _resolveCommentThreadQueue: Promise = Promise.resolve(); + + public static override async createOrShow( + telemetry: ITelemetry, + extensionUri: vscode.Uri, + folderRepositoryManager: FolderRepositoryManager, + 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' }); - private _changeActivePullRequestListener: vscode.Disposable | undefined; + const key = panelKey(identity.owner, identity.repo, identity.number); + let panel = this._panels.get(key); - public static async createOrShow(extensionPath: string, folderRepositoryManager: FolderRepositoryManager, issue: PullRequestModel, descriptionNode: DescriptionNode, toTheSide: Boolean = false) { - const activeColumn = toTheSide ? - vscode.ViewColumn.Beside : - vscode.window.activeTextEditor ? - vscode.window.activeTextEditor.viewColumn : - vscode.ViewColumn.One; + const activeColumn = IssueOverviewPanel._getViewColumn(toTheSide, panel); - // If we already have a panel, show it. - // Otherwise, create a new panel. - if (PullRequestOverviewPanel.currentPanel) { - PullRequestOverviewPanel.currentPanel._panel.reveal(activeColumn, true); + if (panel) { + panel._panel.reveal(activeColumn, preserveFocus); } else { - const title = `Pull Request #${issue.number.toString()}`; - PullRequestOverviewPanel.currentPanel = new PullRequestOverviewPanel(extensionPath, activeColumn || vscode.ViewColumn.Active, title, folderRepositoryManager, descriptionNode); + const title = `#${identity.number.toString()}`; + panel = new PullRequestOverviewPanel( + telemetry, + extensionUri, + activeColumn || vscode.ViewColumn.Active, + title, + folderRepositoryManager, + existingPanel + ); + this._panels.set(key, panel); } - await PullRequestOverviewPanel.currentPanel!.update(folderRepositoryManager, issue, descriptionNode); + await panel.updateWithIdentity(folderRepositoryManager, identity, issue); } - protected set _currentPanel(panel: PullRequestOverviewPanel | undefined) { - PullRequestOverviewPanel.currentPanel = panel; + public static scrollToReview(owner: string, repo: string, number: number): void { + const panel = this.findPanel(owner, repo, number); + if (panel) { + panel.scrollToPendingReview(); + } } - public static refresh(): void { - if (this.currentPanel) { - this.currentPanel.refreshPanel(); - } + /** + * Scroll the webview to the pending review section. + */ + public scrollToPendingReview(): void { + this._postMessage({ command: 'pr.scrollToPendingReview' }); } - protected constructor(extensionPath: string, column: vscode.ViewColumn, title: string, folderRepositoryManager: FolderRepositoryManager, descriptionNode: DescriptionNode) { - super(extensionPath, column, title, folderRepositoryManager, descriptionNode, PullRequestOverviewPanel._viewType); + /** + * Get the currently active pull request from the active panel + */ + public static getCurrentPullRequest(): PullRequestModel | undefined { + return this.getActivePanel()?._item; + } - this.registerFolderRepositoryListener(); + /** + * Return the panel whose webview is currently active (focused), + * or `undefined` when no PR panel is active. + */ + public static override getActivePanel(): PullRequestOverviewPanel | undefined { + return super.getActivePanel() as PullRequestOverviewPanel | undefined; + } - onDidUpdatePR(pr => { - if (pr) { - this._item.update(pr); - } + /** + * Find the panel showing a specific pull request. + */ + public static override findPanel(owner: string, repo: string, number: number): PullRequestOverviewPanel | undefined { + return super.findPanel(owner, repo, number) as PullRequestOverviewPanel | undefined; + } - this._postMessage({ - command: 'update-state', - state: this._item.state, - }); - }, null, this._disposables); + /** + * Register the webview context-menu commands once globally, + * rather than per panel instance. Each command receives the + * PR identity (owner / repo / number) from the webview context + * and looks up the matching panel. + */ + public static registerGlobalCommands(context: vscode.ExtensionContext, telemetry: ITelemetry): void { + context.subscriptions.push( + vscode.commands.registerCommand('pr.readyForReviewDescription', async (ctx: ReadyForReviewContext) => { + const panel = PullRequestOverviewPanel.findPanel(ctx.owner, ctx.repo, ctx.number); + if (panel) { + return panel.readyForReviewCommand(); + } + }), + vscode.commands.registerCommand('pr.readyForReviewAndMergeDescription', async (ctx: ReadyForReviewAndMergeContext) => { + const panel = PullRequestOverviewPanel.findPanel(ctx.owner, ctx.repo, ctx.number); + if (panel) { + return panel.readyForReviewAndMergeCommand(ctx); + } + }), + vscode.commands.registerCommand('review.approveDescription', (ctx: ReviewCommentContext) => { + const panel = PullRequestOverviewPanel.findPanel(ctx.owner, ctx.repo, ctx.number); + if (panel) { + return panel.approvePullRequestCommand(ctx); + } + }), + vscode.commands.registerCommand('review.commentDescription', (ctx: ReviewCommentContext) => { + const panel = PullRequestOverviewPanel.findPanel(ctx.owner, ctx.repo, ctx.number); + if (panel) { + return panel.submitReviewCommand(ctx); + } + }), + vscode.commands.registerCommand('review.requestChangesDescription', (ctx: ReviewCommentContext) => { + const panel = PullRequestOverviewPanel.findPanel(ctx.owner, ctx.repo, ctx.number); + if (panel) { + return panel.requestChangesCommand(ctx); + } + }), + vscode.commands.registerCommand('review.approveOnDotComDescription', (ctx: ReviewCommentContext) => { + const panel = PullRequestOverviewPanel.findPanel(ctx.owner, ctx.repo, ctx.number); + if (panel) { + return openPullRequestOnGitHub(panel._item, telemetry); + } + }), + vscode.commands.registerCommand('review.requestChangesOnDotComDescription', (ctx: ReviewCommentContext) => { + const panel = PullRequestOverviewPanel.findPanel(ctx.owner, ctx.repo, ctx.number); + if (panel) { + return openPullRequestOnGitHub(panel._item, telemetry); + } + }), + ); } - registerFolderRepositoryListener() { - this._changeActivePullRequestListener = this._folderRepositoryManager.onDidChangeActivePullRequest(_ => { + protected constructor( + telemetry: ITelemetry, + extensionUri: vscode.Uri, + column: vscode.ViewColumn, + title: string, + folderRepositoryManager: FolderRepositoryManager, + existingPanel?: vscode.WebviewPanel + ) { + super(telemetry, extensionUri, column, title, folderRepositoryManager, PullRequestOverviewPanel.viewType, existingPanel, { + light: 'resources/icons/git-pull-request_webview.svg', + dark: 'resources/icons/dark/git-pull-request_webview.svg' + }); + + this.registerPrListeners(); + + this.setVisibilityContext(); + } + + 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); this._postMessage({ command: 'pr.update-checkout-status', - isCurrentlyCheckedOut + isCurrentlyCheckedOut, }); } + })); + + if (this._item) { + this._prListeners.push(this._item.onDidChange(e => { + if ((e.state || e.comments) && !this._updatingPromise) { + this.refreshPanel(); + } + })); + } + } + + /** + * Override to process permalinks with PR-specific logic (including diff links). + * Returns undefined if bodyHTML is undefined. + */ + protected override async processLinksInBodyHtml(bodyHTML: string | undefined): Promise { + if (!bodyHTML) { + return bodyHTML; + } + // Check cache first, otherwise fetch raw file changes + const rawFileChanges = this._item.rawFileChanges ?? await this._item.getRawFileChangesInfo(); + + // Create hash-to-filename mapping for diff links + const hashMap: Record = {}; + rawFileChanges.forEach(file => { + const hash = crypto.createHash('sha256').update(file.filename).digest('hex'); + hashMap[hash] = file.filename; }); + + let result = await processPermalinks( + bodyHTML, + this._item.githubRepository, + this._item.githubRepository.rootUri + ); + result = await processDiffLinks( + result, + this._item.githubRepository, + hashMap, + this._item.number + ); + return result; + } + + 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); } /** - * Create a list of reviewers composed of people who have already left reviews on the PR, and - * those that have had a review requested of them. If a reviewer has left multiple reviews, the - * state should be the state of their most recent review, or 'REQUESTED' if they have an outstanding - * review request. - * @param requestedReviewers The list of reviewers that are requested for this pull request - * @param timelineEvents All timeline events for the pull request - * @param author The author of the pull request + * 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 parseReviewers(requestedReviewers: IAccount[], timelineEvents: TimelineEvent[], author: IAccount): ReviewState[] { - const reviewEvents = timelineEvents.filter(isReviewEvent).filter(event => event.state !== 'PENDING'); - let reviewers: ReviewState[] = []; - const seen = new Map(); - - // Do not show the author in the reviewer list - seen.set(author.login, true); - - for (let i = reviewEvents.length - 1; i >= 0; i--) { - const reviewer = reviewEvents[i].user; - if (!seen.get(reviewer.login)) { - seen.set(reviewer.login, true); - reviewers.push({ - reviewer: reviewer, - state: reviewEvents[i].state - }); - } + 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._getTimeline() + }; + } + + 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. + // GitHub Enterprise doesn't support the GraphQL UpdatePullRequestBranch mutation, + // so we always need the branch to be checked out for enterprise. + if (this._item.githubRepository.remote.isEnterprise) { + return this._item.isActive; } + const hasConflicts = this._item.item.mergeable === PullRequestMergeability.Conflict; + if (hasConflicts) { + return this._item.isActive; + } + return true; + } - requestedReviewers.forEach(request => { - if (!seen.get(request.login)) { - reviewers.push({ - reviewer: request, - state: 'REQUESTED' - }); - } else { - const reviewer = reviewers.find(r => r.reviewer.login === request.login); - reviewer!.state = 'REQUESTED'; - } - }); + 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; + } - // Put completed reviews before review requests and alphabetize each section - reviewers = reviewers.sort((a, b) => { - if (a.state === 'REQUESTED' && b.state !== 'REQUESTED') { - return 1; - } + private preLoadInfoNotRequiredForOverview(pullRequest: PullRequestModel): void { + // Load some more info in the background, don't await. + pullRequest.getFileChangesInfo(); + } - if (b.state === 'REQUESTED' && a.state !== 'REQUESTED') { - return -1; - } + protected override async updateItem(pullRequestModel: PullRequestModel): Promise { + if (this._updatingPromise) { + Logger.error('Already updating pull request webview', PullRequestOverviewPanel.ID); + return; + } + this._item = pullRequestModel; - return a.reviewer.login.toLowerCase() < b.reviewer.login.toLowerCase() ? -1 : 1; - }); + 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; + } + }); + this._updatingPromise = clearingPromise; + + const [ + pullRequest, + timelineEvents, + defaultBranch, + status, + requestedReviewers, + repositoryAccess, + branchInfo, + currentUser, + viewerCanEdit, + orgTeamsCount, + mergeQueueMethod, + isBranchUpToDateWithBase, + mergeability, + emailForCommit, + coAuthors, + hasReviewDraft, + assignableUsers + ] = await updatingPromise; - this._existingReviewers = reviewers; - return reviewers; - } - - public async updatePullRequest(pullRequestModel: PullRequestModel, descriptionNode: DescriptionNode): 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), - ]).then(result => { - const [pullRequest, timelineEvents, defaultBranch, status, requestedReviewers, repositoryAccess] = result; if (!pullRequest) { - throw new Error(`Fail to resolve Pull Request #${pullRequestModel.number} in ${pullRequestModel.remote.owner}/${pullRequestModel.remote.repositoryName}`); + throw new Error( + `Fail to resolve Pull Request #${pullRequestModel.number} in ${pullRequestModel.remote.owner}/${pullRequestModel.remote.repositoryName}`, + ); } this._item = pullRequest; + this.registerPrListeners(); this._repositoryDefaultBranch = defaultBranch!; - this._panel.title = `Pull Request #${pullRequestModel.number.toString()}`; + this._teamsCount = orgTeamsCount; + this._assignableUsers = assignableUsers; + this.setPanelTitle(this.buildPanelTitle(pullRequestModel.number, pullRequestModel.title)); const isCurrentlyCheckedOut = pullRequestModel.equals(this._folderRepositoryManager.activePullRequest); - const hasWritePermission = repositoryAccess!.hasWritePermission; const mergeMethodsAvailability = repositoryAccess!.mergeMethodsAvailability; - const canEdit = hasWritePermission || this._item.canEdit(); - const preferredMergeMethod = vscode.workspace.getConfiguration('githubPullRequests').get('defaultMergeMethod'); - const defaultMergeMethod = getDefaultMergeMethod(mergeMethodsAvailability, preferredMergeMethod); + + 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 = await 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(), + attestationCommitsEnabled: isAttestationCommitsEnabled(), + closingIssues: await (async () => { + const enterpriseUri = pullRequest.remote.isEnterprise ? getEnterpriseUri() : undefined; + const issueOrUrlExpression = getIssueOrURLExpression(enterpriseUri); + return Promise.all((pullRequest.closingIssues ?? []).map(async issue => { + const parsed = parseIssueExpressionOutput(issue.url.match(issueOrUrlExpression)); + const owner = parsed?.owner ?? pullRequest.remote.owner; + const repo = parsed?.name ?? pullRequest.remote.repositoryName; + const webviewUri = await toOpenIssueWebviewUri({ owner, repo, issueNumber: issue.number }); + return { ...issue, url: webviewUri.toString() }; + })); + })(), + }; 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, - events: timelineEvents, - isCurrentlyCheckedOut: isCurrentlyCheckedOut, - base: pullRequest.base && pullRequest.base.label || 'UNKNOWN', - head: pullRequest.head && pullRequest.head.label || 'UNKNOWN', - repositoryDefaultBranch: defaultBranch, - canEdit: canEdit, - hasWritePermission, - status: status ? status : { statuses: [] }, - mergeable: pullRequest.item.mergeable, - reviewers: this.parseReviewers(requestedReviewers!, timelineEvents!, pullRequest.author), - isDraft: pullRequest.isDraft, - mergeMethodsAvailability, - defaultMergeMethod, - isIssue: false - } + pullrequest: context }); - }).catch(e => { - vscode.window.showErrorMessage(formatError(e)); - }); - } - - public async update(folderRepositoryManager: FolderRepositoryManager, pullRequestModel: PullRequestModel, descriptionNode: DescriptionNode): Promise { - if (this._folderRepositoryManager !== folderRepositoryManager) { - this._folderRepositoryManager = folderRepositoryManager; - if (this._changeActivePullRequestListener) { - this._changeActivePullRequestListener.dispose(); - this._changeActivePullRequestListener = undefined; - this.registerFolderRepositoryListener(); + if (pullRequest.isResolved()) { + this._folderRepositoryManager.checkBranchUpToDate(pullRequest, true); } + } catch (e) { + vscode.window.showErrorMessage(`Error updating pull request description: ${formatError(e)}`); } + } - this._descriptionNode = descriptionNode; - 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 + ); + } - this._panel.webview.html = this.getHtmlForWebview(pullRequestModel.number.toString()); + protected override getItemTypeName(): string { + return 'Pull Request'; + } - return this.updatePullRequest(pullRequestModel, descriptionNode); + public override async updateWithIdentity( + folderRepositoryManager: FolderRepositoryManager, + identity: UnresolvedIdentity, + pullRequestModel?: PullRequestModel, + progressLocation?: string + ): Promise { + await super.updateWithIdentity(folderRepositoryManager, identity, pullRequestModel, progressLocation); + + // Notify that this PR overview is now active + PullRequestOverviewPanel._onVisible.fire(this._item); } - protected async _onDidReceiveMessage(message: IRequestMessage) { + protected override async _onDidReceiveMessage(message: IRequestMessage) { const result = await super._onDidReceiveMessage(message); if (result !== this.MESSAGE_UNHANDLED) { return; @@ -249,407 +523,743 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { return this.checkoutPullRequest(message); case 'pr.merge': return this.mergePullRequest(message); + case 'pr.change-email': + return this.changeEmail(message); case 'pr.deleteBranch': return this.deleteBranch(message); case 'pr.readyForReview': return this.setReadyForReview(message); + case 'pr.readyForReviewAndMerge': + return this.setReadyForReviewAndMerge(message); + case 'pr.convertToDraft': + return this.setConvertToDraft(message); case 'pr.approve': - return this.approvePullRequest(message); + return this.approvePullRequestMessage(message); case 'pr.request-changes': - return this.requestChanges(message); - case 'pr.submit': - return this.submitReview(message); + return this.requestChangesMessage(message); case 'pr.checkout-default-branch': return this.checkoutDefaultBranch(message); case 'pr.apply-patch': return this.applyPatch(message); case 'pr.open-diff': return this.openDiff(message); + case 'pr.open-changes': + return this.openChanges(message); + case 'pr.resolve-comment-thread': + return this.resolveCommentThread(message); case 'pr.checkMergeability': - return this._replyMessage(message, await this._item.getMergability()); - case 'pr.add-reviewers': - return this.addReviewers(message); - case 'pr.remove-reviewer': - return this.removeReviewer(message); + return this._replyMessage(message, await this._item.getMergeability()); + case 'pr.change-reviewers': + return this.changeReviewers(message); + case 'pr.update-automerge': + return this.updateAutoMerge(message); + case 'pr.dequeue': + return this.dequeue(message); + case 'pr.enqueue': + return this.enqueue(message); + case 'pr.update-branch': + return this.updateBranch(message); + case 'pr.gotoChangesSinceReview': + return this.gotoChangesSinceReview(message); + case 'pr.re-request-review': + return this.reRequestReview(message); + case 'pr.add-reviewer-copilot': + return this.addReviewerCopilot(message); + case 'pr.revert': + return this.revert(message); + case 'pr.open-session-log': + return this.openSessionLog(message); + case 'pr.cancel-coding-agent': + return this.cancelCodingAgent(message); + case 'pr.view-check-logs': + return this.viewCheckLogs(message); + case 'pr.openCommitChanges': + return this.openCommitChanges(message); + case 'pr.delete-review': + return this.deleteReview(message); + case 'pr.generate-description': + return this.generateDescription(message); + case 'pr.cancel-generate-description': + return this.cancelGenerateDescription(); + case 'pr.change-base-branch': + return this.changeBaseBranch(message); + case 'pr.open-diff-from-link': + return this.openDiffFromLink(message); + case 'pr.add-attestation-commit': + return this.addAttestationCommitMessage(message); } } - private getReviewersQuickPickItems(assignableUsers: IAccount[], suggestedReviewers: ISuggestedReviewer[] | undefined): vscode.QuickPickItem[] { - if (!suggestedReviewers) { - return []; - } - // 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[] = []; - for (const { login, name, isAuthor, isCommenter } of suggestedReviewers) { - if (skipList.has(login)) { - continue; - } + private async addAttestationCommitMessage(message: IRequestMessage): Promise { + const result = await addAttestationCommit(this._folderRepositoryManager, this._item); + this._replyMessage(message, { success: !!result, sha: result?.sha }); + } - const suggestionReason: string = - isAuthor && isCommenter - ? 'Recently edited and reviewed changes to these files' - : isAuthor - ? 'Recently edited these files' - : isCommenter - ? 'Recently reviewed changes to these files' - : 'Suggested reviewer'; - - reviewers.push({ - label: login, - description: name, - detail: suggestionReason - }); - // this user shouldn't be added later from assignable users list - skipList.add(login); + private gotoChangesSinceReview(message: IRequestMessage): Promise { + if (!this._item.showChangesSinceReview) { + this._item.showChangesSinceReview = true; + } else { + PullRequestModel.openChanges(this._folderRepositoryManager, this._item); } + return this._replyMessage(message, {}); + } - for (const { login, name } of assignableUsers) { - if (skipList.has(login)) { - continue; - } + private async changeReviewers(message: IRequestMessage): Promise { + let quickPick: vscode.QuickPick | undefined; - reviewers.push({ - label: login, - description: name + try { + 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: 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; }); - } - - return reviewers; - } + const hidePromise = asPromise(quickPick.onDidHide); + const allReviewers = await Promise.race<(IAccount | ITeam)[] | void>([acceptPromise, hidePromise]); + quickPick.busy = true; + quickPick.enabled = false; + + if (allReviewers) { + const newUserReviewers: IAccount[] = []; + const newTeamReviewers: ITeam[] = []; + allReviewers.forEach(reviewer => { + const newReviewers: (IAccount | ITeam)[] = isITeam(reviewer) ? newTeamReviewers : newUserReviewers; + newReviewers.push(reviewer); + }); - private async addReviewers(message: IRequestMessage): Promise { - try { - const allAssignableUsers = await this._folderRepositoryManager.getAssignableUsers(); - const assignableUsers = allAssignableUsers[this._item.remote.remoteName]; - - const reviewersToAdd = await vscode.window.showQuickPick( - this.getReviewersQuickPickItems(assignableUsers, this._item.suggestedReviewers), - { - canPickMany: true, - matchOnDescription: true - } - ); + 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); + } + }); - if (reviewersToAdd) { - await this._item.requestReview(reviewersToAdd.map(r => r.label)); - const addedReviewers: ReviewState[] = reviewersToAdd.map(reviewer => { + await this._item.requestReview(newUserReviewers, newTeamReviewers); + await this._item.deleteReviewRequest(removedUserReviewers, removedTeamReviewers); + const addedReviewers: ReviewState[] = allReviewers.map(selected => { return { - // assumes that suggested reviewers will be a subset of assignable users - reviewer: assignableUsers.find(r => r.login === reviewer.label)!, - state: 'REQUESTED' + reviewer: selected, + state: 'REQUESTED', }; }); - this._existingReviewers = this._existingReviewers.concat(addedReviewers); - this._replyMessage(message, { - added: addedReviewers + this._existingReviewers = addedReviewers; + await this._replyMessage(message, { + reviewers: addedReviewers, }); } } catch (e) { + Logger.error(formatError(e), PullRequestOverviewPanel.ID); vscode.window.showErrorMessage(formatError(e)); + } finally { + quickPick?.hide(); + quickPick?.dispose(); } } - private async removeReviewer(message: IRequestMessage): Promise { + private async applyPatch(message: IRequestMessage<{ comment: IComment }>): Promise { try { - await this._item.deleteReviewRequest(message.args); + const comment = message.args.comment; + const regex = /```diff\n([\s\S]*)\n```/g; + const matches = regex.exec(comment.body); - const index = this._existingReviewers.findIndex(reviewer => reviewer.reviewer.login === message.args); - this._existingReviewers.splice(index, 1); + const tempUri = vscode.Uri.joinPath(this._folderRepositoryManager.repository.rootUri, '.git', `${comment.id}.diff`); - this._replyMessage(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(formatError(e)); + Logger.error(`Applying patch failed: ${e}`, PullRequestOverviewPanel.ID); + vscode.window.showErrorMessage(`Applying patch failed: ${formatError(e)}`); } } - private applyPatch(message: IRequestMessage<{ comment: IComment }>): void { + protected override async _getTimeline(): Promise { + const events = await this._item.getTimelineEvents(); + return this.processTimelineEvents(events); + } + + private async openDiff(message: IRequestMessage<{ comment: IComment }>): Promise { try { const comment = message.args.comment; - const regex = /```diff\n([\s\S]*)\n```/g; - const matches = regex.exec(comment.body); + return PullRequestModel.openDiffFromComment(this._folderRepositoryManager, this._item, comment); + } catch (e) { + Logger.error(`Open diff view failed: ${formatError(e)}`, PullRequestOverviewPanel.ID); + } + } - const tempFilePath = path.join(this._folderRepositoryManager.repository.rootUri.path, '.git', `${comment.id}.diff`); - writeFile(tempFilePath, matches![1], {}, async (writeError) => { - if (writeError) { - throw writeError; - } + private async openDiffFromLink(message: IRequestMessage): Promise { + try { + const { file, startLine } = message.args; + const fileChanges = await this._item.getFileChangesInfo(); + const change = fileChanges.find( + fileChange => fileChange.fileName === file || fileChange.previousFileName === file, + ); - try { - await this._folderRepositoryManager.repository.apply(tempFilePath); + if (change) { + + const pathSegments = file.split('/'); + // GitHub line numbers are 1-indexed, VSCode selection API is 0-indexed + await PullRequestModel.openDiff( + this._folderRepositoryManager, + this._item, + change, + pathSegments[pathSegments.length - 1], + startLine - 1, + ); + return; + } + Logger.warn(`Could not find file ${file} in PR changes`, PullRequestOverviewPanel.ID); + } catch (e) { + Logger.error(`Open diff from link failed: ${formatError(e)}`, PullRequestOverviewPanel.ID); + } - // Need to mark conversation as resolved - unlink(tempFilePath, (err) => { - if (err) { - throw err; - } + // Fallback to opening external URL + await vscode.env.openExternal(vscode.Uri.parse(message.args.href)); + } - vscode.window.showInformationMessage('The suggested changes have been applied.'); - this._replyMessage(message, {}); - }); - } catch (e) { - Logger.appendLine(`Applying patch failed: ${e}`); - vscode.window.showErrorMessage(`Applying patch failed: ${formatError(e)}`); - } - }); + private async openSessionLog(message: IRequestMessage<{ link: SessionLinkInfo }>): Promise { + try { + const resource = SessionIdForPr.getResource(this._item.number, message.args.link.sessionIndex); + return vscode.commands.executeCommand('vscode.open', resource); } catch (e) { - Logger.appendLine(`Applying patch failed: ${e}`); - vscode.window.showErrorMessage(`Applying patch failed: ${formatError(e)}`); + Logger.error(`Open session log view failed: ${formatError(e)}`, PullRequestOverviewPanel.ID); } } - private openDiff(message: IRequestMessage<{ comment: IComment }>): void { + private async viewCheckLogs(message: IRequestMessage<{ status: PullRequestCheckStatus }>): Promise { try { - const comment = message.args.comment; - const prContainer = this._descriptionNode.parent; + const { status } = message.args; + if (!status.databaseId) { + return this._replyMessage(message, { error: 'Logs are only available for GitHub Actions check runs.' }); + } + const uri = toCheckRunLogUri({ + owner: this._item.remote.owner, + repo: this._item.remote.repositoryName, + checkRunDatabaseId: status.databaseId, + checkName: status.context, + }); + + await vscode.window.showTextDocument(uri, { preview: true, preserveFocus: false }); + return this._replyMessage(message, {}); + } catch (e) { + Logger.error(`View check run logs failed: ${formatError(e)}`, PullRequestOverviewPanel.ID); + return this._replyMessage(message, { error: formatError(e) }); + } + } - if ((prContainer as TreeNode | Revealable).revealComment) { - (prContainer as TreeNode | Revealable).revealComment!(comment); + private async cancelCodingAgent(message: IRequestMessage): Promise { + try { + 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) { - Logger.appendLine(`Open diff view failed: ${formatError(e)}`, PullRequestOverviewPanel.ID); + 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 openCommitChanges(message: IRequestMessage): Promise { + try { + 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 openChanges(message?: IRequestMessage<{ openToTheSide?: boolean }>): Promise { + const openToTheSide = message?.args?.openToTheSide || false; + return PullRequestModel.openChanges(this._folderRepositoryManager, this._item, openToTheSide); + } + + private resolveCommentThread(message: IRequestMessage<{ threadId: string, toResolve: boolean, thread: IComment[] }>) { + // Serialize resolve/unresolve operations so that concurrent calls don't race. + // Each call fetches the full timeline after its mutation, and without serialization + // a stale timeline response from an earlier call can overwrite a newer one. + // Normalize any prior rejection so one unexpected failure does not permanently + // block later resolve/unresolve requests from running. + this._resolveCommentThreadQueue = this._resolveCommentThreadQueue + .catch(error => { + Logger.error(`Resolve comment thread queue failed: ${formatError(error)}`, PullRequestOverviewPanel.ID); + }) + .then(async () => { + try { + if (message.args.toResolve) { + await this._item.resolveReviewThread(message.args.threadId); + } + else { + await this._item.unresolveReviewThread(message.args.threadId); + } + const timelineEvents = await this._getTimeline(); + this._replyMessage(message, timelineEvents); + } catch (e) { + Logger.error(`Failed to ${message.args.toResolve ? 'resolve' : 'unresolve'} comment thread: ${formatError(e)}`, PullRequestOverviewPanel.ID); + vscode.window.showErrorMessage(vscode.l10n.t('Failed to {0} comment thread: {1}', message.args.toResolve ? 'resolve' : 'unresolve', formatError(e))); + this._replyMessage(message, undefined); + } + }); + } + private checkoutPullRequest(message: IRequestMessage): void { - vscode.commands.executeCommand('pr.pick', this._item).then(() => { - const isCurrentlyCheckedOut = this._item.equals(this._folderRepositoryManager.activePullRequest); - this._replyMessage(message, { isCurrentlyCheckedOut: isCurrentlyCheckedOut }); - }, () => { - const isCurrentlyCheckedOut = this._item.equals(this._folderRepositoryManager.activePullRequest); - this._replyMessage(message, { isCurrentlyCheckedOut: isCurrentlyCheckedOut }); - }); + vscode.commands.executeCommand('pr.pick', this._item).then( + () => { + const isCurrentlyCheckedOut = this._item.equals(this._folderRepositoryManager.activePullRequest); + this._replyMessage(message, { isCurrentlyCheckedOut: isCurrentlyCheckedOut }); + }, + () => { + const isCurrentlyCheckedOut = this._item.equals(this._folderRepositoryManager.activePullRequest); + this._replyMessage(message, { isCurrentlyCheckedOut: isCurrentlyCheckedOut }); + }, + ); } - private mergePullRequest(message: IRequestMessage<{ title: string, description: string, method: 'merge' | 'squash' | 'rebase' }>): 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}`); + 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 => { + 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, {}); - }); + 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 branchInfo = await this._folderRepositoryManager.getBranchNameForPullRequest(this._item); - const actions: (vscode.QuickPickItem & { type: 'upstream' | 'local' | 'remote' })[] = []; + const result = await PullRequestReviewCommon.deleteBranch(this._folderRepositoryManager, this._item); + if (result.isReply) { + this._replyMessage(message, result.message); + } else { + this.refreshPanel(); + this._postMessage(result.message); + } + } - if (this._item.isResolved()) { - const branchHeadRef = this._item.head.ref; + private async setReadyForReview(message: IRequestMessage<{}>): Promise { + return PullRequestReviewCommon.setReadyForReview(this.getReviewContext(), message); + } - actions.push({ - label: `Delete remote branch ${this._item.remote.remoteName}/${branchHeadRef}`, - description: `${this._item.remote.normalizedHost}/${this._item.remote.owner}/${this._item.remote.repositoryName}`, - type: 'upstream', - picked: true - }); - } + private async setReadyForReviewAndMerge(message: IRequestMessage<{ mergeMethod: MergeMethod }>): Promise { + return PullRequestReviewCommon.setReadyForReviewAndMerge(this.getReviewContext(), message); + } - if (branchInfo) { - const preferredLocalBranchDeletionMethod = vscode.workspace.getConfiguration('githubPullRequests').get('defaultDeletionMethod.selectLocalBranch'); - actions.push({ - label: `Delete local branch ${branchInfo.branch}`, - type: 'local', - picked: !!preferredLocalBranchDeletionMethod - }); + private async setConvertToDraft(message: IRequestMessage<{}>): Promise { + return PullRequestReviewCommon.setConvertToDraft(this.getReviewContext(), message); + } - const preferredRemoteDeletionMethod = vscode.workspace.getConfiguration('githubPullRequests').get('defaultDeletionMethod.selectRemote'); + private async readyForReviewCommand(): Promise { + return PullRequestReviewCommon.readyForReviewCommand(this.getReviewContext()); + } - if (branchInfo.remote && branchInfo.createdForPullRequest && !branchInfo.remoteInUse) { - actions.push({ - label: `Delete remote ${branchInfo.remote}, which is no longer used by any other branch`, - type: 'remote', - picked: !!preferredRemoteDeletionMethod - }); - } + private async readyForReviewAndMergeCommand(context: { mergeMethod: MergeMethod }): Promise { + return PullRequestReviewCommon.readyForReviewAndMergeCommand(this.getReviewContext(), context); + } + + private async checkoutDefaultBranch(message: IRequestMessage): Promise { + 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); } + } - if (!actions.length) { - vscode.window.showWarningMessage(`There is no longer an upstream or local branch for Pull Request #${this._item.number}`); - this._replyMessage(message, { - cancelled: true - }); + private async doReviewMessage(message: IRequestMessage, action: (body) => Promise, additionalEvents?: TimelineEvent[]) { + const result = await PullRequestReviewCommon.doReviewMessage( + this.getReviewContext(), + message, + // When we already have the just-pushed attestation commit locally, skip the + // extra full-timeline fetch and let the webview splice it in itself. + !additionalEvents, + action, + additionalEvents, + ); + if (result) { + this.tryScheduleCopilotRefresh(result.body, result.state); + } + } - return; + /** + * Handles a review-submission message that may include a request to first + * push a signed attestation commit. If attestation fails, the review is + * NOT submitted. + */ + private async doReviewMessageWithAttestation( + message: IRequestMessage, + action: (body: string, expectedRemoteHead: string | undefined) => Promise, + ): Promise { + const { body, addAttestation } = message.args ?? { body: '' }; + let attestationEvent: TimelineEvent | undefined; + let attestationSha: string | undefined; + if (addAttestation) { + const result = await addAttestationCommit(this._folderRepositoryManager, this._item); + if (!result) { + this._throwError(message, 'Attestation commit failed; review was not submitted.'); + return; + } + attestationEvent = result.event; + attestationSha = result.sha; } + const forwarded: IRequestMessage = { req: message.req, command: message.command, args: body }; + // Pass the attestation sha as the expected remote head so the approve + // check waits for GitHub's PR API to catch up to *our* commit (a + // concurrent third-party push would still be rejected). + await this.doReviewMessage(forwarded, (b) => action(b, attestationSha), attestationEvent ? [attestationEvent] : undefined); + } - const selectedActions = await vscode.window.showQuickPick(actions, { - canPickMany: true, - ignoreFocusOut: true - }); + private approvePullRequest(body: string, expectedRemoteHead?: string): Promise { + return this._item.approve(this._folderRepositoryManager.repository, body, expectedRemoteHead); + } - if (selectedActions) { - const isBranchActive = this._item.equals(this._folderRepositoryManager.activePullRequest); - - const promises = selectedActions.map(async (action) => { - switch (action.type) { - case 'upstream': - return this._folderRepositoryManager.deleteBranch(this._item); - case 'local': - if (isBranchActive) { - if (this._folderRepositoryManager.repository.state.workingTreeChanges.length) { - const response = await vscode.window.showWarningMessage(`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 this._folderRepositoryManager.repository.checkout(this._repositoryDefaultBranch); - } - return await this._folderRepositoryManager.repository.deleteBranch(branchInfo!.branch, true); - case 'remote': - return this._folderRepositoryManager.repository.removeRemote(branchInfo!.remote!); - } - }); + private approvePullRequestMessage(message: IRequestMessage): Promise { + return this.doReviewMessageWithAttestation(message, (body, expectedRemoteHead) => this.approvePullRequest(body, expectedRemoteHead)); + } - await Promise.all(promises); + private approvePullRequestCommand(context: { body: string }): Promise { + return this.doReviewCommand(context, ReviewType.Approve, (body) => this.approvePullRequest(body)); + } - this.refreshPanel(); - vscode.commands.executeCommand('pr.refreshList'); + private requestChanges(body: string): Promise { + return this._item.requestChanges(body); + } - this._postMessage({ - command: 'pr.deleteBranch' - }); - } else { - this._replyMessage(message, { - cancelled: true - }); - } + private requestChangesCommand(context: { body: string }): Promise { + return this.doReviewCommand(context, ReviewType.RequestChanges, (body) => this.requestChanges(body)); + } + + private requestChangesMessage(message: IRequestMessage): Promise { + return this.doReviewMessageWithAttestation(message, (body) => this.requestChanges(body)); } - private setReadyForReview(message: IRequestMessage<{}>): void { - this._item.setReadyForReview().then(isDraft => { - vscode.commands.executeCommand('pr.refreshList'); + private submitReview(body: string): Promise { + return this._item.submitReview(ReviewEventEnum.Comment, body); + } - this._replyMessage(message, { isDraft }); - }).catch(e => { - vscode.window.showErrorMessage(`Unable to set PR ready for review. ${formatError(e)}`); - this._throwError(message, {}); - }); + private submitReviewCommand(context: { body: string }) { + return this.doReviewCommand(context, ReviewType.Comment, (body) => this.submitReview(body)); } - private async checkoutDefaultBranch(message: IRequestMessage): Promise { - try { - const branch = message.args; - // This should be updated for multi-root support and consume the git extension API if possible - const branchObj = await this._folderRepositoryManager.repository.getBranch(branch); + protected override submitReviewMessage(message: IRequestMessage) { + return this.doReviewMessageWithAttestation(message, (body) => this.submitReview(body)); + } + + private reRequestReview(message: IRequestMessage): void { + return PullRequestReviewCommon.reRequestReview(this.getReviewContext(), message); + } - if (branchObj.upstream && branch === branchObj.upstream.name) { - await this._folderRepositoryManager.repository.checkout(branch); + 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 { - await vscode.commands.executeCommand('git.checkout'); + this._throwError(message, 'Copilot reviewer not found.'); } } 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('Your local changes would be overwritten by checkout, please commit your changes or stash them before you switch branches'); - return; - } - } + vscode.window.showErrorMessage(formatError(e)); + this._throwError(message, formatError(e)); + } + } - vscode.window.showErrorMessage(`Exiting failed: ${e}`); - } finally { - // Complete webview promise so that button becomes enabled again - this._replyMessage(message, {}); + 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 { + let replyMessage: { autoMerge: boolean, autoMergeMethod?: MergeMethod }; + if (!message.args.autoMerge && !this._item.autoMerge) { + replyMessage = { autoMerge: false }; + } else if ((message.args.autoMerge === false) && this._item.autoMerge) { + await this._item.disableAutoMerge(); + replyMessage = { autoMerge: this._item.autoMerge }; + } else { + if (this._item.autoMerge && message.args.autoMergeMethod !== this._item.autoMergeMethod) { + await this._item.disableAutoMerge(); + } + await this._item.enableAutoMerge(message.args.autoMergeMethod); + replyMessage = { autoMerge: this._item.autoMerge, autoMergeMethod: this._item.autoMergeMethod }; } + this._replyMessage(message, replyMessage); } - 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 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 approvePullRequest(message: IRequestMessage): void { - this._item.approve().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 async updateBranch(message: IRequestMessage): Promise { + return PullRequestReviewCommon.updateBranch( + this.getReviewContext(), + message, + () => this.refreshPanel(), + () => this.isUpdateBranchWithGitHubEnabled() + ); + } - this._throwError(message, `${formatError(e)}`); - }); + protected override editCommentPromise(comment: IComment, text: string): Promise { + return this._item.editReviewComment(comment, text); } - 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)}`); - }); + protected override deleteCommentPromise(comment: IComment): Promise { + return this._item.deleteReviewComment(comment.id.toString()); } - 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)}`); + 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)}`); - }); + } } - protected editCommentPromise(comment: IComment, text: string): Promise { - return this._item.editReviewComment(comment, text); + private getGenerateDescriptionTitle(): string | undefined { + const provider = this._folderRepositoryManager.getTitleAndDescriptionProvider(); + return provider ? `Generate description with ${provider.title}` : undefined; } - protected deleteCommentPromise(comment: IComment): Promise { - return this._item.deleteReviewComment(comment.id.toString()); + 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, compareBranch: this._item.head?.ref }, + this.generatingDescriptionCancellationToken.token + ); + + /* __GDPR__ + "pr.generatedTitleAndDescription" : { + "providerTitle" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "source" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this._telemetry.sendTelemetryEvent('pr.generatedTitleAndDescription', { providerTitle: provider?.title, source: 'regenerate' }); + + 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 }); + } } - dispose() { - super.dispose(); + 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(); + // Register event handlers before awaiting async operations to avoid missing early user interactions + const acceptPromise = asPromise(quickPick.onDidAccept).then(() => { + return (quickPick.selectedItems[0] ?? quickPick.activeItems[0])?.branch; + }); + const hidePromise = asPromise(quickPick.onDidHide); + await updateItems(undefined); + + quickPick.busy = false; + const selectedBranch = await Promise.race([acceptPromise, hidePromise]); + quickPick.busy = true; + quickPick.enabled = false; - if (this._changeActivePullRequestListener) { - this._changeActivePullRequestListener.dispose(); + 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(); + disposeAll(this._prListeners); + } + + /** + * Static dispose method to clean up static resources + */ + public static dispose() { + PullRequestOverviewPanel._onVisible.dispose(); + } } -function getDefaultMergeMethod(methodsAvailability: MergeMethodsAvailability, userPreferred: MergeMethod | undefined): MergeMethod { +export function getDefaultMergeMethod( + methodsAvailability: MergeMethodsAvailability, +): MergeMethod { + 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; } const methods: MergeMethod[] = ['merge', 'squash', 'rebase']; - // GitHub requires to have at leas one merge method to be enabled; use first available as default + // GitHub requires to have at least one merge method to be enabled; use first available as default return methods.find(method => methodsAvailability[method])!; } diff --git a/src/github/pullRequestReviewCommon.ts b/src/github/pullRequestReviewCommon.ts new file mode 100644 index 0000000000..8d52d05619 --- /dev/null +++ b/src/github/pullRequestReviewCommon.ts @@ -0,0 +1,575 @@ +/*--------------------------------------------------------------------------------------------- + * 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, SELECT_WORKTREE } 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, + additionalEvents?: TimelineEvent[], + ): 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, + additionalEvents, + 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, + additionalEvents?: TimelineEvent[], + ): 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, + additionalEvents, + 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' | 'worktree' + /** Path to the worktree directory to remove. Only used when type is 'worktree'. */ + worktreePath?: string; + }; + + function isWorktreeInWorkspace(worktreePath: vscode.Uri): boolean { + const worktreeFsPath = worktreePath.fsPath; + return !!vscode.workspace.workspaceFolders?.some(folder => { + const folderPath = folder.uri.fsPath; + return folderPath === worktreeFsPath || + (process.platform === 'win32' && folderPath.toLowerCase() === worktreeFsPath.toLowerCase()); + }); + } + + 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, + }); + } + + const worktreePath = folderRepositoryManager.getWorktreeForBranch(branchInfo.branch); + if (worktreePath && !isWorktreeInWorkspace(worktreePath)) { + const preferredWorktreeDeletion = vscode.workspace + .getConfiguration(PR_SETTINGS_NAMESPACE) + .get(`${DEFAULT_DELETION_METHOD}.${SELECT_WORKTREE}`); + actions.push({ + label: vscode.l10n.t('Remove worktree {0}', worktreePath.fsPath), + type: 'worktree', + worktreePath: worktreePath.fsPath, + picked: !!preferredWorktreeDeletion, + }); + } + } + + 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[] = []; + + // Remove worktree first, before deleting the branch, since a branch checked out + // in a worktree cannot be deleted. + const worktreeAction = selectedActions.find(a => a.type === 'worktree'); + if (worktreeAction?.worktreePath) { + await folderRepositoryManager.removeWorktree(worktreeAction.worktreePath); + deletedBranchTypes.push(worktreeAction.type); + } + + const remainingActions = selectedActions.filter(a => a.type !== 'worktree'); + const promises = remainingActions.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' }); + } + + // Remove worktree if preference is set + const deleteWorktree = vscode.workspace + .getConfiguration(PR_SETTINGS_NAMESPACE) + .get(`${DEFAULT_DELETION_METHOD}.${SELECT_WORKTREE}`, false); + if (branchInfo && deleteWorktree) { + const worktreePath = folderRepositoryManager.getWorktreeForBranch(branchInfo.branch); + if (worktreePath && !isWorktreeInWorkspace(worktreePath)) { + selectedActions.push({ type: 'worktree', worktreePath: worktreePath.fsPath }); + } + } + + // 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 22bfda25e2..42c84317c6 100644 --- a/src/github/queries.gql +++ b/src/github/queries.gql @@ -1,793 +1,787 @@ -# /*--------------------------------------------------------------------------------------------- -# * Copyright (c) Microsoft Corporation. All rights reserved. -# * Licensed under the MIT License. See License.txt in the project root for license information. -# *--------------------------------------------------------------------------------------------*/ - -fragment Merged on MergedEvent { - id - actor { - login - avatarUrl - url - } - createdAt - mergeRef { - name - } - commit { - oid - commitUrl - } - url -} - -fragment HeadRefDeleted on HeadRefDeletedEvent { - id - actor { - login - avatarUrl - url - } - createdAt - headRefName -} - -fragment Ref on Ref { - name - repository { - owner { - login - } - url - } - target { - oid - } -} - -fragment Comment on IssueComment { - id - databaseId - authorAssociation - author { - login - avatarUrl - url - } - url - body - bodyHTML - updatedAt - createdAt - viewerCanUpdate - viewerCanReact - viewerCanDelete -} - -fragment Commit on PullRequestCommit { - id - commit { - author { - user { - login - avatarUrl - url - } - } - committer { - avatarUrl - name - } - oid - message - } - url -} - -fragment AssignedEvent on AssignedEvent { - actor { - login - avatarUrl - url - } - user { - login - avatarUrl - url - } -} - -fragment Review on PullRequestReview { - id - databaseId - authorAssociation - url - author { - login - avatarUrl - url - } - state - body - bodyHTML - submittedAt - updatedAt - createdAt -} - -fragment Reactable on Reactable { - reactionGroups { - content - viewerHasReacted - users { - totalCount - } - } -} - -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 - ...HeadRefDeleted - } - } - } - } - rateLimit { - limit - cost - remaining - resetAt - } -} - -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 - } - } - } - } -} - -fragment ReviewComment on PullRequestReviewComment { - id databaseId url - author { - login - avatarUrl - url - } - path originalPosition - body - bodyHTML - diffHunk - position - state - pullRequestReview { databaseId } - commit { - oid - } - replyTo { - databaseId - } - createdAt - originalCommit { - oid - } - reactionGroups { - content - viewerHasReacted - users { - totalCount - } - } - viewerCanUpdate - viewerCanDelete -} - -query GetPendingReviewId($pullRequestId: ID!, $author: String!) { - node(id: $pullRequestId) { - ...on PullRequest { - reviews(first: 1, author: $author, states: [PENDING]) { nodes { id } } - } - } - rateLimit { - limit - cost - remaining - resetAt - } -} - -query PullRequestComments($owner:String!, $name:String!, $number:Int!, $first:Int=100) { - repository(owner:$owner, name:$name) { - pullRequest(number:$number) { - reviews(first:$first) { - nodes { - comments(first:100) { - nodes { ...ReviewComment } - } - } - } - } - } - rateLimit { - limit - cost - remaining - resetAt - } -} - -query PullRequest($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - number - url - state - body - bodyHTML - title - author { - login - url - avatarUrl - } - createdAt - updatedAt - headRef { - ...Ref - } - baseRef { - ...Ref - } - labels(first: 50) { - nodes { - name - } - } - merged - mergeable - id - databaseId - isDraft - suggestedReviewers { - isAuthor - isCommenter - reviewer { - login - avatarUrl - name - url - } - } - } - } - rateLimit { - limit - cost - remaining - resetAt - } -} - -query Issue($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 - } - createdAt - updatedAt - labels(first: 50) { - nodes { - name - color - } - } - id - databaseId - } - } -} - -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 - } - createdAt - updatedAt - labels(first: 50) { - nodes { - name - color - } - } - id - databaseId - comments(first: 50) { - nodes { - author { - login - url - avatarUrl - } - body - databaseId - } - } - } - } -} - -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 - } -} - -query PullRequestMergeability($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - mergeable - } - } - rateLimit { - limit - cost - remaining - resetAt - } -} - -query PullRequestState($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - title - number - state - } - } -} - -mutation AddComment($input: AddPullRequestReviewCommentInput!) { - addPullRequestReviewComment(input: $input) { - comment { - ...ReviewComment - } - } -} - -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 - comments(first:1) { - nodes { - ...ReviewComment - } - } - } - } -} - - -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 UpdatePullRequest($input: UpdatePullRequestInput!) { - updatePullRequest(input: $input) { - pullRequest { - body - bodyHTML - title - } - } -} - -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 - } -} - -query GetAssignableUsers($owner: String!, $name: String!, $first: Int!, $after: String) { - 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 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 - author { - login - url - avatarUrl(size: 50) - } - createdAt - updatedAt - labels(first: 50) { - nodes { - name - color - } - } - id - databaseId - milestone { - title - dueOn - } - } - } - } - pageInfo { - hasNextPage - endCursor - } - } - } -} - -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 - } - } - } - } - } -} - -query GetMilestones($owner: String!, $name: String!, $assignee: String!) { - repository(owner: $owner, name: $name) { - milestones(first: 12, orderBy: { - direction: DESC - field: DUE_DATE - } , states: OPEN) { - 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 - author { - login - url - avatarUrl(size: 50) - } - createdAt - updatedAt - labels(first: 50) { - nodes { - name - color - } - } - id - databaseId - milestone { - title - dueOn - } - } - } - } - } - } - pageInfo { - hasNextPage - endCursor - } - } - } -} - -query Issues($query: String!) { - search(first: 100, type: ISSUE, query: $query) { - issueCount - pageInfo { - hasNextPage - endCursor - } - edges { - node { - ... on Issue { - number - url - state - body - bodyHTML - title - author { - login - url - avatarUrl(size: 50) - } - createdAt - updatedAt - labels(first: 50) { - nodes { - name - color - } - } - id - databaseId - milestone { - title - dueOn - id - createdAt - } - repository { - name - owner { - login - } - url - } - } - } - } - } -} - -query GetViewerPermission($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - viewerPermission - } -} - -query GetRepositoryForkDetails($owner: String!, $name: String!) { - repository(owner:$owner, name: $name) { - isFork - parent { - name - owner - } - } -} - -query GetChecks($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 - checkSuite { - app { - logoUrl - url - } - } - } - } - } - } - } - } - } - } - } -} +# /*--------------------------------------------------------------------------------------------- +# * 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 also in the limit file, but are not limited (by scope or API availability) here + +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 + } + issueType { + name + } +} + +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 + comments(first: 1) { + totalCount + } + + 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 + } + closingIssuesReferences(first: 50) { + nodes { + databaseId + number + title + state + url + } + } + merged + mergeable + mergeQueueEntry { + ...MergeQueueEntryFragment + } + mergeStateStatus + autoMergeRequest { + mergeMethod + } + reviewThreads { + totalCount + } + viewerCanEnableAutoMerge + viewerCanDisableAutoMerge + viewerCanUpdate + isDraft + suggestedReviewers { + isAuthor + isCommenter + reviewer { + ...Actor + ...User + ...Node + } + } + additions + deletions +} + +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 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 RevertPullRequest($input: RevertPullRequestInput!) { + revertPullRequest(input: $input) { + revertPullRequest { + ...PullRequestFragment + } + } +} + +# Queries that only exist in this file and in extra + +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 + } +} + +mutation DequeuePullRequest($input: DequeuePullRequestInput!) { + dequeuePullRequest(input: $input) { + mergeQueueEntry { + ...MergeQueueEntryFragment + } + } +} + +mutation EnqueuePullRequest($input: EnqueuePullRequestInput!) { + enqueuePullRequest(input: $input) { + mergeQueueEntry { + ...MergeQueueEntryFragment + } + } +} + +mutation ReplaceActorsForAssignable($input: ReplaceActorsForAssignableInput!) { + replaceActorsForAssignable(input: $input) { + assignable { + assignees: assignedActors(first: 100) { + nodes { + ...Node + ...Actor + ...User + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +} + +mutation UpdatePullRequestBranch($input: UpdatePullRequestBranchInput!) { + updatePullRequestBranch(input: $input) { + pullRequest { + id + headRefOid + } + } +} \ No newline at end of file diff --git a/src/github/queriesExtra.gql b/src/github/queriesExtra.gql new file mode 100644 index 0000000000..3fa99a892f --- /dev/null +++ b/src/github/queriesExtra.gql @@ -0,0 +1,830 @@ +# /*--------------------------------------------------------------------------------------------- +# * 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 + } + } + } + issueType { + name + } +} + +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 + } + closingIssuesReferences(first: 50) { + nodes { + databaseId + number + title + state + url + } + } + 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..8c095dcf51 --- /dev/null +++ b/src/github/queriesLimited.gql @@ -0,0 +1,667 @@ +# /*--------------------------------------------------------------------------------------------- +# * 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 + } + closingIssuesReferences(first: 50) { + nodes { + databaseId + number + title + state + 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..833bd796f6 --- /dev/null +++ b/src/github/queriesShared.gql @@ -0,0 +1,1349 @@ +# /*--------------------------------------------------------------------------------------------- +# * 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 + signature { + isValid + state + wasSignedByGitHub + signer { + login + name + avatarUrl + } + ... on GpgSignature { + keyId + } + ... on SshSignature { + keyFingerprint + } + ... on SmimeSignature { + email + } + } + 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 + } + } + } + } + } +} + +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 + databaseId + conclusion + title + detailsUrl + name + resourcePath + isRequired(pullRequestNumber: $number) + checkSuite { + app { + logoUrl + url + } + workflowRun { + event + workflow { + name + } + } + } + } + } + } + } + checkSuites(first: 100) { + nodes { + status + conclusion + workflowRun { + event + workflow { + name + } + } + app { + logoUrl + url + } + } + } + } + } + } + } + } + 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 + databaseId + conclusion + title + detailsUrl + name + resourcePath + isRequired(pullRequestNumber: $number) + } + } + } + } + checkSuites(first: 100) { + nodes { + status + conclusion + workflowRun { + event + workflow { + name + } + } + } + } + } + } + } + } + } + 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..52f681d4f4 --- /dev/null +++ b/src/github/quickPicks.ts @@ -0,0 +1,570 @@ +/*--------------------------------------------------------------------------------------------- + * 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] || []; +} + +function buildBranchPickItems(branchNames: string[], githubRepository: GitHubRepository, folderRepoManager: FolderRepositoryManager, changeRepoMessage: string | undefined, isBase: boolean): (vscode.QuickPickItem & { remote?: RemoteInfo, branch?: string })[] { + // 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; +} + +export function cachedBranchPicks(githubRepository: GitHubRepository, folderRepoManager: FolderRepositoryManager, changeRepoMessage: string | undefined, isBase: boolean): (vscode.QuickPickItem & { remote?: RemoteInfo, branch?: string })[] | undefined { + if (!isBase) { + return undefined; + } + const cached = githubRepository.getCachedBranches(githubRepository.remote.owner, githubRepository.remote.repositoryName); + if (!cached) { + return undefined; + } + return buildBranchPickItems(cached, githubRepository, folderRepoManager, changeRepoMessage, isBase); +} + +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!); + return buildBranchPickItems(branchNames, githubRepository, folderRepoManager, changeRepoMessage, isBase); +} \ No newline at end of file diff --git a/src/github/repositoriesManager.ts b/src/github/repositoriesManager.ts index d441472346..beff98508b 100644 --- a/src/github/repositoriesManager.ts +++ b/src/github/repositoriesManager.ts @@ -4,16 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import * as path from 'path'; import { CredentialStore } from './credentials'; -import { Remote } from '../common/remote'; -import { EventType } from '../common/timelineEvent'; -import { GitHubRepository } from './githubRepository'; -import { Repository, UpstreamRef } from '../api/api'; -import { Protocol } from '../common/protocol'; -import { IssueModel } from './issueModel'; 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 { fromPRUri, fromRepoUri, Schemes } from '../common/uri'; +import { compareIgnoreCase, isDescendant } from '../common/utils'; export interface ItemsResponseResult { items: T[]; @@ -21,91 +25,193 @@ export interface ItemsResponseResult { hasUnsearchedRepositories: boolean; } -export class NoGitHubReposError extends Error { - constructor(public repository: Repository) { - super(); - } - - get message() { - return `${this.repository.rootUri.toString()} has no GitHub remotes`; - } -} - -export class DetachedHeadError extends Error { - constructor(public repository: Repository) { - super(); - } - - get message() { - return `${this.repository.rootUri.toString()} has a detached HEAD (create a branch first)`; - } -} - -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 `The upstream ref ${remote}/${name} for branch ${branchName} ${problem}.`; - } -} - -export const REMOTES_SETTING = 'remotes'; - export interface PullRequestDefaults { owner: string; repo: string; base: string; } -export const NO_MILESTONE: string = 'No Milestone'; - -export class RepositoriesManager implements vscode.Disposable { +export class RepositoriesManager extends Disposable { static ID = 'RepositoriesManager'; - private _subs: vscode.Disposable[]; + private _folderManagers: FolderRepositoryManager[] = []; + private _subs: Map; private _onDidChangeState = new vscode.EventEmitter(); readonly onDidChangeState: vscode.Event = this._onDidChangeState.event; + 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( - public readonly folderManagers: FolderRepositoryManager[], private _credentialStore: CredentialStore, - private _telemetry: ITelemetry + private _telemetry: ITelemetry, ) { - this._subs = []; + super(); + this._subs = new Map(); vscode.commands.executeCommand('setContext', ReposManagerStateContext, this._state); + } + + private updateActiveReviewCount() { + let count = 0; + for (const folderManager of this._folderManagers) { + if (folderManager.activePullRequest) { + count++; + } + } + commands.setContext(contexts.ACTIVE_PR_COUNT, count); + } + + get folderManagers(): FolderRepositoryManager[] { + return this._folderManagers; + } + + private registerFolderListeners(folderManager: FolderRepositoryManager) { + const disposables = [ + folderManager.onDidLoadRepositories(() => { + this.updateState(); + this._onDidLoadAnyRepositories.fire(); + }), + 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)), + folderManager.repository.state.onDidChange(() => this.checkWorktreeChanges(folderManager.repository)), + ]; + this._subs.set(folderManager, disposables); + } - this._subs.push(...folderManagers.map(folderManager => { - return folderManager.onDidLoadRepositories(state => this.state = state); - })); + private _previousWorktrees: Map> = new Map(); + + private checkWorktreeChanges(repo: Repository): void { + const worktrees = repo.state.worktrees; + if (!worktrees) { + return; + } + + const repoKey = repo.rootUri.toString(); + const currentPaths = new Set(worktrees.map(wt => vscode.Uri.file(wt.path).toString())); + const previousPaths = this._previousWorktrees.get(repoKey); + this._previousWorktrees.set(repoKey, currentPaths); + + if (!previousPaths) { + return; + } + + for (const previousPath of previousPaths) { + if (!currentPaths.has(previousPath)) { + const folderManager = this._folderManagers.find(m => m.repository.rootUri.toString() === previousPath); + if (folderManager) { + Logger.appendLine(`Removing folder manager for removed worktree ${previousPath}`, RepositoriesManager.ID); + this.removeRepo(folderManager.repository); + } + } + } + } + + insertFolderManager(folderManager: FolderRepositoryManager) { + this.registerFolderListeners(folderManager); + + // Try to insert the new repository in workspace folder order + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders) { + const index = workspaceFolders.findIndex( + 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); + this._folderManagers = this._folderManagers.slice(0, index); + this._folderManagers.push(folderManager); + this._folderManagers.push(...arrayEnd); + this.updateActiveReviewCount(); + this._onDidChangeFolderRepositories.fire({ added: folderManager }); + return; + } + } + this._folderManagers.push(folderManager); + this.updateActiveReviewCount(); + this._onDidChangeFolderRepositories.fire({ added: folderManager }); + } + + removeRepo(repo: Repository) { + const existingFolderManagerIndex = this._folderManagers.findIndex( + manager => manager.repository.rootUri.toString() === repo.rootUri.toString(), + ); + if (existingFolderManagerIndex > -1) { + const folderManager = this._folderManagers[existingFolderManagerIndex]; + disposeAll(this._subs.get(folderManager)!); + this._subs.delete(folderManager); + this._folderManagers.splice(existingFolderManagerIndex, 1); + folderManager.dispose(); + this.updateActiveReviewCount(); + this._onDidChangeFolderRepositories.fire({}); + } } getManagerForIssueModel(issueModel: IssueModel | undefined): FolderRepositoryManager | undefined { 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 this.getManagerForRepository(issueModel.remote.owner, issueModel.remote.repositoryName); + } + + getManagerForFile(uri: vscode.Uri): FolderRepositoryManager | undefined { + if (uri.scheme === 'untitled') { + return this._folderManagers[0]; + } + + 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; + + 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; } - getManagerForFile(uri: vscode.Uri): FolderRepositoryManager | undefined { - for (const folderManager of this.folderManagers) { - const managerPath = folderManager.repository.rootUri.fsPath; - const testUriRelativePath = uri.fsPath.substring(managerPath.length); - if (path.join(managerPath, testUriRelativePath) === uri.fsPath) { + 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; } } @@ -115,11 +221,29 @@ export class RepositoriesManager implements vscode.Disposable { 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(); } } @@ -130,24 +254,75 @@ export class RepositoriesManager implements vscode.Disposable { async clearCredentialCache(): Promise { await this._credentialStore.reset(); - this.state = ReposManagerState.Initializing; + this.updateState(ReposManagerState.NeedsAuthentication); } - async authenticate(): Promise { - return !!(await this._credentialStore.login()); - } + 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'); - createGitHubRepository(remote: Remote, credentialStore: CredentialStore): GitHubRepository { - return new GitHubRepository(remote, credentialStore, this._telemetry); - } + if (enterprise) { + 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 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); + } 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; + } + } + } + // If we have no github.com remotes, but we do have github remotes, then we likely have github enterprise remotes. + else if (!hasEnterpriseUri() && (dotComRemotes.length === 0) && (enterpriseRemotes.length > 0)) { + const promptResult = await vscode.window.showInformationMessage(vscode.l10n.t('It looks like you might be using GitHub Enterprise. Would you like to set up GitHub Pull Requests and Issues to authenticate with the enterprise server {0}?', enterpriseRemotes[0].normalizedHost), + { modal: true }, yes, vscode.l10n.t('No, use GitHub.com')); + if (promptResult === yes) { + await setEnterpriseUri(enterpriseRemotes[0].normalizedHost); + } else if (promptResult === undefined) { + return false; + } + } - createGitHubRepositoryFromOwnerName(owner: string, name: string): GitHubRepository { - const uri = `https://github.com/${owner}/${name}`; - return new GitHubRepository(new Remote(name, uri, new Protocol(uri)), this._credentialStore, this._telemetry); + let githubEnterprise; + const hasNonDotComRemote = (enterpriseRemotes.length > 0) || (unknownRemotes.length > 0); + if ((hasEnterpriseUri() || (dotComRemotes.length === 0)) && hasNonDotComRemote) { + githubEnterprise = await this._credentialStore.login(AuthProvider.githubEnterprise); + } + let github; + if (!githubEnterprise && (!hasEnterpriseUri() || enterpriseRemotes.length === 0)) { + github = await this._credentialStore.login(AuthProvider.github); + } + return !!github || !!githubEnterprise; } - dispose() { - this._subs.forEach(sub => sub.dispose()); + override dispose() { + this._subs.forEach(sub => disposeAll(sub)); } } @@ -167,16 +342,3 @@ export function getEventType(text: string) { return EventType.Other; } } - -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), - }; -}; 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 e2f7606693..9188a4cea0 100644 --- a/src/github/utils.ts +++ b/src/github/utils.ts @@ -1,712 +1,2020 @@ -/*--------------------------------------------------------------------------------------------- - * 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 OctokitTypes from '@octokit/types'; -import * as vscode from 'vscode'; -import { IAccount, PullRequest, IGitHubRef, PullRequestMergeability, ISuggestedReviewer, IMilestone, User, Issue } from './interface'; -import { IComment, Reaction } from '../common/comment'; -import { parseDiffHunk, DiffHunk } from '../common/diffHunk'; -import * as Common from '../common/timelineEvent'; -import * as GraphQL from './graphql'; -import { Resource } from '../common/resources'; -import { uniqBy } from '../common/utils'; -import { GitHubRepository, ViewerPermission } from './githubRepository'; -import { GHPRCommentThread, GHPRComment } from './prComment'; -import { ThreadData } from '../view/treeNodes/pullRequestNode'; -import { OctokitCommon } from './common'; -import { GitAPI, Repository } from '../typings/git'; - -export interface CommentReactionHandler { - toggleReaction(comment: vscode.Comment, reaction: vscode.CommentReaction): Promise; -} -export function createVSCodeCommentThread(thread: ThreadData, commentController: vscode.CommentController): GHPRCommentThread { - const vscodeThread = commentController.createCommentThread( - thread.uri, - thread.range!, - [] - ); - - (vscodeThread as GHPRCommentThread).threadId = thread.threadId; - - vscodeThread.comments = thread.comments.map(comment => new GHPRComment(comment, vscodeThread as GHPRCommentThread)); - - updateCommentThreadLabel(vscodeThread as GHPRCommentThread); - vscodeThread.collapsibleState = thread.collapsibleState; - return vscodeThread as GHPRCommentThread; -} - -export function updateCommentThreadLabel(thread: GHPRCommentThread) { - if (thread.comments.length) { - const participantsList = uniqBy(thread.comments as vscode.Comment[], comment => comment.author.name).map(comment => `@${comment.author.name}`).join(', '); - thread.label = `Participants: ${participantsList}`; - } else { - thread.label = 'Start discussion'; - } -} - -export function generateCommentReactions(reactions: Reaction[] | undefined) { - return getReactionGroup().map(reaction => { - if (!reactions) { - return { label: reaction.label, authorHasReacted: false, count: 0, iconPath: reaction.icon || '' }; - } - - const matchedReaction = reactions.find(re => re.label === reaction.label); - - if (matchedReaction) { - return { label: matchedReaction.label, authorHasReacted: matchedReaction.viewerHasReacted, count: matchedReaction.count, iconPath: reaction.icon || '' }; - } else { - return { label: reaction.label, authorHasReacted: false, count: 0, iconPath: reaction.icon || '' }; - } - }); -} -export function updateCommentReactions(comment: vscode.Comment, reactions: Reaction[] | undefined) { - comment.reactions = generateCommentReactions(reactions); -} - -export function updateCommentReviewState(thread: GHPRCommentThread, newDraftMode: boolean) { - if (newDraftMode) { - return; - } - - thread.comments = thread.comments.map(comment => { - if (comment instanceof GHPRComment) { - comment._rawComment.isDraft = false; - } - - comment.label = undefined; - - return comment; - }); -} - -export function convertRESTUserToAccount(user: OctokitCommon.PullsListResponseItemUser, githubRepository: GitHubRepository): IAccount { - return { - login: user.login, - url: user.html_url, - avatarUrl: githubRepository.isGitHubDotCom ? user.avatar_url : undefined - }; -} - -export function convertRESTHeadToIGitHubRef(head: OctokitCommon.PullsListResponseItemHead) { - return { - label: head.label, - ref: head.ref, - sha: head.sha, - repo: { cloneUrl: head.repo.clone_url } - }; -} - -export function convertRESTPullRequestToRawPullRequest(pullRequest: OctokitTypes.PullsCreateResponseData | OctokitTypes.PullsGetResponseData | OctokitCommon.PullsListResponseItem, githubRepository: GitHubRepository): PullRequest { - const { - number, - body, - title, - html_url, - user, - state, - assignee, - created_at, - updated_at, - head, - base, - labels, - node_id, - id, - draft - } = pullRequest; - - const item: PullRequest = { - id, - graphNodeId: node_id, - number, - body, - title, - url: html_url, - user: convertRESTUserToAccount(user, githubRepository), - state, - merged: (pullRequest as OctokitTypes.PullsGetResponseData).merged || false, - assignee: assignee ? convertRESTUserToAccount(assignee, githubRepository) : undefined, - createdAt: created_at, - updatedAt: updated_at, - head: convertRESTHeadToIGitHubRef(head), - base: convertRESTHeadToIGitHubRef(base), - mergeable: (pullRequest as OctokitTypes.PullsGetResponseData).mergeable ? PullRequestMergeability.Mergeable : PullRequestMergeability.NotMergeable, - labels, - isDraft: draft, - suggestedReviewers: [] // suggested reviewers only available through GraphQL API - }; - - return item; -} - -export function convertRESTIssueToRawPullRequest(pullRequest: OctokitTypes.IssuesCreateResponseData, githubRepository: GitHubRepository): PullRequest { - const { - number, - body, - title, - html_url, - user, - state, - assignee, - created_at, - updated_at, - labels, - node_id, - id, - } = pullRequest; - - const item: PullRequest = { - id, - graphNodeId: node_id, - number, - body, - title, - url: html_url, - user: convertRESTUserToAccount(user, githubRepository), - state, - assignee: assignee ? convertRESTUserToAccount(assignee, githubRepository) : undefined, - createdAt: created_at, - updatedAt: updated_at, - labels, - suggestedReviewers: [] // suggested reviewers only available through GraphQL API - }; - - return item; -} - -export function convertRESTReviewEvent(review: OctokitTypes.PullsCreateReviewResponseData, githubRepository: GitHubRepository): Common.ReviewEvent { - return { - event: Common.EventType.Reviewed, - comments: [], - submittedAt: (review as any).submitted_at, // TODO fix typings upstream - body: review.body, - bodyHTML: review.body, - htmlUrl: review.html_url, - user: convertRESTUserToAccount(review.user, githubRepository), - authorAssociation: review.user.type, - state: review.state as 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED' | 'PENDING', - id: review.id - }; -} - -export function parseCommentDiffHunk(comment: IComment): DiffHunk[] { - const diffHunks = []; - const diffHunkReader = parseDiffHunk(comment.diffHunk); - let diffHunkIter = diffHunkReader.next(); - - while (!diffHunkIter.done) { - const diffHunk = diffHunkIter.value; - diffHunks.push(diffHunk); - diffHunkIter = diffHunkReader.next(); - } - - return diffHunks; -} - -export function convertPullRequestsGetCommentsResponseItemToComment(comment: OctokitTypes.PullsCreateReviewCommentResponseData, githubRepository: GitHubRepository): IComment { - const ret: IComment = { - url: comment.url, - id: comment.id, - pullRequestReviewId: comment.pull_request_review_id, - 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': - return Common.EventType.Committed; - case 'LabeledEvent': - return Common.EventType.Labeled; - case 'MilestonedEvent': - return Common.EventType.Milestoned; - case 'AssignedEvent': - return Common.EventType.Assigned; - case 'HeadRefDeletedEvent': - return Common.EventType.HeadRefDeleted; - case 'IssueComment': - return Common.EventType.Commented; - case 'PullRequestReview': - return Common.EventType.Reviewed; - case 'MergedEvent': - return Common.EventType.Merged; - - default: - return Common.EventType.Other; - } -} - -export function parseGraphQLComment(comment: GraphQL.ReviewComment): IComment { - const c: IComment = { - id: comment.databaseId, - url: comment.url, - body: comment.body, - bodyHTML: comment.bodyHTML, - path: comment.path, - canEdit: comment.viewerCanDelete, - canDelete: comment.viewerCanDelete, - pullRequestReviewId: comment.pullRequestReview && comment.pullRequestReview.databaseId, - diffHunk: comment.diffHunk, - position: comment.position, - commitId: comment.commit.oid, - originalPosition: comment.originalPosition, - originalCommitId: comment.originalCommit && comment.originalCommit.oid, - user: comment.author, - createdAt: comment.createdAt, - htmlUrl: comment.url, - graphNodeId: comment.id, - isDraft: comment.state === 'PENDING', - inReplyToId: comment.replyTo && comment.replyTo.databaseId, - reactions: parseGraphQLReaction(comment.reactionGroups) - }; - - const diffHunks = parseCommentDiffHunk(c); - c.diffHunks = diffHunks; - - return c; -} - -export function parseGraphQlIssueComment(comment: GraphQL.IssueComment): IComment { - return { - id: comment.databaseId, - url: comment.url, - body: comment.body, - bodyHTML: comment.bodyHTML, - canEdit: comment.viewerCanDelete, - canDelete: comment.viewerCanDelete, - user: comment.author, - createdAt: comment.createdAt, - htmlUrl: comment.url, - graphNodeId: comment.id, - diffHunk: '' - }; -} - -export function parseGraphQLReaction(reactionGroups: GraphQL.ReactionGroup[]): Reaction[] { - const reactionContentEmojiMapping = getReactionGroup().reduce((prev, curr) => { - prev[curr.title] = curr; - return prev; - }, {} as { [key: string]: { title: string; label: string; icon?: vscode.Uri } }); - - const reactions = reactionGroups.filter(group => group.users.totalCount > 0).map(group => { - const reaction: Reaction = { - label: reactionContentEmojiMapping[group.content].label, - count: group.users.totalCount, - icon: reactionContentEmojiMapping[group.content].icon, - viewerHasReacted: group.viewerHasReacted - }; - - return reaction; - }); - - return reactions; -} - -function parseRef(ref: GraphQL.Ref | undefined): IGitHubRef | undefined { - if (ref) { - return { - label: `${ref.repository.owner.login}:${ref.name}`, - ref: ref.name, - sha: ref.target.oid, - repo: { - cloneUrl: ref.repository.url - } - }; - } -} - -function parseAuthor(author: { login: string, url: string, avatarUrl: string } | null, githubRepository: GitHubRepository): IAccount { - if (author) { - return { - login: author.login, - url: author.url, - avatarUrl: githubRepository.isGitHubDotCom ? author.avatarUrl : undefined - }; - } else { - return { - login: '', - url: '' - }; - } -} - -export function parseMilestone(milestone: { title: string, dueOn?: string, createdAt: string, id: string } | undefined): IMilestone | undefined { - if (!milestone) { - return undefined; - } - return { - title: milestone.title, - dueOn: milestone.dueOn, - createdAt: milestone.createdAt, - id: milestone.id - }; -} - -export function parseMergeability(mergability: 'UNKNOWN' | 'MERGEABLE' | 'CONFLICTING'): PullRequestMergeability { - switch (mergability) { - case 'UNKNOWN': return PullRequestMergeability.Unknown; - case 'MERGEABLE': return PullRequestMergeability.Mergeable; - case 'CONFLICTING': return PullRequestMergeability.NotMergeable; - } -} - -export function parseGraphQLPullRequest(pullRequest: GraphQL.PullRequestResponse, githubRepository: GitHubRepository): PullRequest { - const graphQLPullRequest = pullRequest.repository.pullRequest; - - return { - id: graphQLPullRequest.databaseId, - graphNodeId: graphQLPullRequest.id, - url: graphQLPullRequest.url, - number: graphQLPullRequest.number, - state: graphQLPullRequest.state, - body: graphQLPullRequest.body, - bodyHTML: graphQLPullRequest.bodyHTML, - title: graphQLPullRequest.title, - createdAt: graphQLPullRequest.createdAt, - updatedAt: graphQLPullRequest.updatedAt, - head: parseRef(graphQLPullRequest.headRef), - base: parseRef(graphQLPullRequest.baseRef), - user: parseAuthor(graphQLPullRequest.author, githubRepository), - merged: graphQLPullRequest.merged, - mergeable: parseMergeability(graphQLPullRequest.mergeable), - labels: graphQLPullRequest.labels.nodes, - isDraft: graphQLPullRequest.isDraft, - suggestedReviewers: parseSuggestedReviewers(graphQLPullRequest.suggestedReviewers), - comments: parseComments(graphQLPullRequest.comments?.nodes, githubRepository) - }; -} - -function parseComments(comments: GraphQL.AbbreviatedIssueComment[] | undefined, githubRepository: GitHubRepository) { - if (!comments) { - return; - } - const parsedComments: { - author: IAccount; - body: string; - databaseId: number; - }[] = []; - for (const comment of comments) { - parsedComments.push({ - author: parseAuthor(comment.author, githubRepository), - body: comment.body, - databaseId: comment.databaseId - }); - } - - return parsedComments; -} - -export function parseGraphQLIssue(issue: GraphQL.PullRequest, githubRepository: GitHubRepository): Issue { - return { - id: issue.databaseId, - graphNodeId: issue.id, - url: issue.url, - number: issue.number, - state: issue.state, - body: issue.body, - bodyHTML: issue.bodyHTML, - title: issue.title, - createdAt: issue.createdAt, - updatedAt: issue.updatedAt, - user: parseAuthor(issue.author, githubRepository), - labels: issue.labels.nodes, - repositoryName: issue.repository?.name, - repositoryOwner: issue.repository?.owner.login, - repositoryUrl: issue.repository?.url - }; -} - -export function parseGraphQLIssuesRequest(pullRequest: GraphQL.PullRequest, githubRepository: GitHubRepository): PullRequest { - const graphQLPullRequest = pullRequest; - - return { - id: graphQLPullRequest.databaseId, - graphNodeId: graphQLPullRequest.id, - url: graphQLPullRequest.url, - number: graphQLPullRequest.number, - state: graphQLPullRequest.state, - body: graphQLPullRequest.body, - bodyHTML: graphQLPullRequest.bodyHTML, - title: graphQLPullRequest.title, - createdAt: graphQLPullRequest.createdAt, - updatedAt: graphQLPullRequest.updatedAt, - head: parseRef(graphQLPullRequest.headRef), - base: parseRef(graphQLPullRequest.baseRef), - user: parseAuthor(graphQLPullRequest.author, githubRepository), - merged: graphQLPullRequest.merged, - mergeable: parseMergeability(graphQLPullRequest.mergeable), - labels: graphQLPullRequest.labels.nodes, - isDraft: graphQLPullRequest.isDraft, - suggestedReviewers: parseSuggestedReviewers(graphQLPullRequest.suggestedReviewers), - milestone: parseMilestone(graphQLPullRequest.milestone) - }; -} - -function parseSuggestedReviewers(suggestedReviewers: GraphQL.SuggestedReviewerResponse[] | undefined): ISuggestedReviewer[] { - if (!suggestedReviewers) { - return []; - } - const ret: ISuggestedReviewer[] = suggestedReviewers.map(suggestedReviewer => { - return { - login: suggestedReviewer.reviewer.login, - avatarUrl: suggestedReviewer.reviewer.avatarUrl, - name: suggestedReviewer.reviewer.name, - url: suggestedReviewer.reviewer.url, - isAuthor: suggestedReviewer.isAuthor, - isCommenter: suggestedReviewer.isCommenter - }; - }); - - return ret.sort(loginComparator); -} - -/** - * Used for case insensitive sort by login - */ -export function loginComparator(a: IAccount, b: IAccount) { - // sensitivity: 'accent' allows case insensitive comparison - return a.login.localeCompare(b.login, 'en', { sensitivity: 'accent' }); -} - -export function parseGraphQLReviewEvent(review: GraphQL.SubmittedReview, githubRepository: GitHubRepository): Common.ReviewEvent { - return { - event: Common.EventType.Reviewed, - comments: review.comments.nodes.map(parseGraphQLComment).filter(c => !c.inReplyToId), - submittedAt: review.submittedAt, - body: review.body, - bodyHTML: review.bodyHTML, - htmlUrl: review.url, - user: parseAuthor(review.author, githubRepository), - authorAssociation: review.authorAssociation, - state: review.state, - id: review.databaseId - }; -} - -export function parseGraphQLTimelineEvents(events: (GraphQL.MergedEvent | GraphQL.Review | GraphQL.IssueComment | GraphQL.Commit | GraphQL.AssignedEvent | GraphQL.HeadRefDeletedEvent)[], githubRepository: GitHubRepository): Common.TimelineEvent[] { - const normalizedEvents: Common.TimelineEvent[] = []; - events.forEach(event => { - const type = convertGraphQLEventType(event.__typename); - - switch (type) { - case Common.EventType.Commented: - const commentEvent = event as GraphQL.IssueComment; - normalizedEvents.push({ - htmlUrl: commentEvent.url, - body: commentEvent.body, - bodyHTML: commentEvent.bodyHTML, - user: parseAuthor(commentEvent.author, githubRepository), - event: type, - canEdit: commentEvent.viewerCanUpdate, - canDelete: commentEvent.viewerCanDelete, - id: commentEvent.databaseId, - graphNodeId: commentEvent.id, - createdAt: commentEvent.createdAt - }); - return; - case Common.EventType.Reviewed: - const reviewEvent = event as GraphQL.Review; - normalizedEvents.push({ - event: type, - comments: [], - submittedAt: reviewEvent.submittedAt, - body: reviewEvent.body, - bodyHTML: reviewEvent.bodyHTML, - htmlUrl: reviewEvent.url, - user: parseAuthor(reviewEvent.author, githubRepository), - authorAssociation: reviewEvent.authorAssociation, - state: reviewEvent.state, - id: reviewEvent.databaseId, - }); - return; - case Common.EventType.Committed: - const commitEv = event as GraphQL.Commit; - normalizedEvents.push({ - id: commitEv.databaseId, - event: type, - sha: commitEv.commit.oid, - author: commitEv.commit.author.user ? parseAuthor(commitEv.commit.author.user, githubRepository) : { login: commitEv.commit.committer.name }, - htmlUrl: commitEv.url, - message: commitEv.commit.message - } as Common.CommitEvent); // TODO remove cast - return; - case Common.EventType.Merged: - const mergeEv = event as GraphQL.MergedEvent; - - normalizedEvents.push({ - id: mergeEv.databaseId, - event: type, - user: parseAuthor(mergeEv.actor, githubRepository), - createdAt: mergeEv.createdAt, - mergeRef: mergeEv.mergeRef.name, - sha: mergeEv.commit.oid, - commitUrl: mergeEv.commit.commitUrl, - url: mergeEv.url, - graphNodeId: mergeEv.id - }); - return; - case Common.EventType.Assigned: - const assignEv = event as GraphQL.AssignedEvent; - - normalizedEvents.push({ - id: assignEv.databaseId, - event: type, - user: assignEv.user, - actor: assignEv.actor - }); - return; - case Common.EventType.HeadRefDeleted: - const deletedEv = event as GraphQL.HeadRefDeletedEvent; - - normalizedEvents.push({ - id: deletedEv.id, - event: type, - actor: deletedEv.actor, - createdAt: deletedEv.createdAt, - headRef: deletedEv.headRefName - }); - return; - default: - break; - } - }); - - return normalizedEvents; -} - -export function parseGraphQLUser(user: GraphQL.UserResponse): User { - return { - login: user.user.login, - name: user.user.name, - avatarUrl: user.user.avatarUrl, - url: user.user.url, - bio: user.user.bio, - company: user.user.company, - location: user.user.location, - commitContributions: parseGraphQLCommitContributions(user.user.contributionsCollection) - }; -} - -function parseGraphQLCommitContributions(commitComments: GraphQL.ContributionsCollection): { createdAt: Date, repoNameWithOwner: string }[] { - const items: { createdAt: Date, repoNameWithOwner: string }[] = []; - commitComments.commitContributionsByRepository.forEach(repoCommits => { - repoCommits.contributions.nodes.forEach(commit => { - items.push({ createdAt: new Date(commit.occurredAt), repoNameWithOwner: repoCommits.repository.nameWithOwner }); - }); - }); - return items; -} - -export function getReactionGroup(): { title: string; label: string; icon?: vscode.Uri }[] { - const ret = [ - { - title: 'THUMBS_UP', - label: '👍', - icon: Resource.icons.reactions.THUMBS_UP - }, - { - title: 'THUMBS_DOWN', - label: '👎', - icon: Resource.icons.reactions.THUMBS_DOWN - }, - { - title: 'LAUGH', - label: '😄', - icon: Resource.icons.reactions.LAUGH - }, - { - title: 'HOORAY', - label: '🎉', - icon: Resource.icons.reactions.HOORAY - }, - { - title: 'CONFUSED', - label: '😕', - icon: Resource.icons.reactions.CONFUSED - }, - { - title: 'HEART', - label: '❤️', - icon: Resource.icons.reactions.HEART - }, - { - title: 'ROCKET', - label: '🚀', - icon: Resource.icons.reactions.ROCKET - }, - { - title: 'EYES', - label: '👀', - icon: Resource.icons.reactions.EYES - } - ]; - - return ret; -} - -export function getRelatedUsersFromTimelineEvents(timelineEvents: Common.TimelineEvent[]): { login: string; name: string; }[] { - const ret: { login: string; name: string; }[] = []; - - timelineEvents.forEach(event => { - if (Common.isCommitEvent(event)) { - ret.push({ - login: event.author.login, - name: event.author.name || '' - }); - } - - if (Common.isReviewEvent(event)) { - ret.push({ - login: event.user.login, - name: event.user.name ?? event.user.login - }); - } - - if (Common.isCommentEvent(event)) { - ret.push({ - login: event.user.login, - name: event.user.name ?? event.user.login - }); - } - }); - - return ret; -} - -export function parseGraphQLViewerPermission(viewerPermissionResponse: GraphQL.ViewerPermissionResponse): ViewerPermission { - if (viewerPermissionResponse && viewerPermissionResponse.repository.viewerPermission) { - if ((Object.values(ViewerPermission)).includes(viewerPermissionResponse.repository.viewerPermission)) { - return viewerPermissionResponse.repository.viewerPermission; - } - } - return ViewerPermission.Unknown; -} - -export function getRepositoryForFile(gitAPI: GitAPI, file: vscode.Uri): Repository | undefined { - for (const repository of gitAPI.repositories) { - if ((file.path.toLowerCase() === repository.rootUri.path.toLowerCase()) || - (file.path.toLowerCase().startsWith(repository.rootUri.path.toLowerCase()) - && file.path.substring(repository.rootUri.path.length).startsWith('/'))) { - return repository; - } - } - return undefined; -} \ 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. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import * as crypto from 'crypto'; +import * as OctokitTypes from '@octokit/types'; +import * as vscode from 'vscode'; +import { OctokitCommon } from './common'; +import { FolderRepositoryManager, PullRequestDefaults } from './folderRepositoryManager'; +import { GitHubRepository, ViewerPermission } from './githubRepository'; +import * as GraphQL from './graphql'; +import { + AccountType, + GithubItemStateEnum, + IAccount, + IActor, + IGitHubRef, + IIssueComment, + ILabel, + IMilestone, + IProjectItem, + Issue, + IssueReference, + 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, processDiffLinks as processDiffLinksCore, processPermalinks as processPermalinksCore, 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)/; + +/** + * Returns an issue/URL regex matching the appropriate GitHub host. When a + * GitHub Enterprise URI is provided, that host is used in addition to + * github.com so that issue URLs from the enterprise instance are recognized. + * The capture group layout matches `ISSUE_OR_URL_EXPRESSION` for use with + * `parseIssueExpressionOutput`. + */ +export function getIssueOrURLExpression(enterpriseUri?: vscode.Uri): RegExp { + const hostPattern = enterpriseUri + ? `(?:github\\.com|${escapeRegExp(enterpriseUri.authority)})` + : 'github\\.com'; + return new RegExp(`(https?:\\/\\/${hostPattern}\\/(([^\\s]+)\\/([^\\s]+))\\/([^\\s]+\\/)?(issues|pull)\\/([0-9]+)(#issuecomment\\-([0-9]+))?)|(([A-Za-z0-9_.\\-]+)\\/([A-Za-z0-9_.\\-]+))?(#|GH-)([1-9][0-9]*)($|\\b)`); +} + +export interface CommentReactionHandler { + toggleReaction(comment: vscode.Comment, reaction: vscode.CommentReaction): Promise; +} + +export type ParsedIssue = { + owner: string | undefined; + name: string | undefined; + issueNumber: number; + commentNumber?: number; +}; + +export function parseIssueExpressionOutput(output: RegExpMatchArray | null): ParsedIssue | undefined { + if (!output) { + return undefined; + } + const issue: ParsedIssue = { owner: undefined, name: undefined, issueNumber: 0 }; + if (output.length === 7) { + issue.owner = output[2]; + issue.name = output[3]; + issue.issueNumber = parseInt(output[5]); + return issue; + } else if (output.length === 16) { + issue.owner = output[3] || output[11]; + issue.name = output[4] || output[12]; + issue.issueNumber = parseInt(output[7] || output[14]); + issue.commentNumber = output[9] !== undefined ? parseInt(output[9]) : undefined; + return issue; + } else { + return undefined; + } +} + +export function threadRange(startLine: number, endLine: number, endCharacter?: number): vscode.Range { + if ((startLine !== endLine) && (endCharacter === undefined)) { + endCharacter = 300; // 300 is a "large" number that will select a lot of the line since don't know anything about the line length + } else if (!endCharacter) { + endCharacter = 0; + } + 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; + if (!DataUri.isGitHubDotComAvatar(currentUser.avatarUrl)) { + thread2.canReply = { name: currentUser.name ?? currentUser.login, iconPath: undefined }; + return; + } + 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 | undefined, + thread: IReviewThread, + commentController: vscode.CommentController, + 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(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.login); + + setReplyAuthor(vscodeThread, currentUser, context); + + return vscodeThread as GHPRCommentThread; +} + +function isResolvedToResolvedState(isResolved: boolean) { + return isResolved ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved; +} + +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, 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) { + expand = config === COMMENT_EXPAND_STATE_EXPAND_VALUE; + } + return expand + ? vscode.CommentThreadCollapsibleState.Expanded : vscode.CommentThreadCollapsibleState.Collapsed; +} + + +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(context, vscodeThread, reviewThread, githubRepositories, expand, range, undefined, isNewlyAdded); + break; + } + } +} + +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); + 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, currentUser, isNewlyAdded); + if (range) { + vscodeThread.range = range; + } + 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?.resolved === vscode.CommentThreadState.Resolved) { + thread.label = vscode.l10n.t('Marked as resolved'); + return; + } + + if (thread.comments.length) { + const participantsList = uniqBy(thread.comments, comment => comment.originalAuthor.name) + .map(comment => `@${comment.originalAuthor.name}`) + .join(', '); + thread.label = vscode.l10n.t('Participants: {0}', participantsList); + } else { + thread.label = vscode.l10n.t('Start discussion'); + } +} + +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) { + newReaction = { + label: matchedReaction.label, + authorHasReacted: matchedReaction.viewerHasReacted, + count: matchedReaction.count, + iconPath: reaction.icon || '', + reactors: matchedReaction.reactors.map(reactor => ({ name: reactor })) + }; + } else { + 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; + }); + comment.reactions = newReactions; + return reactionsHaveUpdates; +} + +export function updateCommentReviewState(thread: GHPRCommentThread, newDraftMode: boolean) { + if (newDraftMode) { + return; + } + + thread.comments = thread.comments.map(comment => { + if (comment instanceof GHPRComment) { + comment.rawComment.isDraft = false; + } + + comment.label = undefined; + + return comment; + }); +} + +export function isEnterprise(provider: AuthProvider): boolean { + return provider === AuthProvider.githubEnterprise; +} + +export function convertRESTUserToAccount( + user: OctokitCommon.PullsListResponseItemUser, + githubRepository?: GitHubRepository, +): IAccount { + return parseAccount(user, githubRepository); +} + +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()}"`; + } + }); +} + +/** + * Process GitHub blob permalinks in HTML and add data attributes for local file handling. + * Finds blob permalinks (e.g., /blob/[sha]/file.ts#L10), checks if files exist locally, + * and adds data attributes to enable clicking to open local files. + */ +export async function processPermalinks( + bodyHTML: string, + githubRepository: GitHubRepository, + rootUri: vscode.Uri +): Promise { + try { + const repoName = githubRepository.remote.repositoryName; + const authority = githubRepository.remote.gitProtocol.url.authority; + + // Create file existence check callback + const fileExistsCheck = async (filePath: string): Promise => { + try { + const localFileUri = vscode.Uri.joinPath(rootUri, filePath); + const stat = await vscode.workspace.fs.stat(localFileUri); + return stat.type === vscode.FileType.File; + } catch { + return false; + } + }; + + return await processPermalinksCore(bodyHTML, repoName, authority, fileExistsCheck); + } catch (error) { + Logger.error(`Failed to process blob permalinks in HTML: ${error}`, 'processPermalinks'); + return bodyHTML; + } +} + +/** + * Process GitHub diff permalinks in HTML and add data attributes for local file handling. + * Finds diff permalinks (e.g., /pull/123/files#diff-[hash]R10), maps hashes to filenames, + * and adds data attributes to enable clicking to open diff views. + */ +export async function processDiffLinks( + bodyHTML: string, + githubRepository: GitHubRepository, + hashMap: Record, + prNumber: number +): Promise { + try { + const repoName = githubRepository.remote.repositoryName; + const repoOwner = githubRepository.remote.owner; + const authority = githubRepository.remote.gitProtocol.url.authority; + + return await processDiffLinksCore(bodyHTML, repoOwner, repoName, authority, hashMap, prNumber); + } catch (error) { + Logger.error(`Failed to process diff permalinks in HTML: ${error}`, 'processDiffLinks'); + return bodyHTML; + } +} + +export function convertRESTPullRequestToRawPullRequest( + pullRequest: + | OctokitCommon.PullsGetResponseData + | OctokitCommon.PullsListResponseItem, + githubRepository: GitHubRepository, +): PullRequest { + const { + number, + body, + title, + html_url, + user, + state, + assignees, + created_at, + updated_at, + head, + base, + labels, + node_id, + id, + draft, + } = pullRequest; + + const item: PullRequest = { + id, + graphNodeId: node_id, + number, + body: body ?? '', + title, + titleHTML: title, + url: html_url, + user: convertRESTUserToAccount(user!, githubRepository), + state, + merged: (pullRequest as OctokitCommon.PullsGetResponseData).merged || false, + assignees: assignees + ? assignees.map(assignee => convertRESTUserToAccount(assignee!, githubRepository)) + : undefined, + createdAt: created_at, + updatedAt: updated_at, + viewerCanUpdate: false, + head: head.repo ? convertRESTHeadToIGitHubRef(head as OctokitCommon.PullsListResponseItemHead) : undefined, + base: convertRESTHeadToIGitHubRef(base), + 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, +): Issue { + const { + number, + body, + title, + html_url, + user, + state, + assignees, + created_at, + updated_at, + labels, + node_id, + id, + comments + } = pullRequest; + + const item: Issue = { + id, + graphNodeId: node_id, + number, + body: body ?? '', + title, + titleHTML: title, + url: html_url, + user: convertRESTUserToAccount(user!, githubRepository), + state, + assignees: assignees + ? assignees.map(assignee => convertRESTUserToAccount(assignee!, githubRepository)) + : undefined, + createdAt: created_at, + updatedAt: updated_at, + labels: labels.map(l => + typeof l === 'string' ? { name: l, color: '' } : { name: l.name ?? '', color: l.color ?? '', description: l.description ?? undefined }, + ), + 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; +} + +export function convertRESTReviewEvent( + review: OctokitCommon.PullsCreateReviewResponseData, + githubRepository: GitHubRepository, +): Common.ReviewEvent { + return { + event: Common.EventType.Reviewed, + comments: [], + submittedAt: review.submitted_at, + body: review.body, + bodyHTML: review.body, + htmlUrl: review.html_url, + user: convertRESTUserToAccount(review.user!, githubRepository), + authorAssociation: review.user!.type, + state: review.state as 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED' | 'PENDING', + id: review.id, + reactions: undefined // reactions only available through GraphQL API + }; +} + +export function parseCommentDiffHunk(comment: IComment): DiffHunk[] { + const diffHunks: DiffHunk[] = []; + const diffHunkReader = parseDiffHunk(comment.diffHunk); + let diffHunkIter = diffHunkReader.next(); + + while (!diffHunkIter.done) { + const diffHunk = diffHunkIter.value; + diffHunks.push(diffHunk); + diffHunkIter = diffHunkReader.next(); + } + + return diffHunks; +} + +export function convertGraphQLEventType(text: string) { + switch (text) { + case 'PullRequestCommit': + return Common.EventType.Committed; + case 'LabeledEvent': + return Common.EventType.Labeled; + case 'MilestonedEvent': + return Common.EventType.Milestoned; + case 'AssignedEvent': + return Common.EventType.Assigned; + case 'UnassignedEvent': + return Common.EventType.Unassigned; + case 'HeadRefDeletedEvent': + return Common.EventType.HeadRefDeleted; + case 'IssueComment': + return Common.EventType.Commented; + case 'PullRequestReview': + 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; + } +} + +export function parseGraphQLReviewThread(thread: GraphQL.ReviewThread, githubRepository: GitHubRepository): IReviewThread { + return { + id: thread.id, + prReviewDatabaseId: thread.comments.edges && thread.comments.edges.length ? + thread.comments.edges[0].node.pullRequestReview?.databaseId : + undefined, + isResolved: thread.isResolved, + viewerCanResolve: thread.viewerCanResolve, + viewerCanUnresolve: thread.viewerCanUnresolve, + path: thread.path, + startLine: thread.startLine ?? thread.line, + endLine: thread.line, + originalStartLine: thread.originalStartLine ?? thread.originalLine, + originalEndLine: thread.originalLine, + diffSide: thread.diffSide, + isOutdated: thread.isOutdated, + comments: thread.comments.nodes.map(comment => parseGraphQLComment(comment, thread.isResolved, thread.isOutdated, githubRepository, thread.id)), + subjectType: thread.subjectType ?? SubjectType.LINE + }; +} + +export function parseGraphQLComment(comment: GraphQL.ReviewComment, isResolved: boolean, isOutdated: boolean, githubRepository: GitHubRepository, threadId?: string): 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, + canDelete: comment.viewerCanDelete, + pullRequestReviewId: comment.pullRequestReview && comment.pullRequestReview.databaseId, + diffHunk: comment.diffHunk, + position: comment.position, + commitId: comment.commit.oid, + originalPosition: comment.originalPosition, + originalCommitId: comment.originalCommit && comment.originalCommit.oid, + user: comment.author ? parseAccount(comment.author, githubRepository) : undefined, + createdAt: comment.createdAt, + htmlUrl: comment.url, + graphNodeId: comment.id, + isDraft: comment.state === 'PENDING', + inReplyToId: comment.replyTo && comment.replyTo.databaseId, + reactions: parseGraphQLReaction(comment.reactionGroups), + isResolved, + isOutdated, + threadId + }; + + const diffHunks = parseCommentDiffHunk(c); + c.diffHunks = diffHunks; + + return c; +} + +export function parseGraphQlIssueComment(comment: GraphQL.IssueComment, githubRepository: GitHubRepository): IComment { + return { + 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: parseAccount(comment.author, githubRepository), + createdAt: comment.createdAt, + htmlUrl: comment.url, + graphNodeId: comment.id, + diffHunk: '', + reactions: parseGraphQLReaction(comment.reactionGroups) + }; +} + +export function parseGraphQLReaction(reactionGroups: GraphQL.ReactionGroup[]): Reaction[] { + const reactionContentEmojiMapping = getReactionGroup().reduce((prev, curr) => { + prev[curr.title] = curr; + return prev; + }, {} as { [key: string]: { title: string; label: string; icon?: string } }); + + const reactions = reactionGroups + .filter(group => group.reactors.totalCount > 0) + .map(group => { + const reaction: Reaction = { + label: reactionContentEmojiMapping[group.content].label, + 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; + }); + + return reactions; +} + +function parseRef(refName: string, oid: string, repository?: GraphQL.RefRepository): IGitHubRef | undefined { + if (!repository) { + return undefined; + } + + return { + label: `${repository.owner.login}:${refName}`, + ref: refName, + sha: oid, + repo: { + cloneUrl: repository.url, + isInOrganization: repository.isInOrganization, + owner: repository.owner.login, + name: refName + }, + }; +} + +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.isEnterprise), + }; + } else { + return { + login: '', + url: '', + }; + } +} + +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, number: number } | undefined, +): IMilestone | undefined { + if (!milestone) { + return undefined; + } + return { + title: milestone.title, + dueOn: milestone.dueOn, + createdAt: milestone.createdAt, + id: milestone.id, + number: milestone.number + }; +} + +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'; + case 'SQUASH': return 'squash'; + } +} + +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; + case 'MERGEABLE': + parsed = PullRequestMergeability.Mergeable; + break; + case 'CONFLICTING': + parsed = PullRequestMergeability.Conflict; + break; + } + if (parsed !== PullRequestMergeability.Conflict) { + if (mergeStateStatus === 'BLOCKED') { + parsed = PullRequestMergeability.NotMergeable; + } else if (mergeStateStatus === 'BEHIND') { + parsed = PullRequestMergeability.Behind; + } + } + return parsed; +} + +export async function parseGraphQLPullRequest( + graphQLPullRequest: GraphQL.PullRequest, + githubRepository: GitHubRepository, +): Promise { + const pr: PullRequest = { + id: graphQLPullRequest.databaseId, + graphNodeId: graphQLPullRequest.id, + url: graphQLPullRequest.url, + number: graphQLPullRequest.number, + state: graphQLPullRequest.state, + body: graphQLPullRequest.body, + bodyHTML: await transformHtmlUrlsToExtensionUrls(graphQLPullRequest.bodyHTML, githubRepository), + title: graphQLPullRequest.title, + titleHTML: graphQLPullRequest.titleHTML, + createdAt: graphQLPullRequest.createdAt, + updatedAt: graphQLPullRequest.updatedAt, + isRemoteHeadDeleted: !graphQLPullRequest.headRef, + 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: 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 => 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, + closingIssues: parseClosingIssuesReferences(graphQLPullRequest.closingIssuesReferences?.nodes), + }; + 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) { + if (!comments) { + return; + } + const parsedComments: { + author: IAccount; + body: string; + databaseId: number; + reactionCount: number; + createdAt: string; + }[] = []; + for (const comment of comments) { + parsedComments.push({ + author: parseAccount(comment.author, githubRepository), + body: comment.body, + databaseId: comment.databaseId, + reactionCount: comment.reactions.totalCount, + createdAt: comment.createdAt + }); + } + + return parsedComments; +} + +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: await transformHtmlUrlsToExtensionUrls(issue.bodyHTML, githubRepository), + title: issue.title, + titleHTML: issue.titleHTML, + createdAt: issue.createdAt, + updatedAt: issue.updatedAt, + assignees: issue.assignees?.nodes.map(assignee => parseAccount(assignee, githubRepository)), + user: parseAccount(issue.author, githubRepository), + labels: issue.labels.nodes, + 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, + issueType: issue.issueType?.name + }; +} + +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, + }; +} + +function parseSuggestedReviewers( + suggestedReviewers: GraphQL.SuggestedReviewerResponse[] | undefined, +): ISuggestedReviewer[] { + if (!suggestedReviewers) { + return []; + } + const ret: ISuggestedReviewer[] = suggestedReviewers.map(suggestedReviewer => { + const account = parseAccount(suggestedReviewer.reviewer, undefined); + return { + ...account, + isAuthor: suggestedReviewer.isAuthor, + isCommenter: suggestedReviewer.isCommenter + }; + }); + + return ret.sort(loginComparator); +} + +function parseClosingIssuesReferences( + closingIssuesReferences: Array<{ databaseId: number, number: number, title: string, state: string, url: string }> | undefined +): IssueReference[] { + if (!closingIssuesReferences) { + return []; + } + + return closingIssuesReferences.map(issue => ({ + id: issue.databaseId, + number: issue.number, + title: issue.title, + state: issue.state === 'OPEN' ? GithubItemStateEnum.Open : GithubItemStateEnum.Closed, + url: issue.url, + })); +} + +/** + * Used for case insensitive sort by login + */ +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, + githubRepository: GitHubRepository, +): Common.ReviewEvent { + return { + event: Common.EventType.Reviewed, + 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: 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 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 + | GraphQL.IssueComment + | GraphQL.Commit + | GraphQL.AssignedEvent + | GraphQL.HeadRefDeletedEvent + | GraphQL.CrossReferencedEvent + | GraphQL.BaseRefChangedEvent + | null + )[], + restEvents: Common.TimelineEvent[], + githubRepository: GitHubRepository, +): Promise { + const normalizedEvents: Common.TimelineEvent[] = []; + 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; + addTimelineEvent({ + htmlUrl: commentEvent.url, + body: commentEvent.body, + bodyHTML: commentEvent.bodyHTML, + 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), + }); + break; + case Common.EventType.Reviewed: + const reviewEvent = event as GraphQL.Review; + addTimelineEvent({ + event: type, + comments: [], + submittedAt: reviewEvent.submittedAt, + body: reviewEvent.body, + bodyHTML: reviewEvent.bodyHTML, + htmlUrl: reviewEvent.url, + user: parseAccount(reviewEvent.author, githubRepository), + authorAssociation: reviewEvent.authorAssociation, + state: reviewEvent.state, + id: reviewEvent.databaseId, + reactions: parseGraphQLReaction(reviewEvent.reactionGroups), + }); + break; + case Common.EventType.Committed: + const commitEv = event as GraphQL.Commit; + addTimelineEvent({ + id: commitEv.id, + event: type, + sha: commitEv.commit.oid, + author: commitEv.commit.author.user + ? parseAccount(commitEv.commit.author.user, githubRepository) + : { login: commitEv.commit.committer.name }, + htmlUrl: commitEv.url, + message: commitEv.commit.message, + committedDate: new Date(commitEv.commit.committedDate), + verification: commitEv.commit.signature ? { + verified: commitEv.commit.signature.isValid, + state: commitEv.commit.signature.state, + wasSignedByGitHub: commitEv.commit.signature.wasSignedByGitHub, + signer: commitEv.commit.signature.signer ? { + login: commitEv.commit.signature.signer.login, + name: commitEv.commit.signature.signer.name ?? undefined, + avatarUrl: commitEv.commit.signature.signer.avatarUrl, + } : undefined, + keyId: commitEv.commit.signature.keyId ?? undefined, + keyFingerprint: commitEv.commit.signature.keyFingerprint ?? undefined, + email: commitEv.commit.signature.email ?? undefined, + } : undefined, + status: commitEv.commit.statusCheckRollup?.state + } as Common.CommitEvent); // TODO remove cast + break; + case Common.EventType.Merged: + const mergeEv = event as GraphQL.MergedEvent; + + addTimelineEvent({ + id: mergeEv.id, + event: type, + user: parseActor(mergeEv.actor, githubRepository), + createdAt: mergeEv.createdAt, + mergeRef: mergeEv.mergeRef.name, + sha: mergeEv.commit.oid, + commitUrl: mergeEv.commit.commitUrl, + url: mergeEv.url, + graphNodeId: mergeEv.id, + }); + break; + case Common.EventType.Assigned: + const assignEv = event as GraphQL.AssignedEvent; + + addTimelineEvent({ + id: assignEv.id, + event: type, + 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, + }); + break; + case Common.EventType.HeadRefDeleted: + const deletedEv = event as GraphQL.HeadRefDeletedEvent; + + addTimelineEvent({ + id: deletedEv.id, + event: type, + actor: parseAccount(deletedEv.actor, githubRepository), + createdAt: deletedEv.createdAt, + headRef: deletedEv.headRefName, + }); + 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; +} + +export function parseGraphQLUser(user: GraphQL.UserResponse, githubRepository: GitHubRepository): User { + return { + login: user.user.login, + name: user.user.name, + 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) + }; +} + +function parseGraphQLCommitContributions( + commitComments: GraphQL.ContributionsCollection, +): { createdAt: Date; repoNameWithOwner: string }[] { + const items: { createdAt: Date; repoNameWithOwner: string }[] = []; + commitComments.commitContributionsByRepository.forEach(repoCommits => { + repoCommits.contributions.nodes.forEach(commit => { + items.push({ + createdAt: new Date(commit.occurredAt), + repoNameWithOwner: repoCommits.repository.nameWithOwner, + }); + }); + }); + return items; +} + +export function getReactionGroup(): { title: string; label: string; icon?: string }[] { + const ret = [ + { + title: 'THUMBS_UP', + // allow-any-unicode-next-line + label: '👍' + }, + { + title: 'THUMBS_DOWN', + // allow-any-unicode-next-line + label: '👎' + }, + { + title: 'LAUGH', + // allow-any-unicode-next-line + label: '😄' + }, + { + title: 'HOORAY', + // allow-any-unicode-next-line + label: '🎉' + }, + { + title: 'CONFUSED', + // allow-any-unicode-next-line + label: '😕' + }, + { + title: 'HEART', + // allow-any-unicode-next-line + label: '❤️' + }, + { + title: 'ROCKET', + // allow-any-unicode-next-line + label: '🚀' + }, + { + title: 'EYES', + // allow-any-unicode-next-line + label: '👀' + }, + ]; + + return ret; +} + +export async function restPaginate(request: R, variables: Parameters[0], per_page: number = 100): Promise { + let page = 1; + let results: T[] = []; + let hasNextPage = false; + + do { + const result = await request( + { + // eslint-disable-next-line rulesdir/no-cast-to-any + ...(variables as any), + per_page, + page + } + ); + + results = results.concat( + result.data as T[] + ); + + hasNextPage = !!result.headers.link && result.headers.link.indexOf('rel="next"') > -1; + page += 1; + } while (hasNextPage); + + return results; +} + +export function getRelatedUsersFromTimelineEvents( + timelineEvents: Common.TimelineEvent[] | undefined, +): { login: string; name: string }[] { + const ret: { login: string; name: string }[] = []; + + timelineEvents?.forEach(event => { + if (event.event === Common.EventType.Committed) { + ret.push({ + login: event.author.login, + name: event.author.name || '', + }); + } + + if (event.event === Common.EventType.Reviewed) { + ret.push({ + login: event.user.login, + name: event.user.name ?? event.user.login, + }); + } + + if ((event.event === Common.EventType.Commented) && event.user) { + ret.push({ + login: event.user.login, + name: event.user.name ?? event.user.login, + }); + } + }); + + return ret; +} + +export function parseGraphQLViewerPermission( + viewerPermissionResponse: GraphQL.ViewerPermissionResponse, +): ViewerPermission { + if (viewerPermissionResponse && viewerPermissionResponse.repository?.viewerPermission) { + if ( + (Object.values(ViewerPermission) as string[]).includes(viewerPermissionResponse.repository.viewerPermission) + ) { + return viewerPermissionResponse.repository.viewerPermission as ViewerPermission; + } + } + return ViewerPermission.Unknown; +} + +export function isFileInRepo(repository: Repository, file: vscode.Uri): boolean { + return file.path.toLowerCase() === repository.rootUri.path.toLowerCase() || + (file.path.toLowerCase().startsWith(repository.rootUri.path.toLowerCase()) && + file.path.substring(repository.rootUri.path.length).startsWith('/')); +} + +export function getRepositoryForFile(gitAPI: GitApiImpl, file: vscode.Uri): Repository | undefined { + const foundRepos: Repository[] = []; + for (const repository of gitAPI.repositories.reverse()) { + if (isFileInRepo(repository, file)) { + foundRepos.push(repository); + } + } + if (foundRepos.length > 0) { + foundRepos.sort((a, b) => b.rootUri.path.length - a.rootUri.path.length); + return foundRepos[0]; + } + return undefined; +} + +/** + * Create a list of reviewers composed of people who have already left reviews on the PR, and + * those that have had a review requested of them. If a reviewer has left multiple reviews, the + * state should be the state of their most recent review, or 'REQUESTED' if they have an outstanding + * review request. + * @param requestedReviewers The list of reviewers that are requested for this pull request + * @param timelineEvents All timeline events for the pull request + * @param author The author of the pull request + */ +export function parseReviewers( + requestedReviewers: (IAccount | ITeam)[], + timelineEvents: Common.TimelineEvent[] | undefined, + author: IAccount, +): ReviewState[] { + const reviewEvents = timelineEvents?.filter((e): e is Common.ReviewEvent => e.event === Common.EventType.Reviewed).filter(event => event.state !== 'PENDING') || []; + let reviewers: ReviewState[] = []; + const seen = new Map(); + + // Do not show the author in the reviewer list + seen.set(author.login, true); + + for (let i = reviewEvents.length - 1; i >= 0; i--) { + 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: reviewEvent.state, + }); + } + } + + requestedReviewers.forEach(request => { + if (!seen.get(reviewerId(request))) { + reviewers.push({ + reviewer: request, + state: 'REQUESTED', + }); + } else { + const reviewer = reviewers.find(r => reviewerId(r.reviewer) === reviewerId(request)); + reviewer!.state = 'REQUESTED'; + } + }); + + // Put completed reviews before review requests and alphabetize each section + reviewers = reviewers.sort((a, b) => { + if (a.state === 'REQUESTED' && b.state !== 'REQUESTED') { + return 1; + } + + if (b.state === 'REQUESTED' && a.state !== 'REQUESTED') { + return -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, + currentUser: string, + head: GitHubRef | null +) { + if (!latestReviewCommitOid || !head || head.sha === latestReviewCommitOid) { + return; + } + + // Find the current user's most recent review. + let reviewIndex = -1; + let reviewTime: number | undefined; + for (let i = timelineEvents.length - 1; i >= 0; i--) { + if ( + timelineEvents[i].event === Common.EventType.Reviewed && + (timelineEvents[i] as Common.ReviewEvent).user.login === currentUser + ) { + reviewIndex = i; + const submittedAt = (timelineEvents[i] as Common.ReviewEvent).submittedAt; + reviewTime = submittedAt ? new Date(submittedAt).getTime() : undefined; + break; + } + } + + if (reviewIndex === -1) { + return; + } + + // Insert the marker just before the first commit that occurred AFTER the + // review (so commits pushed before the review, e.g. an attestation commit, + // stay in their chronological place). + let insertIndex = -1; + for (let i = reviewIndex + 1; i < timelineEvents.length; i++) { + if (timelineEvents[i].event === Common.EventType.Committed) { + const committedDate = (timelineEvents[i] as Common.CommitEvent).committedDate; + const committedTime = committedDate ? new Date(committedDate).getTime() : undefined; + if (reviewTime === undefined || committedTime === undefined || committedTime > reviewTime) { + insertIndex = i; + break; + } + } + } + + if (insertIndex === -1) { + // No commits occurred after the user's most recent review - skip the + // marker entirely. Placing it before the review event (e.g. next to a + // pre-review attestation commit) would contradict its "New changes + // since your last Review" label. + return; + } + + timelineEvents.splice(insertIndex, 0, { + id: latestReviewCommitOid, + event: Common.EventType.NewCommitsSinceReview + }); +} + +export function getPRFetchQuery(user: string, query: string): string { + const filter = query.replace(/\$\{user\}/g, user); + return `is:pull-request ${filter} type:pr`; +} + +/** + * Parse a GitHub search query for a state qualifier (e.g. `is:open`, `is:closed`, `is:merged`). + * Returns the corresponding GithubItemStateEnum if found, or undefined if no state is specified. + * GitHub's search API can return stale results that don't match the requested state, + * so callers can use this to post-filter. + */ +export function getStateFromQuery(query: string): GithubItemStateEnum | undefined { + const match = query.match(/(?:^|\s)is:(?open|closed|merged)(?:\s|$)/i); + if (!match?.groups?.state) { + return undefined; + } + switch (match.groups.state.toLowerCase()) { + case 'open': + return GithubItemStateEnum.Open; + case 'closed': + return GithubItemStateEnum.Closed; + case 'merged': + return GithubItemStateEnum.Merged; + } + return undefined; +} + +export function isInCodespaces(): boolean { + return vscode.env.remoteName === 'codespaces' && vscode.env.uiKind === vscode.UIKind.Web; +} + +export async function setEnterpriseUri(host: string) { + 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, ''); + if (config) { + let uri = vscode.Uri.parse(config, true); + if (uri.scheme === 'http') { + uri = uri.with({ scheme: 'https' }); + } + return uri; + } +} + +export function hasEnterpriseUri(): boolean { + return !!getEnterpriseUri(); +} + +export function generateGravatarUrl(gravatarId: string | undefined, size: number = 200): string | undefined { + return !!gravatarId ? `https://www.gravatar.com/avatar/${gravatarId}?s=${size}&d=retro` : 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) { + return vscode.Uri.parse(`https://${repo.remote.host}/${repo.remote.owner}/${repo.remote.repositoryName}/pulls`); +} + +export function getIssuesUrl(repo: GitHubRepository) { + return vscode.Uri.parse(`https://${repo.remote.host}/${repo.remote.owner}/${repo.remote.repositoryName}/issues`); +} + +export function sanitizeIssueTitle(title: string): string { + const regex = /[~^:;'".,~#?%*&[\]@\\{}()/]|\/\//g; + + return title.replace(regex, '').trim().substring(0, 150).replace(/\s+/g, '-'); +} + +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, +): string { + const withVariables = value.replace(VARIABLE_PATTERN, (match: string, variable: string, extra: string) => { + let result: string; + switch (variable) { + case 'user': + result = user ? user : match; + break; + case 'issueNumber': + result = issueModel ? `${issueModel.number}` : match; + break; + case 'issueNumberLabel': + result = issueModel ? `${getIssueNumberLabel(issueModel, defaults)}` : match; + break; + case 'issueTitle': + result = issueModel ? issueModel.title : match; + break; + case 'repository': + result = defaults ? defaults.repo : match; + break; + case 'owner': + result = defaults ? defaults.owner : match; + break; + case 'sanitizedIssueTitle': + result = issueModel ? sanitizeIssueTitle(issueModel.title) : match; // check what characters are permitted + break; + case 'sanitizedLowercaseIssueTitle': + result = issueModel ? sanitizeIssueTitle(issueModel.title).toLowerCase() : match; + break; + case 'issueType': + result = issueModel?.item.issueType ? issueModel.item.issueType : match; + break; + case 'sanitizedIssueType': + result = issueModel?.item.issueType ? sanitizeIssueTitle(issueModel.item.issueType) : match; + break; + case 'sanitizedLowercaseIssueType': + result = issueModel?.item.issueType ? sanitizeIssueTitle(issueModel.item.issueType).toLowerCase() : match; + break; + case 'today': + result = computeSinceValue(extra); + break; + default: + 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) { + const parsedIssue: ParsedIssue = { issueNumber: issue.number, owner: undefined, name: undefined }; + if ( + repo && + (repo.owner.toLowerCase() !== issue.remote.owner.toLowerCase() || + repo.repo.toLowerCase() !== issue.remote.repositoryName.toLowerCase()) + ) { + parsedIssue.owner = issue.remote.owner; + parsedIssue.name = issue.remote.repositoryName; + } + return getIssueNumberLabelFromParsed(parsedIssue); +} + +export function getIssueNumberLabelFromParsed(parsed: ParsedIssue) { + if (!parsed.owner || !parsed.name) { + return `#${parsed.issueNumber}`; + } else { + return `${parsed.owner}/${parsed.name}#${parsed.issueNumber}`; + } +} + +export function getOverrideBranch(): string | undefined { + 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; + } +} + +export async function findDotComAndEnterpriseRemotes(folderManagers: FolderRepositoryManager[]): Promise<{ dotComRemotes: Remote[], enterpriseRemotes: Remote[], unknownRemotes: Remote[] }> { + // Check if we have found any github.com remotes + const dotComRemotes: Remote[] = []; + const enterpriseRemotes: Remote[] = []; + const unknownRemotes: Remote[] = []; + for (const manager of folderManagers) { + for (const remote of await manager.computeAllGitHubRemotes()) { + if (remote.githubServerType === GitHubServerType.GitHubDotCom) { + dotComRemotes.push(remote); + } else if (remote.githubServerType === GitHubServerType.Enterprise) { + enterpriseRemotes.push(remote); + } + } + unknownRemotes.push(...await manager.computeAllUnknownRemotes()); + } + return { dotComRemotes, enterpriseRemotes, unknownRemotes }; +} + +export function vscodeDevPrLink(pullRequest: IssueModel) { + const itemUri = vscode.Uri.parse(pullRequest.html_url); + 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, + Unknown = 1 << 4 +} + +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; +} diff --git a/src/github/views.ts b/src/github/views.ts new file mode 100644 index 0000000000..091091291d --- /dev/null +++ b/src/github/views.ts @@ -0,0 +1,272 @@ +/*--------------------------------------------------------------------------------------------- + * 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, + IssueReference, + 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; + closingIssues?: IssueReference[]; + attestationCommitsEnabled?: boolean; +} + +export interface ProjectItemsReply { + projectItems: IProjectItem[] | undefined; +} + +export interface ChangeAssigneesReply { + assignees: IAccount[]; + events: TimelineEvent[]; +} + +export interface ChangeReviewersReply { + reviewers: ReviewState[]; +} + +export interface SubmitReviewReply { + events?: TimelineEvent[]; + /** + * Extra timeline events (e.g. a locally-constructed attestation commit) that the + * webview should append to its existing timeline immediately before the + * `reviewedEvent`. Lets callers avoid an extra `getTimelineEvents` round-trip when + * they already know what changed. + */ + additionalEvents?: TimelineEvent[]; + reviewedEvent: ReviewEvent | CommentEvent; + reviewers?: ReviewState[]; +} + +export interface SubmitReviewArgs { + body: string; + addAttestation?: boolean; +} + +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 FileUploadPlaceholder { + name: string; + placeholder: string; +} + +export interface UploadFilesReply { + uploads: FileUploadPlaceholder[]; +} + +export interface UploadPastedFilesArgs { + files: { name: string; type: string; bytesBase64: string }[]; +} + +export interface FileUploadCompletedMessage { + command: 'pr.file-upload-completed'; + name: string; + placeholder: string; + markdown?: string; + error?: string; +} + +export interface BaseContext { + 'preventDefaultContextMenuItems': true; + owner: string; + repo: string; + number: number; + [key: string]: boolean | string | number; +} + +export interface OverviewContext extends BaseContext { + 'github:checkoutMenu': true; +} + +export interface ReadyForReviewContext extends BaseContext { + 'github:readyForReviewMenu': true; +} + +export interface ReadyForReviewAndMergeContext extends ReadyForReviewContext { + 'github:readyForReviewMenuWithMerge': true; + mergeMethod: MergeMethod; +} + +export interface CodingAgentContext extends SessionLinkInfo { + 'preventDefaultContextMenuItems': true; + 'github:codingAgentMenu': true; + [key: string]: boolean | string | number | undefined; +} + +export interface ReviewCommentContext { + 'preventDefaultContextMenuItems': true; + 'github:reviewCommentMenu': true, + owner: string; + repo: string; + number: number; + body: string; + 'github:reviewCommentApprove'?: boolean; + 'github:reviewCommentApproveOnDotCom'?: boolean; + 'github:reviewCommentComment'?: boolean; + 'github:reviewCommentCommentEnabled'?: boolean; + 'github:reviewCommentRequestChanges'?: boolean; + 'github:reviewRequestChangesEnabled'?: boolean; + 'github:reviewCommentRequestChangesOnDotCom'?: boolean; +} + +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/github/worktree.ts b/src/github/worktree.ts new file mode 100644 index 0000000000..3c6cb7db0f --- /dev/null +++ b/src/github/worktree.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { FolderRepositoryManager } from './folderRepositoryManager'; +import { PullRequestModel } from './pullRequestModel'; +import { Repository } from '../api/api'; +import { commands } from '../common/executeCommands'; +import Logger from '../common/logger'; +import { ITelemetry } from '../common/telemetry'; + +const logId = 'Worktree'; + +/** + * Checks out a pull request in a new git worktree. + * @param telemetry Telemetry instance for tracking usage + * @param folderManager The folder repository manager + * @param pullRequestModel The pull request to checkout + * @param repository Optional repository to use (if not provided, uses folderManager.repository) + */ +export async function checkoutPRInWorktree( + telemetry: ITelemetry, + folderManager: FolderRepositoryManager, + pullRequestModel: PullRequestModel, + repository: Repository | undefined +): Promise { + // Validate that the PR has a valid head branch + if (!pullRequestModel.head) { + vscode.window.showErrorMessage(vscode.l10n.t('Unable to checkout pull request: missing head branch information.')); + return; + } + + const prHead = pullRequestModel.head; + const repositoryToUse = repository || folderManager.repository; + + /* __GDPR__ + "pr.checkoutInWorktree" : {} + */ + telemetry.sendTelemetryEvent('pr.checkoutInWorktree'); + + // Prepare for operations + const repoRootPath = repositoryToUse.rootUri.fsPath; + const parentDir = path.dirname(repoRootPath); + const repoFolderName = path.basename(repoRootPath); + const defaultWorktreePath = path.join(parentDir, `${repoFolderName}.worktrees`, `pr-${pullRequestModel.number}`); + const branchName = prHead.ref; + const remoteName = pullRequestModel.remote.remoteName; + + // Ask user for worktree location first (not in progress) + const worktreeUri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(defaultWorktreePath), + title: vscode.l10n.t('Select Worktree Location'), + saveLabel: vscode.l10n.t('Create Worktree'), + }); + + if (!worktreeUri) { + return; // User cancelled + } + + const worktreePath = worktreeUri.fsPath; + const trackedBranchName = `${remoteName}/${branchName}`; + + try { + // Check if the createWorktree API is available + if (!repositoryToUse.createWorktree) { + throw new Error(vscode.l10n.t('Git worktree API is not available. Please update VS Code to the latest version.')); + } + + // Start progress for fetch and worktree creation + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t('Creating worktree for Pull Request #{0}...', pullRequestModel.number), + }, + async () => { + // Fetch the PR branch first + await repositoryToUse.fetch({ remote: remoteName, ref: branchName }); + + // Check if the branch already exists locally + let branchExists = false; + try { + await repositoryToUse.getBranch(branchName); + branchExists = true; + } catch { + // Branch doesn't exist locally, we'll create it + branchExists = false; + } + + // Use the git extension's createWorktree API + // If branch already exists, don't specify the branch parameter to avoid "branch already exists" error + if (branchExists) { + await repositoryToUse.createWorktree!({ + path: worktreePath, + commitish: branchName + }); + } else { + await repositoryToUse.createWorktree!({ + path: worktreePath, + commitish: trackedBranchName, + branch: branchName + }); + } + } + ); + + // Ask user how they want to open the worktree (modal dialog) + const openInNewWindow = vscode.l10n.t('New Window'); + const openInCurrentWindow = vscode.l10n.t('Current Window'); + const result = await vscode.window.showInformationMessage( + vscode.l10n.t('Worktree created for Pull Request #{0}. How would you like to open it?', pullRequestModel.number), + { modal: true }, + openInNewWindow, + openInCurrentWindow + ); + + if (result === openInNewWindow) { + await commands.openFolder(worktreeUri, { forceNewWindow: true }); + } else if (result === openInCurrentWindow) { + await commands.openFolder(worktreeUri, { forceNewWindow: false }); + } + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + Logger.error(`Failed to create worktree: ${errorMessage}`, logId); + vscode.window.showErrorMessage(vscode.l10n.t('Failed to create worktree: {0}', errorMessage)); + } +} diff --git a/src/integrations/gitlens/gitlens.d.ts b/src/integrations/gitlens/gitlens.d.ts new file mode 100644 index 0000000000..531a68d1c0 --- /dev/null +++ b/src/integrations/gitlens/gitlens.d.ts @@ -0,0 +1,65 @@ +'use strict'; +import { Disposable } from 'vscode'; + +export { Disposable } from 'vscode'; + +export interface RemoteProvider { + readonly id: string; + readonly name: string; + readonly domain: string; +} + +export interface CreatePullRequestActionContext { + readonly type: 'createPullRequest'; + + readonly repoPath: string; + readonly branch: { + readonly name: string; + readonly upstream: string | undefined; + readonly isRemote: boolean; + }; + readonly remote: + | { + readonly name: string; + readonly provider?: RemoteProvider; + readonly url?: string; + } + | undefined; +} + +export interface OpenPullRequestActionContext { + readonly type: 'openPullRequest'; + + readonly repoPath: string; + readonly provider: RemoteProvider | undefined; + readonly pullRequest: { + readonly id: string; + readonly url: string; + }; +} + +export type ActionContext = CreatePullRequestActionContext | OpenPullRequestActionContext; +export type Action = T['type']; + +export interface ActionRunner { + /* + * A unique key to identify the extension/product/company to which the runner belongs + */ + readonly partnerId: string; + + /* + * A user-friendly name to which the runner belongs, i.e. your extension/product/company name. Will be shown, less prominently, to the user when offering this action + */ + readonly name: string; + + /* + * A user-friendly string which describes the action that will be taken. Will be shown to the user when offering this action + */ + readonly label: string | ((context: ActionContext) => string); + + run(context: ActionContext): void | Promise; +} + +export interface GitLensApi { + registerActionRunner(action: Action, runner: ActionRunner): Disposable; +} diff --git a/src/integrations/gitlens/gitlensImpl.ts b/src/integrations/gitlens/gitlensImpl.ts new file mode 100644 index 0000000000..a589aa1252 --- /dev/null +++ b/src/integrations/gitlens/gitlensImpl.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CreatePullRequestActionContext, GitLensApi } from './gitlens'; +import { Disposable } from '../../common/lifecycle'; + +export class GitLensIntegration extends Disposable { + private _extensionsDisposable: vscode.Disposable; + + constructor() { + super(); + this._extensionsDisposable = this._register(vscode.extensions.onDidChange(this.onExtensionsChanged, this)); + this.onExtensionsChanged(); + } + + private register(api: GitLensApi | undefined) { + if (!api) { + return; + } + this._register( + api.registerActionRunner('createPullRequest', { + partnerId: 'ghpr', + name: 'GitHub Pull Requests and Issues', + label: 'Create Pull Request', + run: function (context: CreatePullRequestActionContext) { + // For now only work with branches that aren't remote + if (context.branch.isRemote) { + return; + } + + vscode.commands.executeCommand('pr.create', { + repoPath: context.repoPath, + compareBranch: context.branch.name, + }); + }, + }), + ); + } + + private async onExtensionsChanged() { + const extension = + vscode.extensions.getExtension>('eamodio.gitlens') ?? + vscode.extensions.getExtension>('eamodio.gitlens-insiders'); + if (extension) { + this._extensionsDisposable.dispose(); + + if (extension.isActive) { + this.register(await extension.exports); + } else { + let count = 0; + // https://github.com/microsoft/vscode/issues/113783 -- since no event exists, poll + const handle = setInterval(async () => { + if (extension.isActive) { + clearInterval(handle); + + this.register(await extension.exports); + } else { + count++; + // Give up after 60 seconds + if (count > 60) { + clearInterval(handle); + } + } + }, 1000); + } + } + } +} diff --git a/src/issues/currentIssue.ts b/src/issues/currentIssue.ts index 5ae07292ec..2839991f30 100644 --- a/src/issues/currentIssue.ts +++ b/src/issues/currentIssue.ts @@ -3,23 +3,40 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; -import { IssueModel } from '../github/issueModel'; import * as vscode from 'vscode'; -import { ISSUES_CONFIGURATION, variableSubstitution, BRANCH_NAME_CONFIGURATION, BRANCH_CONFIGURATION, SCM_MESSAGE_CONFIGURATION, BRANCH_NAME_CONFIGURATION_DEPRECATED } from './util'; -import { Repository } from '../typings/git'; -import { StateManager, IssueState } from './stateManager'; +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'; -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, public readonly manager: FolderRepositoryManager, private stateManager: StateManager, remote?: Remote, private shouldPromptForBranch?: boolean) { + constructor( + private issueModel: IssueModel, + public readonly manager: FolderRepositoryManager, + private stateManager: StateManager, + remote?: Remote, + private shouldPromptForBranch?: boolean, + ) { + super(); this.setRepo(remote ?? this.issueModel.githubRepository.remote); } @@ -28,8 +45,12 @@ export class CurrentIssue { const repo = this.stateManager.gitAPI.repositories[i]; for (let j = 0; j < repo.state.remotes.length; j++) { const remote = repo.state.remotes[j]; - if (remote.name === repoRemote?.remoteName && - (remote.fetchUrl?.toLowerCase().search(`${repoRemote.owner.toLowerCase()}/${repoRemote.repositoryName.toLowerCase()}`) !== -1)) { + if ( + remote.name === repoRemote?.remoteName && + remote.fetchUrl + ?.toLowerCase() + .search(`${repoRemote.owner.toLowerCase()}/${repoRemote.repositoryName.toLowerCase()}`) !== -1 + ) { this.repo = repo; return; } @@ -49,31 +70,49 @@ export class CurrentIssue { return this.issueModel; } - public async startWorking(): Promise { + public async startWorking(silent: boolean = false): Promise { try { this._repoDefaults = await this.manager.getPullRequestDefaults(); - if (await this.createIssueBranch()) { + if (await this.createIssueBranch(silent)) { await this.setCommitMessageAndGitEvent(); this._onDidChangeCurrentIssueState.fire(); + const login = (await this.manager.getCurrentUser(this.issueModel.githubRepository)).login; + if ( + 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. + if (this.manager.gitHubRepositories.find( + r => r.remote.owner === this.issueModel.remote.owner && r.remote.repositoryName === this.issueModel.remote.repositoryName, + )) { + await this.manager.assignIssue(this.issueModel, login); + } + await this.stateManager.refresh(this.manager); + } return true; } } catch (e) { // leave repoDefaults undefined - vscode.window.showErrorMessage('There is no remote. Can\'t start working on an issue.'); + vscode.window.showErrorMessage(vscode.l10n.t('There is no remote. Can\'t start working on an issue.')); } 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(); @@ -83,62 +122,52 @@ export class CurrentIssue { return `${user}/issue${this.issueModel.number}`; } - private async branchExists(branch: string): Promise { + private async getBranch(branch: string): Promise { try { - const repoBranch = await this.manager.repository.getBranch(branch); - return !!repoBranch; + return await this.manager.repository.getBranch(branch); } catch (e) { // branch doesn't exist } - return false; + return undefined; } private async createOrCheckoutBranch(branch: string): Promise { try { - if (await this.branchExists(branch)) { + if (await this.getBranch(branch)) { await this.manager.repository.checkout(branch); } else { await this.manager.repository.createBranch(branch, true); } return true; } catch (e) { - vscode.window.showErrorMessage(`Unable to checkout branch ${branch}. There may be file conflicts that prevent this branch change. Git error: ${e.error}`); + if (e.message !== 'User aborted') { + vscode.window.showErrorMessage( + `Unable to checkout branch ${branch}. There may be file conflicts that prevent this branch change. Git error: ${e.error}`, + ); + } return false; } } 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; } - // TODO: #1972 Delete the deprecated setting - private async ensureBranchTitleConfigMigrated(): Promise { - const configuration = vscode.workspace.getConfiguration(ISSUES_CONFIGURATION); - const deprecatedConfigInspect = configuration.inspect(BRANCH_NAME_CONFIGURATION_DEPRECATED); - async function migrate(value: any, target: vscode.ConfigurationTarget) { - await configuration.update(BRANCH_NAME_CONFIGURATION, value, target); - await configuration.update(BRANCH_NAME_CONFIGURATION_DEPRECATED, undefined, target); - } - if (deprecatedConfigInspect?.globalValue) { - await migrate(deprecatedConfigInspect.globalValue, vscode.ConfigurationTarget.Global); - } - if (deprecatedConfigInspect?.workspaceValue) { - await migrate(deprecatedConfigInspect.workspaceValue, vscode.ConfigurationTarget.Workspace); - } - if (deprecatedConfigInspect?.workspaceFolderValue) { - await migrate(deprecatedConfigInspect.workspaceFolderValue, vscode.ConfigurationTarget.WorkspaceFolder); - } - return vscode.workspace.getConfiguration(ISSUES_CONFIGURATION).get(BRANCH_NAME_CONFIGURATION) ?? this.getBasicBranchName(await this.getUser()); + private async getBranchTitle(): Promise { + return ( + vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(ISSUE_BRANCH_TITLE) ?? + this.getBasicBranchName(await this.getUser()) + ); } private validateBranchName(branch: string): string | undefined { const VALID_BRANCH_CHARACTERS = /[^ \\@\~\^\?\*\[]+/; const match = branch.match(VALID_BRANCH_CHARACTERS); if (match && match.length > 0 && match[0] !== branch) { - return 'Branch name cannot contain a space or the following characters: \\@~^?*['; + return vscode.l10n.t('Branch name cannot contain a space or the following characters: \\@~^?*['); } return undefined; } @@ -147,49 +176,92 @@ export class CurrentIssue { const editSetting = `Edit Setting`; vscode.window.showErrorMessage(error, editSetting).then(result => { if (result === editSetting) { - return vscode.commands.executeCommand('workbench.action.openSettings', `${ISSUES_CONFIGURATION}.${BRANCH_NAME_CONFIGURATION}`); + vscode.commands.executeCommand( + 'workbench.action.openSettings', + `${ISSUES_SETTINGS_NAMESPACE}.${ISSUE_BRANCH_TITLE}`, + ); } }); } - private async createIssueBranch(): Promise { - const createBranchConfig = this.shouldPromptForBranch ? 'prompt' : vscode.workspace.getConfiguration(ISSUES_CONFIGURATION).get(BRANCH_CONFIGURATION); + 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, 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'); + const createBranch = vscode.l10n.t('Create New Branch'); + const createNew = await vscode.window.showInformationMessage(pr.model.state === GithubItemStateEnum.Merged ? mergedMessage : closedMessage, + { + modal: true + }, createBranch); + if (createNew === createBranch) { + const number = (branchNameMatch?.length === 4 ? (Number(branchNameMatch[3]) + 1) : 1); + return `${branchNameConfig}_${number}`; + } + } + return branchNameConfig; + } + + private async createIssueBranch(silent: boolean): Promise { + const createBranchConfig = this.shouldPromptForBranch + ? 'prompt' + : 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; - if (!this._branchName) { - const branchNameConfig = await variableSubstitution(await this.ensureBranchTitleConfigMigrated(), this.issue, undefined, await this.getUser()); - if (createBranchConfig === 'on') { - const validateBranchName = this.validateBranchName(branchNameConfig); - if (validateBranchName) { - this.showBranchNameError(validateBranchName); - return false; + const branchNameConfig = variableSubstitution( + await this.getBranchTitle(), + this.issue, + undefined, + await this.getUser(), + ); + const branchNameMatch = this._branchName?.match(new RegExp('^(' + escapeRegExp(branchNameConfig) + ')(_)?(\\d*)')); + if ((createBranchConfig === 'on')) { + const branch = await this.getBranch(this._branchName!); + if (!branch) { + if (!branchNameMatch) { + this._branchName = branchNameConfig; } - this._branchName = branchNameConfig; - } else { - this._branchName = await vscode.window.showInputBox({ value: branchNameConfig, prompt: 'Enter the label for the new branch.' }); + } else if (!silent) { + this._branchName = await this.offerNewBranch(branch, branchNameConfig, branchNameMatch); } } + if (!this._branchName) { + this._branchName = await vscode.window.showInputBox({ + value: branchNameConfig, + prompt: vscode.l10n.t('Enter the label for the new branch.'), + }); + } if (!this._branchName) { // user has cancelled return false; } + const validateBranchName = this.validateBranchName(this._branchName); + if (validateBranchName) { + this.showBranchNameError(validateBranchName); + return false; + } + state.branch = this._branchName; - this.stateManager.setSavedIssueState(this.issueModel, state); - if (!await this.createOrCheckoutBranch(this._branchName)) { + await this.stateManager.setSavedIssueState(this.issueModel, state); + if (!(await this.createOrCheckoutBranch(this._branchName))) { this._branchName = undefined; + return false; } return true; } 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); } + return undefined; } private async setCommitMessageAndGitEvent() { @@ -199,4 +271,4 @@ export class CurrentIssue { } return; } -} \ No newline at end of file +} diff --git a/src/issues/issueCompletionProvider.ts b/src/issues/issueCompletionProvider.ts index 82e83d6b2a..63bd4a527d 100644 --- a/src/issues/issueCompletionProvider.ts +++ b/src/issues/issueCompletionProvider.ts @@ -1,122 +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 { issueMarkdown, ISSUES_CONFIGURATION, variableSubstitution, getIssueNumberLabel, isComment, getRootUriFromScmInputUri } from './util'; -import { StateManager } from './stateManager'; -import { IssueModel } from '../github/issueModel'; -import { IMilestone } from '../github/interface'; -import { MilestoneModel } from '../github/milestoneModel'; -import { PullRequestDefaults } from '../github/folderRepositoryManager'; -import { RepositoriesManager } from '../github/repositoriesManager'; - -class IssueCompletionItem extends vscode.CompletionItem { - constructor(public readonly issue: IssueModel) { - super(`${issue.number}: ${issue.title}`, vscode.CompletionItemKind.Issue); - } -} - -export class IssueCompletionProvider implements vscode.CompletionItemProvider { - - constructor(private stateManager: StateManager, private repositoriesManager: RepositoriesManager, private context: vscode.ExtensionContext) { } - - async provideCompletionItems(document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken, context: vscode.CompletionContext): Promise { - // If the suggest was not triggered by the trigger character, require that the previous character be the trigger character - if ((document.languageId !== 'scminput') && (position.character > 0) && (context.triggerKind === vscode.CompletionTriggerKind.Invoke) && !document.getText(document.getWordRangeAtPosition(position)).match(/#[0-9]*$/)) { - return []; - } - // It's common in markdown to start a line with #s and not want an completion - if ((position.character <= 6) && (document.languageId === 'markdown') && (document.getText(new vscode.Range(position.with(undefined, 0), position)) === new Array(position.character + 1).join('#'))) { - return []; - } - - if ((context.triggerKind === vscode.CompletionTriggerKind.TriggerCharacter) && - (vscode.workspace.getConfiguration(ISSUES_CONFIGURATION).get('ignoreCompletionTrigger', [])).find(value => value === document.languageId)) { - return []; - } - - if ((document.languageId !== 'scminput') && !(await isComment(document, position))) { - return []; - } - - let range: vscode.Range = new vscode.Range(position, position); - if (position.character - 1 >= 0) { - const wordAtPos = document.getText(new vscode.Range(position.translate(0, -1), position)); - if (wordAtPos === '#') { - range = new vscode.Range(position.translate(0, -1), position); - } - } - - await this.stateManager.tryInitializeAndWait(); - - const completionItems: Map = new Map(); - const now = new Date(); - let repo: PullRequestDefaults | undefined; - const uri = (document.languageId === 'scminput') ? getRootUriFromScmInputUri(document.uri) : document.uri; - if (!uri) { - return []; - } - - try { - repo = await (await this.repositoriesManager.getManagerForFile(uri))?.getPullRequestDefaults(); - } catch (e) { - // leave repo undefined - } - const issueData = this.stateManager.getIssueCollection(uri); - for (const issueQuery of issueData) { - const issuesOrMilestones: IssueModel[] | MilestoneModel[] = await issueQuery[1] ?? []; - if (issuesOrMilestones.length === 0) { - continue; - } - if (issuesOrMilestones[0] instanceof IssueModel) { - let index = 0; - for (const issue of issuesOrMilestones) { - completionItems.set(getIssueNumberLabel(issue), await this.completionItemFromIssue(repo, issue, now, range, document, index++)); - } - } else { - for (let index = 0; index < issuesOrMilestones.length; index++) { - const value: MilestoneModel = issuesOrMilestones[index]; - for (const issue of value.issues) { - completionItems.set(getIssueNumberLabel(issue), await this.completionItemFromIssue(repo, issue, now, range, document, index, value.milestone)); - } - } - } - } - return [...completionItems.values()]; - } - - private async completionItemFromIssue(repo: PullRequestDefaults | undefined, issue: IssueModel, now: Date, range: vscode.Range, document: vscode.TextDocument, index: number, milestone?: IMilestone): Promise { - const item: IssueCompletionItem = new IssueCompletionItem(issue); - if (document.languageId === 'markdown') { - item.insertText = `[${getIssueNumberLabel(issue, repo)}](${issue.html_url})`; - } else { - const configuration = vscode.workspace.getConfiguration(ISSUES_CONFIGURATION).get('issueCompletionFormatScm'); - if (document.uri.path.match(/scm\/git\/scm\d\/input/) && (typeof configuration === 'string')) { - item.insertText = await variableSubstitution(configuration, issue, repo); - } else { - item.insertText = `${getIssueNumberLabel(issue, repo)}`; - } - } - item.documentation = issue.body; - item.range = range; - item.detail = milestone ? milestone.title : issue.milestone?.title; - let updatedAt: string = (now.getTime() - new Date(issue.updatedAt).getTime()).toString(); - updatedAt = (new Array(20 - updatedAt.length).join('0')) + updatedAt; - item.sortText = `${index} ${updatedAt}`; - item.filterText = `${item.detail} # ${issue.number} ${issue.title} ${item.documentation}`; - return item; - } - - resolveCompletionItem(item: vscode.CompletionItem, token: vscode.CancellationToken): vscode.CompletionItem { - if (item instanceof IssueCompletionItem) { - item.documentation = issueMarkdown(item.issue, this.context); - item.command = { - command: 'issues.issueCompletion', - title: 'Issue Completion Chose,' - }; - } - return item; - } -} \ 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 * 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 { issueMarkdown } from '../github/markdownUtils'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { getIssueNumberLabel, variableSubstitution } from '../github/utils'; + +class IssueCompletionItem extends vscode.CompletionItem { + constructor(public readonly issue: IssueModel) { + super(`${issue.number}: ${issue.title}`, vscode.CompletionItemKind.Issue); + } +} + +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, + private repositoriesManager: RepositoriesManager, + private context: vscode.ExtensionContext, + ) { } + + async provideCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken, + context: vscode.CompletionContext, + ): Promise { + let wordRange = document.getWordRangeAtPosition(position); + let wordAtPos = wordRange ? document.getText(wordRange) : undefined; + if (!wordRange || wordAtPos?.charAt(0) !== '#') { + const start = wordRange?.start ?? position; + const testWordRange = new vscode.Range(start.translate(undefined, start.character ? -1 : 0), position); + const testWord = document.getText(testWordRange); + if (testWord.charAt(0) === '#') { + wordRange = testWordRange; + wordAtPos = testWord; + } + } + + // 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 !== 'comment' && + position.character > 0 && + context.triggerKind === vscode.CompletionTriggerKind.Invoke && + !wordAtPos?.match(/#[0-9]*$/) + ) { + return []; + } + // It's common in markdown to start a line with #s and not want an completion + if ( + position.character <= 6 && + document.languageId === 'markdown' && + (document.getText(new vscode.Range(position.with(undefined, 0), position)) === + new Array(position.character + 1).join('#')) && + document.uri.scheme !== 'comment' && + context.triggerKind === vscode.CompletionTriggerKind.TriggerCharacter + ) { + return []; + } + + if ( + context.triggerKind === vscode.CompletionTriggerKind.TriggerCharacter && + vscode.workspace + .getConfiguration(ISSUES_SETTINGS_NAMESPACE) + .get(IGNORE_COMPLETION_TRIGGER, []) + .find(value => value === document.languageId) + ) { + return []; + } + + const isPositionComment = document.languageId === 'plaintext' || document.languageId === 'markdown' || await isComment(document, position); + + if ((document.languageId !== 'scminput') && (document.languageId !== 'git-commit') && !isPositionComment) { + return []; + } + + let range: vscode.Range = new vscode.Range(position, position); + if (position.character - 1 >= 0) { + if (wordRange && ((wordAtPos?.charAt(0) === '#') || (document.languageId === 'scminput') || (document.languageId === 'git-commit'))) { + range = wordRange; + } + } + + // 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') { + uri = getRootUriFromScmInputUri(document.uri); + } else if ((document.uri.scheme === 'comment') && vscode.workspace.workspaceFolders?.length) { + for (const visibleEditor of vscode.window.visibleTextEditors) { + const testFolderUri = vscode.workspace.workspaceFolders[0].uri.with({ path: visibleEditor.document.uri.path }); + const workspace = vscode.workspace.getWorkspaceFolder(testFolderUri); + if (workspace) { + uri = workspace.uri; + break; + } + } + } else { + uri = document.uri.scheme === Schemes.NewIssue + ? fromNewIssueUri(document.uri)?.originUri ?? document.uri + : document.uri; + } + if (!uri) { + return []; + } + + let folderManager: FolderRepositoryManager | undefined; + try { + folderManager = this.repositoriesManager.getManagerForFile(uri); + repo = await folderManager?.getPullRequestDefaults(); + } catch (e) { + // leave repo undefined + } + const issueData = this.stateManager.getIssueCollection(folderManager?.repository.rootUri ?? uri); + let sortNumber = 0; + + // Process queries in order to maintain query priority + for (const issueQuery of issueData) { + const issuesOrMilestones: IssueQueryResult = await issueQuery[1]; + if ((issuesOrMilestones.issues ?? []).length === 0) { + continue; + } + for (const issue of (issuesOrMilestones.issues ?? [])) { + if (filterOwnerAndRepo && ((issue as IssueModel).remote.owner !== filterOwnerAndRepo.owner || (issue as IssueModel).remote.repositoryName !== filterOwnerAndRepo.repo)) { + continue; + } + 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); + } + } + } + + // 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, + range: vscode.Range, + document: vscode.TextDocument, + milestone?: IMilestone, + ): Promise { + const item: IssueCompletionItem = new IssueCompletionItem(issue); + if (document.languageId === 'markdown') { + item.insertText = `[${getIssueNumberLabel(issue, repo)}](${issue.html_url})`; + } else { + const configuration = vscode.workspace + .getConfiguration(ISSUES_SETTINGS_NAMESPACE) + .get(ISSUE_COMPLETION_FORMAT_SCM); + if (document.uri.path.match(/git\/scm\d\/input/) && typeof configuration === 'string') { + item.insertText = variableSubstitution(configuration, issue, repo); + } else { + item.insertText = `${getIssueNumberLabel(issue, repo)}`; + } + } + item.documentation = issue.body; + item.range = range; + item.detail = milestone ? milestone.title : issue.milestone?.title; + item.filterText = `${item.detail} # ${issue.number} ${issue.title} ${item.documentation}`; + return item; + } + + async resolveCompletionItem( + item: vscode.CompletionItem, + _token: vscode.CancellationToken, + ): Promise { + if (item instanceof IssueCompletionItem) { + item.documentation = await issueMarkdown(item.issue, this.context, this.repositoriesManager); + item.command = { + command: 'issues.issueCompletion', + title: vscode.l10n.t('Issue Completion Choose,'), + }; + } + return item; + } +} diff --git a/src/issues/issueFeatureRegistrar.ts b/src/issues/issueFeatureRegistrar.ts index b8c891461d..c0edf0e1e7 100644 --- a/src/issues/issueFeatureRegistrar.ts +++ b/src/issues/issueFeatureRegistrar.ts @@ -3,336 +3,803 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { PullRequestDefaults, FolderRepositoryManager } from '../github/folderRepositoryManager'; +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 { UserHoverProvider } from './userHoverProvider'; +import { openCodeLink } from './issueLinkLookup'; +import { IssuesTreeData, QueryNode, updateExpandedQueries } from './issuesView'; import { IssueTodoProvider } from './issueTodoProvider'; -import { IssueCompletionProvider } from './issueCompletionProvider'; -import { NewIssue, createGithubPermalink, USER_EXPRESSION, ISSUES_CONFIGURATION, QUERIES_CONFIGURATION, pushAndCreatePR } from './util'; -import { UserCompletionProvider } from './userCompletionProvider'; +import { ShareProviderManager } from './shareProviders'; import { StateManager } from './stateManager'; -import { IssuesTreeData } from './issuesView'; -import { IssueModel } from '../github/issueModel'; -import { CurrentIssue } from './currentIssue'; -import { ReviewManager } from '../view/reviewManager'; -import { GitAPI } from '../typings/git'; -import { Resource } from '../common/resources'; -import { IssueFileSystemProvider, NEW_ISSUE_SCHEME, ASSIGNEES, LABELS, LabelCompletionProvider, NEW_ISSUE_FILE, extractIssueOriginFromQuery } from './issueFile'; -import { ITelemetry } from '../common/telemetry'; +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 { 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'; -const ISSUE_COMPLETIONS_CONFIGURATION = 'issueCompletions.enabled'; -const USER_COMPLETIONS_CONFIGURATION = 'userCompletions.enabled'; +export class IssueFeatureRegistrar extends Disposable { + private static readonly ID = 'IssueFeatureRegistrar'; + private _newIssueCache: NewIssueCache; -export class IssueFeatureRegistrar implements vscode.Disposable { - private _stateManager: StateManager; - private createIssueInfo: { document: vscode.TextDocument, newIssue: NewIssue | undefined, lineNumber: number | undefined, insertIndex: number | undefined } | undefined; + private createIssueInfo: + | { + document: vscode.TextDocument; + newIssue: NewIssue | undefined; + lineNumber: number | undefined; + insertIndex: number | undefined; + } + | undefined; - constructor(private gitAPI: GitAPI, private manager: RepositoriesManager, private reviewManagers: ReviewManager[], private context: vscode.ExtensionContext, private telemetry: ITelemetry) { - this._stateManager = new StateManager(gitAPI, this.manager, this.context); + constructor( + private gitAPI: GitApiImpl, + private manager: RepositoriesManager, + private reviewsManager: ReviewsManager, + private context: vscode.ExtensionContext, + private telemetry: ITelemetry, + private readonly _stateManager: StateManager + ) { + super(); + this._newIssueCache = new NewIssueCache(context); } async initialize() { - this.context.subscriptions.push(vscode.workspace.registerFileSystemProvider(NEW_ISSUE_SCHEME, new IssueFileSystemProvider())); - this.registerCompletionProviders(); - 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__ + 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.context.subscriptions.push(vscode.commands.registerCommand('issue.createIssueFromClipboard', () => { - /* __GDPR__ + 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.context.subscriptions.push(vscode.commands.registerCommand('issue.copyGithubPermalink', () => { - /* __GDPR__ + 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)); - this.context.subscriptions.push(vscode.commands.registerCommand('issue.openGithubPermalink', () => { - /* __GDPR__ + 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)); - this.context.subscriptions.push(vscode.commands.registerCommand('issue.openIssue', (issueModel: any) => { - /* __GDPR__ + 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.context.subscriptions.push(vscode.commands.registerCommand('issue.startWorking', (issue: any) => { - /* __GDPR__ + 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.context.subscriptions.push(vscode.commands.registerCommand('issue.startWorkingBranchDescriptiveTitle', (issue: any) => { - /* __GDPR__ + 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.context.subscriptions.push(vscode.commands.registerCommand('issue.continueWorking', (issue: any) => { - /* __GDPR__ + 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.context.subscriptions.push(vscode.commands.registerCommand('issue.startWorkingBranchPrompt', (issueModel: any) => { - /* __GDPR__ + 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.context.subscriptions.push(vscode.commands.registerCommand('issue.stopWorking', (issueModel: any) => { - /* __GDPR__ + 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.context.subscriptions.push(vscode.commands.registerCommand('issue.stopWorkingBranchDescriptiveTitle', (issueModel: any) => { - /* __GDPR__ + 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.context.subscriptions.push(vscode.commands.registerCommand('issue.statusBar', () => { - /* __GDPR__ + 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.context.subscriptions.push(vscode.commands.registerCommand('issue.copyIssueNumber', (issueModel: any) => { - /* __GDPR__ + 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.context.subscriptions.push(vscode.commands.registerCommand('issue.copyIssueUrl', (issueModel: any) => { - /* __GDPR__ + 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.context.subscriptions.push(vscode.commands.registerCommand('issue.refresh', () => { - /* __GDPR__ + 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.context.subscriptions.push(vscode.commands.registerCommand('issue.suggestRefresh', () => { - /* __GDPR__ + 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.context.subscriptions.push(vscode.commands.registerCommand('issue.getCurrent', () => { - /* __GDPR__ + 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.context.subscriptions.push(vscode.commands.registerCommand('issue.editQuery', (query: vscode.TreeItem) => { - /* __GDPR__ + 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 this.editQuery(query); - }, this)); - this.context.subscriptions.push(vscode.commands.registerCommand('issue.createIssue', () => { - /* __GDPR__ + 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.context.subscriptions.push(vscode.commands.registerCommand('issue.createIssueFromFile', () => { - /* __GDPR__ + 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'); - return this.createIssueFromFile(); - }, this)); - this.context.subscriptions.push(vscode.commands.registerCommand('issue.issueCompletion', () => { - /* __GDPR__ + 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.context.subscriptions.push(vscode.commands.registerCommand('issue.userCompletion', () => { - /* __GDPR__ + this.telemetry.sendTelemetryEvent('issue.issueCompletion'); + }), + ); + this._register( + 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.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('Summarize issue {0}/{1}#{2}', issue.remote.owner, issue.remote.repositoryName, issue.number) }); + commands.executeCommand(commands.SHOW_CHAT); + } else { + const pullRequestModel = issue.pullRequestModel; + const remote = pullRequestModel.githubRepository.remote; + commands.executeCommand(commands.NEW_CHAT, { inputValue: vscode.l10n.t('Summarize pull request {0}/{1}#{2}', remote.owner, remote.repositoryName, pullRequestModel.number) }); + commands.executeCommand(commands.SHOW_CHAT); + } + }), + ); + 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('Find a fix for issue {0}/{1}#{2}', issue.remote.owner, issue.remote.repositoryName, issue.number) }); + commands.executeCommand(commands.SHOW_CHAT); + }), + ); + this._register(vscode.commands.registerCommand('issues.configureIssuesViewlet', async () => { + /* __GDPR__ + "issues.configureIssuesViewlet" : {} + */ + return vscode.commands.executeCommand( + 'workbench.action.openSettings', + `@ext:${EXTENSION_ID} issues`, + ); })); - return this._stateManager.tryInitializeAndWait().then(() => { - 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))); + 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._register( + vscode.languages.registerCodeActionsProvider('*', todoProvider, { 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: '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: '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: '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 - } - ]; + 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_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)); + 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; } - break; } - } - })); + }), + ); } async createIssue() { - return this.makeNewIssueFile(vscode.window.activeTextEditor?.document.uri); - } + let uri = vscode.window.activeTextEditor?.document.uri; + let folderManager: FolderRepositoryManager | undefined = uri ? this.manager.getManagerForFile(uri) : undefined; - async createIssueFromFile() { - let text: string; - if (!vscode.window.activeTextEditor || (vscode.window.activeTextEditor.document.uri.scheme !== NEW_ISSUE_SCHEME)) { - return; + 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; } - 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) { + if (!folderManager || !uri) { 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(); + + 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; } - } - 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 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; } - const body = text; - if (!title || !body) { - 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'); + + 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 editQuery(query: vscode.TreeItem) { - const config = vscode.workspace.getConfiguration(ISSUES_CONFIGURATION); - 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'; + async createIssueFromFile() { + const metadata = await extractMetadataFromFile(this.manager); + if (!metadata || !vscode.window.activeTextEditor) { + return; } - await vscode.commands.executeCommand(command); - const editor = vscode.window.activeTextEditor; - if (editor) { - const text = editor.document.getText(); - const search = text.search(query.label!); - if (search >= 0) { - const position = editor.document.positionAt(search); - editor.revealRange(new vscode.Range(position, position)); - editor.selection = new vscode.Selection(position, position); - } + 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(); } } @@ -340,8 +807,13 @@ export class IssueFeatureRegistrar implements vscode.Disposable { // 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 { + owner: currentIssues[0].issue.remote.owner, + repo: currentIssues[0].issue.remote.repositoryName, + number: currentIssues[0].issue.number, + }; } + return undefined; } refreshView() { @@ -358,31 +830,84 @@ export class IssueFeatureRegistrar implements vscode.Disposable { if (issueModel instanceof IssueModel) { return vscode.env.openExternal(vscode.Uri.parse(issueModel.html_url)); } + return undefined; } - async doStartWorking(issueModel: IssueModel, needsBranchPrompt?: boolean) { - const repoManager = this.manager.getManagerForIssueModel(issueModel); + async doStartWorking( + matchingRepoManager: FolderRepositoryManager | undefined, + issueModel: IssueModel, + needsBranchPrompt?: boolean, + ) { + let repoManager = matchingRepoManager; + let githubRepository = issueModel.githubRepository; + let remote = issueModel.remote; if (!repoManager) { - return; + 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(issueModel); + const remoteNameResult = await repoManager.findUpstreamForItem({ githubRepository, remote }); if (remoteNameResult.needsFork) { - if ((await repoManager.tryOfferToFork(issueModel.githubRepository)) === undefined) { + if ((await repoManager.tryOfferToFork(githubRepository)) === undefined) { return; } } - await this._stateManager.setCurrentIssue(repoManager, new CurrentIssue(issueModel, repoManager, this._stateManager, remoteNameResult.remote, needsBranchPrompt)); + // 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; - } - const folderManager = this.manager.getManagerForIssueModel(issue); - if (folderManager) { - this.doStartWorking(issue); + 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); + } + } } } @@ -390,50 +915,57 @@ export class IssueFeatureRegistrar implements vscode.Disposable { if (!(issueModel instanceof IssueModel)) { return; } - const folderManager = this.manager.getManagerForIssueModel(issueModel); - if (folderManager) { - this.doStartWorking(issueModel, true); - } + this.doStartWorking(this.manager.getManagerForIssueModel(issueModel), issueModel, true); } async stopWorking(issueModel: any) { - const folderManager = this.manager.getManagerForIssueModel(issueModel); + let folderManager = this.manager.getManagerForIssueModel(issueModel); if (!folderManager) { - return; + 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); + 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 = `$(globe) Open #${currentIssue.issue.number} ${currentIssue.issue.title}`; - const pullRequestText: string = `$(git-pull-request) Create pull request for #${currentIssue.issue.number} (pushes branch)`; - const draftPullRequestText: string = `$(comment-discussion) Create draft pull request for #${currentIssue.issue.number} (pushes branch)`; + 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 = `$(primitive-square) Stop working on #${currentIssue.issue.number}`; - const choices = currentIssue.branchName && defaults ? [openIssueText, pullRequestText, draftPullRequestText, stopWorkingText] : [openIssueText, pullRequestText, draftPullRequestText, stopWorkingText]; - const response: string | undefined = await vscode.window.showQuickPick(choices, { placeHolder: 'Current issue options' }); + 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 openIssueText: + return this.openIssue(currentIssue.issue); case pullRequestText: { - const reviewManager = ReviewManager.getReviewManagerForFolderManager(this.reviewManagers, currentIssue.manager); + const reviewManager = ReviewManager.getReviewManagerForFolderManager( + this.reviewsManager.reviewManagers, + currentIssue.manager, + ); if (reviewManager) { return pushAndCreatePR(currentIssue.manager, reviewManager, this._stateManager); } + break; } - case draftPullRequestText: { - const reviewManager = ReviewManager.getReviewManagerForFolderManager(this.reviewManagers, currentIssue.manager); - if (reviewManager) { - return pushAndCreatePR(currentIssue.manager, reviewManager, this._stateManager, true); - } - } - case stopWorkingText: return this._stateManager.setCurrentIssue(currentIssue.manager, undefined); + case stopWorkingText: + return this._stateManager.setCurrentIssue(currentIssue.manager, undefined, true); } } @@ -447,8 +979,8 @@ export class IssueFeatureRegistrar implements vscode.Disposable { } const choices: IssueChoice[] = currentIssues.map(currentIssue => { return { - label: `#${currentIssue.issue.number} from ${currentIssue.issue.githubRepository.remote.owner}/${currentIssue.issue.githubRepository.remote.repositoryName}`, - currentIssue + 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); @@ -467,24 +999,51 @@ export class IssueFeatureRegistrar implements vscode.Disposable { 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 assignee: string[] | undefined; + let assignees: string[] | undefined; let issueGenerationText: string | undefined; if (!newIssue && vscode.window.activeTextEditor) { document = vscode.window.activeTextEditor.document; @@ -494,35 +1053,65 @@ export class IssueFeatureRegistrar implements vscode.Disposable { 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); + 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]]; + 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 = issueBody || newIssue?.document.isUntitled ? issueBody : (await createGithubPermalink(this.gitAPI, newIssue)).permalink; + const body: string | undefined = await this.createTodoIssueBody(newIssue, issueBody); const quickInput = vscode.window.createInputBox(); quickInput.value = titlePlaceholder ?? ''; - quickInput.prompt = 'Set the issue title. Confirm to create the issue now or use the edit button to edit the issue title and description.'; - quickInput.title = 'Create Issue'; + 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: { - light: Resource.icons.light.Edit, - dark: Resource.icons.dark.Edit - }, - tooltip: 'Edit Description' - } + 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); + await this.doCreateIssue(document, newIssue, title, body, assignees, undefined, undefined, undefined, lineNumber, insertIndex); quickInput.busy = false; } quickInput.hide(); @@ -532,46 +1121,103 @@ export class IssueFeatureRegistrar implements vscode.Disposable { quickInput.busy = true; this.createIssueInfo = { document, newIssue, lineNumber, insertIndex }; - this.makeNewIssueFile(document.uri, title, body, assignee); + await this.makeNewIssueFile(document.uri, { title, body, assignees }); quickInput.busy = false; quickInput.hide(); }); quickInput.show(); + + return undefined; } - private async makeNewIssueFile(originUri?: vscode.Uri, title?: string, body?: string, assignees?: string[] | undefined) { - const query = originUri ? `?{"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) { + 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} ${assignees && assignees.length > 0 ? assignees.map(value => '@' + value).join(', ') + ' ' : ''}`; - const labelLine = `${LABELS} `; - const text = - `${title ?? 'Issue Title'}\n + 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}\n -${body ?? ''}\n -`; +${labelLine} +${milestoneLine} +${projectsLine}\n +${options?.body ?? ''}\n +`; await vscode.workspace.fs.writeFile(bodyPath, this.stringToUint8Array(text)); - const assigneesDecoration = vscode.window.createTextEditorDecorationType({ after: { contentText: ' Comma-separated usernames, either @username or just username.', fontStyle: 'italic', color: new vscode.ThemeColor('issues.newIssueDecoration') } }); - const labelsDecoration = vscode.window.createTextEditorDecorationType({ after: { contentText: ' 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); + 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(2, 0), new vscode.Position(2, assigneeFullLine.text.length))]); + textEditor.setDecorations(assigneesDecoration, [ + new vscode.Range( + new vscode.Position(assigneeLineNum, 0), + new vscode.Position(assigneeLineNum, assigneeFullLine.text.length), + ), + ]); } - const labelFullLine = textEditor.document.lineAt(3); + const labelFullLine = textEditor.document.lineAt(labelLineNum); if (labelFullLine.text.startsWith(LABELS)) { - textEditor.setDecorations(labelsDecoration, [new vscode.Range(new vscode.Position(3, 0), new vscode.Position(3, labelFullLine.text.length))]); + 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) => { + const closeDisposable = vscode.workspace.onDidCloseTextDocument(textDocument => { if (textDocument === editor.document) { editorChangeDisposable.dispose(); closeDisposable.dispose(); @@ -579,14 +1225,22 @@ ${body ?? ''}\n }); } - private async verifyLabels(folderManager: FolderRepositoryManager, createParams: OctokitCommon.IssuesCreateParams): Promise { + 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(label => { + createParams.labels?.forEach(paramLabel => { + let label = typeof paramLabel === 'string' ? paramLabel : paramLabel.name; + if (!label) { + return; + } + if (allLabels.includes(label)) { filteredLabels.push(label); } else { @@ -595,16 +1249,25 @@ ${body ?? ''}\n }); if (newLabels.length > 0) { - const yes = 'Yes'; - const no = 'No'; - const promptResult = await vscode.window.showInformationMessage(`The following labels don't exist in this repository: ${newLabels.join(', ')}. \nDo you want to create these labels?`, { modal: true }, yes, no); + 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 yes: + return true; case no: { createParams.labels = filteredLabels; return true; } - default: return false; + default: + return false; } } return true; @@ -620,7 +1283,7 @@ ${body ?? ''}\n const defaults = await folderManager.getPullRequestDefaults(); choices.push({ label: `${defaults.owner}/${defaults.repo}`, - repo: folderManager + repo: folderManager, }); } catch (e) { // ignore @@ -628,88 +1291,420 @@ ${body ?? ''}\n } 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 { + 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 (document) { - folderManager = this.manager.getManagerForFile(document.uri); - } else if (originUri) { - folderManager = this.manager.getManagerForFile(originUri); - } else { - folderManager = await this.chooseRepo('Choose where to create the issue.'); + 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 { - origin = await folderManager.getPullRequestDefaults(); + uri = await vscode.env.asExternalUri(file); } catch (e) { - // There is no remote - vscode.window.showErrorMessage('There is no remote. Can\'t create an issue.'); - return false; + // asExternalUri can throw when in the browser and the embedder doesn't set a uri resolver. + return link; } - const body: string | undefined = issueBody || newIssue?.document.isUntitled ? issueBody : (await createGithubPermalink(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 authority = (uri.scheme === 'https' && /^(insiders\.vscode|vscode|github)\./.test(uri.authority)) ? uri.authority : undefined; + if (!authority) { + return link; } - 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 = 'Copy URL'; - const openIssue = 'Open Issue'; - vscode.window.showInformationMessage('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; - } - }); + 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; } - this._stateManager.refreshCacheNeeded(); - return true; } - return false; + + // 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; } - private async getPermalinkWithError(): Promise { - const link = await createGithubPermalink(this.gitAPI); - if (link.error) { - vscode.window.showWarningMessage(`Unable to create a GitHub permalink for the selection. ${link.error}`); + 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); + + // Only use selection text when the context is from a gutter click (not a vscode.Uri) or editor selection, + // not when from a file tab context menu (context is just a vscode.Uri). + const firstContext = context.length > 0 ? context[0] : undefined; + const contextIsFromTab = firstContext instanceof vscode.Uri; + + if (withPermalinks.length === 1 && !contextIsFromTab) { + const selection = this.getMarkdownLinkText(withPermalinks[0].range); + if (selection) { + return vscode.env.clipboard.writeText(`[${selection.trim()}](${withPermalinks[0].permalink})`); + } } - return link.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 copyPermalink() { - const link = await this.getPermalinkWithError(); - if (link) { - vscode.env.clipboard.writeText(link); + 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 openPermalink() { - const link = await this.getPermalinkWithError(); - if (link) { - vscode.env.openExternal(vscode.Uri.parse(link)); + 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)); } } -} \ No newline at end of file +} diff --git a/src/issues/issueFile.ts b/src/issues/issueFile.ts index 3eda6bb6df..69e0ef6ca3 100644 --- a/src/issues/issueFile.ts +++ b/src/issues/issueFile.ts @@ -4,27 +4,38 @@ *--------------------------------------------------------------------------------------------*/ 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 const ASSIGNEES = 'Assignees:'; -export const LABELS = 'Labels:'; - -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 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 class IssueFileSystemProvider implements vscode.FileSystemProvider { private content: Uint8Array | undefined; private createTime: number = 0; private modifiedTime: number = 0; - private _onDidChangeFile: vscode.EventEmitter = new vscode.EventEmitter(); + private _onDidChangeFile: vscode.EventEmitter = new vscode.EventEmitter< + vscode.FileChangeEvent[] + >(); + + constructor(private readonly cache: NewIssueCache) { } onDidChangeFile: vscode.Event = this._onDidChangeFile.event; - watch(_uri: vscode.Uri, _options: { recursive: boolean; excludes: string[]; }): vscode.Disposable { + watch(_uri: vscode.Uri, _options: { recursive: boolean; excludes: string[] }): vscode.Disposable { const disposable = this.onDidChangeFile(e => { if (e.length === 0 && e[0].type === vscode.FileChangeType.Deleted) { disposable.dispose(); @@ -37,7 +48,7 @@ export class IssueFileSystemProvider implements vscode.FileSystemProvider { type: vscode.FileType.File, ctime: this.createTime, mtime: this.modifiedTime, - size: this.content?.length ?? 0 + size: this.content?.length ?? 0, }; } readDirectory(_uri: vscode.Uri): [string, vscode.FileType][] | Thenable<[string, vscode.FileType][]> { @@ -47,7 +58,11 @@ export class IssueFileSystemProvider implements vscode.FileSystemProvider { readFile(_uri: vscode.Uri): Uint8Array | Thenable { return this.content ?? new Uint8Array(0); } - writeFile(uri: vscode.Uri, content: Uint8Array, _options: { create: boolean; overwrite: boolean; } = { create: false, overwrite: false }): void | Thenable { + writeFile( + uri: vscode.Uri, + content: Uint8Array, + _options: { create: boolean; overwrite: boolean } = { create: false, overwrite: false }, + ): void | Thenable { const oldContent = this.content; this.content = content; if (oldContent === undefined) { @@ -57,26 +72,32 @@ export class IssueFileSystemProvider implements vscode.FileSystemProvider { this.modifiedTime = new Date().getTime(); this._onDidChangeFile.fire([{ uri: uri, type: vscode.FileChangeType.Changed }]); } + this.cache.cache(content); } - delete(uri: vscode.Uri, _options: { recursive: boolean; }): void | Thenable { + delete(uri: vscode.Uri, _options: { recursive: boolean }): void | Thenable { this.content = undefined; this.createTime = 0; this.modifiedTime = 0; this._onDidChangeFile.fire([{ uri: uri, type: vscode.FileChangeType.Deleted }]); } - rename(_oldUri: vscode.Uri, _newUri: vscode.Uri, _options: { overwrite: boolean; }): void | Thenable { } + 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(document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken, context: vscode.CompletionContext): Promise { - if (!document.lineAt(position.line).text.startsWith(LABELS)) { + async provideCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + _token: vscode.CancellationToken, + _context: vscode.CompletionContext, + ): Promise { + 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 []; } @@ -85,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); @@ -94,4 +128,156 @@ export class LabelCompletionProvider implements vscode.CompletionItemProvider { }); } -} \ No newline at end of file + 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 { + constructor(private readonly context: vscode.ExtensionContext) { + this.clear(); + } + + public cache(issueFileContent: Uint8Array) { + this.context.workspaceState.update(NEW_ISSUE_CACHE, issueFileContent); + } + + public clear() { + this.context.workspaceState.update(NEW_ISSUE_CACHE, undefined); + } + + public get(): string | undefined { + const content = this.context.workspaceState.get(NEW_ISSUE_CACHE); + if (content) { + 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 11d1687947..5a33c43f00 100644 --- a/src/issues/issueHoverProvider.ts +++ b/src/issues/issueHoverProvider.ts @@ -1,52 +1,79 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { getIssue, ISSUE_OR_URL_EXPRESSION, ParsedIssue, parseIssueExpressionOutput, issueMarkdown, shouldShowHover } from './util'; -import { StateManager } from './stateManager'; -import { ITelemetry } from '../common/telemetry'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { FolderRepositoryManager } from '../github/folderRepositoryManager'; - -export class IssueHoverProvider implements vscode.HoverProvider { - constructor(private manager: RepositoriesManager, private stateManager: StateManager, private context: vscode.ExtensionContext, private telemetry: ITelemetry) { } - - async provideHover(document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken): Promise { - if (!(await shouldShowHover(document, position))) { - return; - } - - let wordPosition = document.getWordRangeAtPosition(position, ISSUE_OR_URL_EXPRESSION); - if (wordPosition && (wordPosition.start.character > 0)) { - wordPosition = new vscode.Range(new vscode.Position(wordPosition.start.line, wordPosition.start.character - 1), wordPosition.end); - const word = document.getText(wordPosition); - const match = word.match(ISSUE_OR_URL_EXPRESSION); - const tryParsed = parseIssueExpressionOutput(match); - - const folderManager = this.manager.getManagerForFile(document.uri); - if (!folderManager) { - return; - } - - if (tryParsed && match && tryParsed.issueNumber <= this.stateManager.maxIssueNumber(folderManager.repository.rootUri)) { - return this.createHover(folderManager, match[0], tryParsed, wordPosition); - } - } else { - return; - } - } - - private async createHover(folderManager: FolderRepositoryManager, value: string, parsed: ParsedIssue, range: vscode.Range): Promise { - const issue = await getIssue(this.stateManager, folderManager, value, parsed); - if (!issue) { - return; - } - /* __GDPR__ - "issue.issueHover" : {} - */ - this.telemetry.sendTelemetryEvent('issues.issueHover'); - return new vscode.Hover(issueMarkdown(issue, this.context, parsed.commentNumber), range); - } -} \ 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 * as vscode from 'vscode'; +import { StateManager } from './stateManager'; +import { + getIssue, + shouldShowHover, +} from './util'; +import { ITelemetry } from '../common/telemetry'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { issueMarkdown } from '../github/markdownUtils'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { ISSUE_OR_URL_EXPRESSION, ParsedIssue, parseIssueExpressionOutput } from '../github/utils'; + +export class IssueHoverProvider implements vscode.HoverProvider { + constructor( + private manager: RepositoriesManager, + private stateManager: StateManager, + private context: vscode.ExtensionContext, + private telemetry: ITelemetry, + ) { } + + async provideHover( + document: vscode.TextDocument, + position: vscode.Position, + _token: vscode.CancellationToken, + ): Promise { + if (!(await shouldShowHover(document, position))) { + return; + } + + let wordPosition = document.getWordRangeAtPosition(position, ISSUE_OR_URL_EXPRESSION); + if (wordPosition && wordPosition.start.character > 0) { + wordPosition = new vscode.Range( + new vscode.Position(wordPosition.start.line, wordPosition.start.character), + wordPosition.end, + ); + const word = document.getText(wordPosition); + const match = word.match(ISSUE_OR_URL_EXPRESSION); + const tryParsed = parseIssueExpressionOutput(match); + + const folderManager = this.manager.getManagerForFile(document.uri) ?? this.manager.folderManagers[0]; + if (!folderManager) { + return; + } + + if ( + tryParsed && + match && + // Only check the max issue number if the owner/repo format isn't used here. + (tryParsed.owner || tryParsed.issueNumber <= this.stateManager.maxIssueNumber(folderManager.repository.rootUri)) + ) { + return this.createHover(folderManager, match[0], tryParsed, wordPosition); + } + } else { + return; + } + } + + private async createHover( + folderManager: FolderRepositoryManager, + value: string, + parsed: ParsedIssue, + range: vscode.Range, + ): Promise { + const issue = await getIssue(this.stateManager, folderManager, value, parsed); + if (!issue) { + return; + } + /* __GDPR__ + "issue.issueHover" : {} + */ + this.telemetry.sendTelemetryEvent('issues.issueHover'); + return new vscode.Hover(await issueMarkdown(issue, this.context, this.manager, parsed.commentNumber), range); + } +} diff --git a/src/issues/issueLinkLookup.ts b/src/issues/issueLinkLookup.ts new file mode 100644 index 0000000000..f90468cd3d --- /dev/null +++ b/src/issues/issueLinkLookup.ts @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { IssueModel } from '../github/issueModel'; +import { RepositoriesManager } from '../github/repositoriesManager'; + +export const CODE_PERMALINK = /http(s)?\:\/\/github\.com\/([^\/]+)\/([^\/]+)\/blob\/([0-9a-fA-F]{40})\/([^#]+)#L(\d+)(-L(\d+))?/; + +function findCodeLink(issueContent: string): RegExpMatchArray | null { + return issueContent.match(CODE_PERMALINK); +} + +export function issueBodyHasLink(issueModel: IssueModel): boolean { + return !!findCodeLink(issueModel.body); +} + +interface CodeLink { + file: vscode.Uri; + start: number; + end: number; +} + +export async function findCodeLinkLocally( + codeLink: RegExpMatchArray, + repositoriesManager: RepositoriesManager, + _silent: boolean = true, +): Promise { + const owner = codeLink[2]; + const repo = codeLink[3]; + const repoSubPath = codeLink[5]; + // subtract 1 because VS Code starts lines at 0, whereas GitHub starts at 1. + const startingLine = Number(codeLink[6]) - 1; + const endingLine = codeLink[8] ? Number(codeLink[8]) - 1 : startingLine; + let linkFolderManager: FolderRepositoryManager | undefined; + + for (const folderManager of repositoriesManager.folderManagers) { + const remotes = await folderManager.getGitHubRemotes(); + for (const remote of remotes) { + if ( + owner.toLowerCase() === remote.owner.toLowerCase() && + repo.toLowerCase() === remote.repositoryName.toLowerCase() + ) { + linkFolderManager = folderManager; + break; + } + } + if (linkFolderManager) { + break; + } + } + + if (!linkFolderManager) { + return; + } + + const path = vscode.Uri.joinPath(linkFolderManager.repository.rootUri, repoSubPath); + try { + await vscode.workspace.fs.stat(path); + } catch (e) { + return; + } + return { + file: path, + start: startingLine, + end: endingLine, + }; +} + +export async function openCodeLink(issueModel: IssueModel, repositoriesManager: RepositoriesManager) { + const issueLink = findCodeLink(issueModel.body); + if (!issueLink) { + vscode.window.showInformationMessage(vscode.l10n.t('Issue has no link.')); + return; + } + const codeLink = await findCodeLinkLocally(issueLink, repositoriesManager, false); + if (!codeLink) { + return vscode.env.openExternal(vscode.Uri.parse(issueLink[0])); + } + const textDocument = await vscode.workspace.openTextDocument(codeLink?.file); + const endingTextDocumentLine = textDocument.lineAt( + codeLink.end < textDocument.lineCount ? codeLink.end : textDocument.lineCount - 1, + ); + const selection = new vscode.Range(codeLink.start, 0, codeLink.end, endingTextDocumentLine.text.length); + return vscode.window.showTextDocument(codeLink.file, { selection }); +} diff --git a/src/issues/issueLinkProvider.ts b/src/issues/issueLinkProvider.ts deleted file mode 100644 index b4d305c484..0000000000 --- a/src/issues/issueLinkProvider.ts +++ /dev/null @@ -1,63 +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 { ReposManagerState } from '../github/folderRepositoryManager'; -import * as vscode from 'vscode'; -import { getIssue, ISSUE_EXPRESSION, ParsedIssue, parseIssueExpressionOutput, MAX_LINE_LENGTH, isComment } from './util'; -import { StateManager } from './stateManager'; -import { RepositoriesManager } from '../github/repositoriesManager'; - -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; - } - } -} \ No newline at end of file diff --git a/src/issues/issueTodoProvider.ts b/src/issues/issueTodoProvider.ts index cbcd826961..ace829f627 100644 --- a/src/issues/issueTodoProvider.ts +++ b/src/issues/issueTodoProvider.ts @@ -1,53 +1,82 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { ISSUE_OR_URL_EXPRESSION, MAX_LINE_LENGTH, ISSUES_CONFIGURATION } from './util'; - -export class IssueTodoProvider implements vscode.CodeActionProvider { - private expression: RegExp | undefined; - - constructor(context: vscode.ExtensionContext) { - context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(() => { - this.updateTriggers(); - })); - this.updateTriggers(); - } - - private updateTriggers() { - const triggers = vscode.workspace.getConfiguration(ISSUES_CONFIGURATION).get('createIssueTriggers', []); - this.expression = triggers.length > 0 ? new RegExp(triggers.join('|')) : undefined; - } - - async provideCodeActions(document: vscode.TextDocument, range: vscode.Range | vscode.Selection, context: vscode.CodeActionContext, token: vscode.CancellationToken): Promise { - if (this.expression === undefined || (context.only && context.only !== vscode.CodeActionKind.QuickFix)) { - return []; - } - const codeActions: vscode.CodeAction[] = []; - 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('Create GitHub Issue', 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: 'Create GitHub Issue', - command: 'issue.createIssueFromSelection', - arguments: [{ document, lineNumber, line, insertIndex, range }] - }; - codeActions.push(codeAction); - break; - } - } - lineNumber++; - } while (range.end.line >= lineNumber); - return codeActions; - } -} +/*--------------------------------------------------------------------------------------------- + * 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 { MAX_LINE_LENGTH } from './util'; +import { CREATE_ISSUE_TRIGGERS, ISSUES_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { escapeRegExp } from '../common/utils'; +import { ISSUE_OR_URL_EXPRESSION } from '../github/utils'; + +export class IssueTodoProvider implements vscode.CodeActionProvider { + private expression: RegExp | undefined; + + constructor( + context: vscode.ExtensionContext) { + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(() => { + this.updateTriggers(); + }), + ); + this.updateTriggers(); + } + + private updateTriggers() { + 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( + document: vscode.TextDocument, + range: vscode.Range | vscode.Selection, + context: vscode.CodeActionContext, + _token: vscode.CancellationToken, + ): Promise { + if (this.expression === undefined || (context.only && context.only !== vscode.CodeActionKind.QuickFix)) { + return []; + } + const codeActions: vscode.CodeAction[] = []; + let lineNumber = range.start.line; + do { + const line = document.lineAt(lineNumber).text; + 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); + break; + } + lineNumber++; + } while (range.end.line >= lineNumber); + return codeActions; + } +} diff --git a/src/issues/issuesView.ts b/src/issues/issuesView.ts index 4a3b2fa04f..f43a04f15a 100644 --- a/src/issues/issuesView.ts +++ b/src/issues/issuesView.ts @@ -3,128 +3,322 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as vscode from 'vscode'; import * as path from 'path'; -import { StateManager, MilestoneItem, IssueItem } from './stateManager'; -import { Resource } from '../common/resources'; -import { issueMarkdown } from './util'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { ReposManagerState, FolderRepositoryManager } from '../github/folderRepositoryManager'; +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, IssueAvatarDisplay, 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'; + +export class QueryNode { + constructor( + public readonly repoRootUri: vscode.Uri, + public readonly queryLabel: string, + public readonly isFirst: boolean + ) { + } +} -class UriTreeItem extends vscode.TreeItem2 { - constructor(public readonly uri: vscode.Uri | undefined, label: string, collapsibleState?: vscode.TreeItemCollapsibleState) { - super({ label }, collapsibleState); +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 { - private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); - public onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; +export class IssuesTreeData + implements vscode.TreeDataProvider { + private _onDidChangeTreeData: vscode.EventEmitter< + FolderRepositoryManager | IssueItem | null | undefined | void + > = new vscode.EventEmitter(); + public onDidChangeTreeData: vscode.Event< + FolderRepositoryManager | IssueItem | null | undefined | void + > = this._onDidChangeTreeData.event; + + constructor( + private stateManager: StateManager, + private manager: RepositoriesManager, + private context: vscode.ExtensionContext, + ) { + context.subscriptions.push( + this.manager.onDidChangeState(() => { + this._onDidChangeTreeData.fire(); + }), + ); + context.subscriptions.push( + this.stateManager.onDidChangeIssueData(() => { + this._onDidChangeTreeData.fire(); + }), + ); - constructor(private stateManager: StateManager, private manager: RepositoriesManager, private context: vscode.ExtensionContext) { - context.subscriptions.push(this.manager.onDidChangeState(() => { - this._onDidChangeTreeData.fire(); - })); - context.subscriptions.push(this.stateManager.onDidChangeIssueData(() => { - this._onDidChangeTreeData.fire(); - })); + context.subscriptions.push( + this.stateManager.onDidChangeCurrentIssue(() => { + this._onDidChangeTreeData.fire(); + }), + ); - context.subscriptions.push(this.stateManager.onDidChangeCurrentIssue(() => { - 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 | UriTreeItem): UriTreeItem { - let treeItem: UriTreeItem; - if (element instanceof UriTreeItem) { - treeItem = element; - } else if (element instanceof FolderRepositoryManager) { - treeItem = new UriTreeItem(element.repository.rootUri, path.basename(element.repository.rootUri.fsPath), vscode.TreeItemCollapsibleState.Expanded); - } else if (!(element instanceof IssueModel)) { - treeItem = new UriTreeItem(element.uri, element.milestone.title, element.issues.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None); + 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(ISSUE_AVATAR_DISPLAY, 'author'); + + if (avatarDisplaySetting === 'state') { + treeItem.iconPath = element.isOpen + ? new vscode.ThemeIcon('issues', new vscode.ThemeColor('issues.open')) + : new vscode.ThemeIcon('issue-closed', new vscode.ThemeColor('issues.closed')); + } else if (avatarDisplaySetting === 'generic') { + treeItem.iconPath = new vscode.ThemeIcon('issues'); } else { - treeItem = new UriTreeItem(undefined, `${element.number}: ${element.title}`, vscode.TreeItemCollapsibleState.None); - treeItem.iconPath = { - light: element.isOpen ? Resource.icons.light.Issues : Resource.icons.light.IssueClosed, - dark: element.isOpen ? Resource.icons.dark.Issues : Resource.icons.dark.IssueClosed - }; - if (this.stateManager.currentIssue(element.uri)?.issue.number === element.number) { - treeItem.label = `✓ ${treeItem.label.label}`; - treeItem.contextValue = 'currentissue'; + let avatarUser: IAccount | undefined; + if ((avatarDisplaySetting === 'assignee') && element.assignees && (element.assignees.length > 0)) { + avatarUser = element.assignees[0]; } else { - const savedState = this.stateManager.getSavedIssueState(element.number); - if (savedState.branch) { - treeItem.contextValue = 'continueissue'; + 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 { - 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'))); } + } 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; } - getChildren(element: FolderRepositoryManager | IssueItem | MilestoneItem | UriTreeItem | undefined): FolderRepositoryManager[] | Promise<(IssueItem | MilestoneItem)[]> | IssueItem[] | UriTreeItem[] { - if ((element === undefined) && (this.manager.state !== ReposManagerState.RepositoriesLoaded)) { + 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 | QueryNode | IssueGroupNode | IssueItem | undefined, + ): FolderRepositoryManager[] | QueryNode[] | Promise { + if (element === undefined && this.manager.state !== ReposManagerState.RepositoriesLoaded) { return this.getStateChildren(); } else { return this.getIssuesChildren(element); } } - resolveTreeItem(element: FolderRepositoryManager | IssueItem | MilestoneItem | vscode.TreeItem, item: vscode.TreeItem2): vscode.TreeItem2 { + async resolveTreeItem( + item: vscode.TreeItem, + element: FolderRepositoryManager | QueryNode | IssueGroupNode | IssueItem, + ): Promise { if (element instanceof IssueModel) { - item.tooltip = issueMarkdown(element, this.context); + item.tooltip = await issueMarkdown(element, this.context, this.manager); } return item; } - getStateChildren(): UriTreeItem[] { - if (this.manager.state === ReposManagerState.NeedsAuthentication) { + getStateChildren(): [] { + if ((this.manager.state === ReposManagerState.NeedsAuthentication) + || !this.manager.folderManagers.length) { return []; } else { - return [new UriTreeItem(undefined, 'Loading...')]; + commands.setContext(contexts.LOADING_ISSUES_TREE, true); + return []; } } - getQueryItems(folderManager: FolderRepositoryManager): Promise<(IssueItem | MilestoneItem)[]> | UriTreeItem[] { - const issueCollection = this.stateManager.getIssueCollection(folderManager.repository.rootUri); - if (issueCollection.size === 1) { - return Array.from(issueCollection.values())[0]; + private async getRootChildren(): 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) { + // Hide repositories that have no matching issues in any query + const managersWithIssues: FolderRepositoryManager[] = []; + for (const fm of this.manager.folderManagers) { + const issueCol = this.stateManager.getIssueCollection(fm.repository.rootUri); + const queryResultPromises = Array.from(issueCol.values()); + const queryResults = await Promise.allSettled(queryResultPromises); + const hasMatchingIssues = queryResults.some(r => + r.status === 'fulfilled' && r.value.issues && r.value.issues.length > 0 + ); + if (hasMatchingIssues) { + managersWithIssues.push(fm); + } + } + return managersWithIssues; + } 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 UriTreeItem(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 | UriTreeItem | undefined): FolderRepositoryManager[] | Promise<(IssueItem | MilestoneItem)[]> | IssueItem[] | UriTreeItem[] { - 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 UriTreeItem) { - return element.uri ? this.stateManager.getIssueCollection(element.uri).get(element.label.label!) ?? [] : []; - } 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 2bb1776291..8d2ea3843f 100644 --- a/src/issues/stateManager.ts +++ b/src/issues/stateManager.ts @@ -3,25 +3,36 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as LRUCache from 'lru-cache'; +import LRUCache from 'lru-cache'; import * as vscode from 'vscode'; -import { IssueModel } from '../github/issueModel'; -import { IAccount } from '../github/interface'; -import { FolderRepositoryManager, NO_MILESTONE, PullRequestDefaults, ReposManagerState } from '../github/folderRepositoryManager'; -import { MilestoneModel } from '../github/milestoneModel'; -import { GitAPI } from '../typings/git'; -import { ISSUES_CONFIGURATION, BRANCH_CONFIGURATION, QUERIES_CONFIGURATION, DEFAULT_QUERY_CONFIGURATION, variableSubstitution, getIssueNumberLabel } from './util'; import { CurrentIssue } from './currentIssue'; +import { Repository } from '../api/api'; +import { GitApiImpl } from '../api/api1'; +import { AuthProvider } from '../common/authentication'; +import { parseRepositoryRemotesAsync } 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, + PullRequestDefaults, + ReposManagerState, +} from '../github/folderRepositoryManager'; +import { IAccount } from '../github/interface'; +import { IssueModel } from '../github/issueModel'; import { RepositoriesManager } from '../github/repositoriesManager'; +import { getIssueNumberLabel, variableSubstitution } from '../github/utils'; -// 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; @@ -33,14 +44,11 @@ interface TimeStampedIssueState extends IssueState { interface IssuesState { issues: Record; - branches: Record; + branches: Record; } -const DEFAULT_QUERY_CONFIGURATION_VALUE = [{ label: '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; @@ -50,27 +58,34 @@ 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: LRUCache = new LRUCache(50); // 50 seems big enough + public readonly resolvedIssues: Map> = new Map(); private _singleRepoStates: Map = new Map(); private _onRefreshCacheNeeded: vscode.EventEmitter = new vscode.EventEmitter(); 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; @@ -81,27 +96,26 @@ export class StateManager { } constructor( - readonly gitAPI: GitAPI, + readonly gitAPI: GitApiImpl, private manager: RepositoriesManager, - private context: vscode.ExtensionContext - ) { - manager.folderManagers.forEach(folderManager => { - this.context.subscriptions.push(folderManager.onDidChangeRepositories(() => this.refresh())); - }); - } + private context: vscode.ExtensionContext, + ) { } - 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(), maxIssueNumber: 0, - folderManager + folderManager, }; this._singleRepoStates.set(uri.path, state); return state; @@ -110,15 +124,22 @@ export class StateManager { async tryInitializeAndWait() { if (!this.initializePromise) { this.initializePromise = new Promise(resolve => { - if (this.manager.state === ReposManagerState.RepositoriesLoaded) { - this.doInitialize(); + if (!this.manager.credentialStore.isAnyAuthenticated()) { + // We don't wait for sign in to finish initializing. + const disposable = this.manager.credentialStore.onDidGetSession(() => { + disposable.dispose(); + this.doInitialize(); + }); resolve(); + } else if (this.manager.state === ReposManagerState.RepositoriesLoaded) { + this.doInitialize().then(() => resolve()); } else { const disposable = this.manager.onDidChangeState(() => { if (this.manager.state === ReposManagerState.RepositoriesLoaded) { - this.doInitialize(); - disposable.dispose(); - resolve(); + this.doInitialize().then(() => { + disposable.dispose(); + resolve(); + }); } }); this.context.subscriptions.push(disposable); @@ -129,29 +150,50 @@ export class StateManager { } private registerRepositoryChangeEvent() { - this.gitAPI.repositories.forEach(repository => { - this.context.subscriptions.push(repository.state.onDidChange(async () => { - const state = this.getOrCreateSingleRepoState(repository.rootUri); - const newHead = (repository.state.HEAD ? repository.state.HEAD.commit : undefined); - if ((repository.state.HEAD ? repository.state.HEAD.commit : undefined) !== state.lastHead) { - state.lastHead = (repository.state.HEAD ? repository.state.HEAD.commit : undefined); - await this.setIssueData(state.folderManager); - } + 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; + const newHead = repository.state.HEAD ? repository.state.HEAD.commit : undefined; + if ((repository.state.HEAD ? repository.state.HEAD.commit : undefined) !== oldHead) { + await that.setIssueData(state.folderManager); + } - const newBranch = repository.state.HEAD?.name; - if (((state.lastHead !== newHead) || (state.lastBranch !== newBranch)) && - (!state.currentIssue || (newBranch !== state.currentIssue.branchName))) { - if (newBranch) { - if (state.folderManager) { - await this.setCurrentIssueFromBranch(state, newBranch); - } - } else { - await this.setCurrentIssue(state, undefined); + const newBranch = repository.state.HEAD?.name; + if ( + (oldHead !== newHead || oldBranch !== newBranch) && + (!state.currentIssue || newBranch !== state.currentIssue.branchName) + ) { + if (newBranch) { + if (state.folderManager) { + await that.setCurrentIssueFromBranch(state, newBranch, true); } + } else { + await that.setCurrentIssue(state, undefined, !!newBranch); } - state.lastHead = (repository.state.HEAD ? repository.state.HEAD.commit : undefined); - state.lastBranch = (repository.state.HEAD ? repository.state.HEAD.name : undefined); - })); + } + state.lastHead = repository.state.HEAD ? repository.state.HEAD.commit : undefined; + state.lastBranch = repository.state.HEAD ? repository.state.HEAD.name : undefined; + } + + function addChangeEvent(that: StateManager, repository: Repository) { + that.context.subscriptions.push( + repository.state.onDidChange(async () => { + updateRepository(that, repository); + }), + ); + } + + this.context.subscriptions.push(this.gitAPI.onDidOpenRepository(repository => { + updateRepository(this, repository); + addChangeEvent(this, repository); + })); + this.gitAPI.repositories.forEach(repository => { + addChangeEvent(this, repository); }); } @@ -159,36 +201,71 @@ 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).get(QUERIES_CONFIGURATION, DEFAULT_QUERY_CONFIGURATION_VALUE); + this._queries = vscode.workspace + .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}`)) { - this._queries = vscode.workspace.getConfiguration(ISSUES_CONFIGURATION).get(QUERIES_CONFIGURATION, DEFAULT_QUERY_CONFIGURATION_VALUE); - this._onRefreshCacheNeeded.fire(); - } else if (change.affectsConfiguration(`${ISSUES_CONFIGURATION}.${IGNORE_MILESTONES_CONFIGURATION}`)) { - this._onRefreshCacheNeeded.fire(); - } - })); - await this.setAllIssueData(); + this.context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(change => { + if (change.affectsConfiguration(`${ISSUES_SETTINGS_NAMESPACE}.${QUERIES}`)) { + this._queries = vscode.workspace + .getConfiguration(ISSUES_SETTINGS_NAMESPACE, null) + .get(QUERIES, DEFAULT_QUERY_CONFIGURATION_VALUE); + this._onRefreshCacheNeeded.fire(); + } else if (change.affectsConfiguration(`${ISSUES_SETTINGS_NAMESPACE}.${IGNORE_MILESTONES}`)) { + this._onRefreshCacheNeeded.fire(); + } + }), + ); this.registerRepositoryChangeEvent(); - this.context.subscriptions.push(this.onRefreshCacheNeeded(async () => { - await this.refresh(); - })); + // 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(folderManager.repository.rootUri, folderManager); - singleRepoState.lastHead = folderManager.repository.state.HEAD ? folderManager.repository.state.HEAD.commit : undefined; + 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; this._singleRepoStates.set(folderManager.repository.rootUri.path, singleRepoState); const branch = folderManager.repository.state.HEAD?.name; if (!singleRepoState.currentIssue && branch) { - await this.setCurrentIssueFromBranch(singleRepoState, branch); + await this.setCurrentIssueFromBranch(singleRepoState, branch, true); } } } @@ -196,7 +273,7 @@ export class StateManager { private cleanIssueState() { const stateString: string | undefined = this.context.workspaceState.get(ISSUES_KEY); const state: IssuesState = stateString ? JSON.parse(stateString) : { issues: [], branches: [] }; - const deleteDate: number = new Date().valueOf() - (30 /*days*/ * 86400000 /*milliseconds in a day*/); + const deleteDate: number = new Date().valueOf() - 30 /*days*/ * 86400000 /*milliseconds in a day*/; for (const issueState in state.issues) { if (state.issues[issueState].stateModifiedTime < deleteDate) { if (state.branches && state.branches[issueState]) { @@ -209,7 +286,7 @@ export class StateManager { private async getUsers(uri: vscode.Uri): Promise> { await this.initializePromise; - const assignableUsers = await (this.manager.getManagerForFile(uri))?.getAssignableUsers(); + const assignableUsers = await this.manager.getManagerForFile(uri)?.getAssignableUsers(); const userMap: Map = new Map(); for (const remote in assignableUsers) { assignableUsers[remote].forEach(account => { @@ -219,19 +296,19 @@ export class StateManager { return userMap; } - getUserMap(uri: vscode.Uri): Promise> { - if (!this.initializePromise) { + async getUserMap(uri: vscode.Uri): Promise> { + const state = this.getOrCreateSingleRepoState(uri); + if (!this.initializePromise || !state) { return Promise.resolve(new Map()); } - const state = this.getOrCreateSingleRepoState(uri); - if (!state.userMap) { + if (!state.userMap || (await state.userMap).size === 0) { state.userMap = this.getUsers(uri); } return state.userMap; } - private async getCurrentUser(): Promise { - return (await this.manager.credentialStore.getCurrentUser())?.login; + private async getCurrentUser(authProviderId: AuthProvider): Promise { + return (await this.manager.credentialStore.getCurrentUser(authProviderId))?.login; } private async setAllIssueData() { @@ -240,47 +317,57 @@ 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) { - user = await this.getCurrentUser(); - } - items = this.setIssues(folderManager, await variableSubstitution(query.query, undefined, defaults, user)); + const enterpriseRemotes = (await parseRepositoryRemotesAsync(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.maxIssueNumber = await folderManager.getMaxIssue(folderManager.repository); singleRepoState.lastHead = folderManager.repository.state.HEAD?.commit; singleRepoState.lastBranch = folderManager.repository.state.HEAD?.name; } - private setIssues(folderManager: FolderRepositoryManager, query: string): Promise { - return new Promise(async (resolve) => { - const issues = await folderManager.getIssues({ fetchNextPage: false }, query); - this._onDidChangeIssueData.fire(); - resolve(issues.items.map(item => { - const issueItem: IssueItem = item; + private async setIssues(folderManager: FolderRepositoryManager, query: string): Promise { + try { + const issues = await folderManager.getIssues(query, { fetchNextPage: false, fetchOnePagePerRepo: true }); + return issues?.items.map(item => { + const issueItem: IssueItem = item as IssueItem; issueItem.uri = folderManager.repository.rootUri; return issueItem; - })); - }); + }); + } catch { + // Errors from fetching issues are expected (e.g. network failures). + // Return undefined so the tree shows an empty state for this query. + return undefined; + } finally { + this._onDidChangeIssueData.fire(); + } } - private async setCurrentIssueFromBranch(singleRepoState: SingleRepoState, branchName: string) { - const createBranchConfig = vscode.workspace.getConfiguration(ISSUES_CONFIGURATION).get(BRANCH_CONFIGURATION); + private async setCurrentIssueFromBranch(singleRepoState: SingleRepoState, branchName: string, silent: boolean = false) { + const createBranchConfig = vscode.workspace + .getConfiguration(ISSUES_SETTINGS_NAMESPACE) + .get(USE_BRANCH_FOR_ISSUES); if (createBranchConfig === 'off') { return; } @@ -293,83 +380,43 @@ export class StateManager { return; } if (branchName === defaults.base) { - await this.setCurrentIssue(singleRepoState, undefined); + await this.setCurrentIssue(singleRepoState, undefined, false); return; } - if (singleRepoState.currentIssue && (singleRepoState.currentIssue.branchName === branchName)) { + if (singleRepoState.currentIssue && singleRepoState.currentIssue.branchName === branchName) { return; } const state: IssuesState = this.getSavedState(); for (const branch in state.branches) { if (branch === branchName) { - const issueModel = await singleRepoState.folderManager.resolveIssue(state.branches[branch].owner, state.branches[branch].repositoryName, state.branches[branch].number); + const issueModel = await singleRepoState.folderManager.resolveIssue( + state.branches[branch].owner, + state.branches[branch].repositoryName, + state.branches[branch].number, + ); if (issueModel) { - await this.setCurrentIssue(singleRepoState, new CurrentIssue(issueModel, singleRepoState.folderManager, this)); + await this.setCurrentIssue( + singleRepoState, + new CurrentIssue(issueModel, singleRepoState.folderManager, this), + false, + silent + ); } return; } } } - 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.getMilestones({ 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]; - 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; } currentIssues(): CurrentIssue[] { - return Array.from(this._singleRepoStates.values()).filter(state => state?.currentIssue).map(state => state!.currentIssue!); + return Array.from(this._singleRepoStates.values()) + .filter(state => state?.currentIssue) + .map(state => state!.currentIssue!); } maxIssueNumber(uri: vscode.Uri): number { @@ -377,8 +424,8 @@ export class StateManager { } private isSettingIssue: boolean = false; - async setCurrentIssue(repoState: SingleRepoState | FolderRepositoryManager, issue: CurrentIssue | undefined) { - if (this.isSettingIssue && (issue === undefined)) { + async setCurrentIssue(repoState: SingleRepoState | FolderRepositoryManager, issue: CurrentIssue | undefined, checkoutDefaultBranch: boolean, silent: boolean = false) { + if (this.isSettingIssue && issue === undefined) { return; } this.isSettingIssue = true; @@ -390,17 +437,21 @@ export class StateManager { repoState = state; } try { - if (repoState.currentIssue && (issue?.issue.number === repoState.currentIssue.issue.number)) { + 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())); } this.context.workspaceState.update(CURRENT_ISSUE_KEY, issue?.issue.number); - if (!issue || await issue.startWorking()) { + if (!issue || (await issue.startWorking(silent))) { repoState.currentIssue = issue; this.updateStatusBar(); } @@ -424,10 +475,13 @@ export class StateManager { return; } if (shouldShowStatusBarItem && !this.statusBarItem) { - this.statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 0); + this.statusBarItem = vscode.window.createStatusBarItem('github.issues.status', vscode.StatusBarAlignment.Left, 0); + this.statusBarItem.name = vscode.l10n.t('GitHub Active Issue'); } const statusBarItem = this.statusBarItem!; - statusBarItem.text = `$(issues) Issue ${currentIssues.map(issue => getIssueNumberLabel(issue.issue, issue.repoDefaults)).join(', ')}`; + statusBarItem.text = vscode.l10n.t('{0} Issue {1}', '$(issues)', currentIssues + .map(issue => getIssueNumberLabel(issue.issue, issue.repoDefaults)) + .join(', ')); statusBarItem.tooltip = currentIssues.map(issue => issue.issue.title).join(', '); statusBarItem.command = 'issue.statusBar'; statusBarItem.show(); @@ -443,15 +497,19 @@ export class StateManager { return state.issues[`${issueNumber}`] ?? {}; } - setSavedIssueState(issue: IssueModel, issueState: IssueState) { + async setSavedIssueState(issue: IssueModel, issueState: IssueState) { const state: IssuesState = this.getSavedState(); - state.issues[`${issue.number}`] = { ...issueState, stateModifiedTime: (new Date().valueOf()) }; + state.issues[`${issue.number}`] = { ...issueState, stateModifiedTime: new Date().valueOf() }; if (issueState.branch) { if (!state.branches) { state.branches = Object.create(null); } - state.branches[issueState.branch] = { number: issue.number, owner: issue.remote.owner, repositoryName: issue.remote.repositoryName }; + state.branches[issueState.branch] = { + number: issue.number, + owner: issue.remote.owner, + repositoryName: issue.remote.repositoryName, + }; } - this.context.workspaceState.update(ISSUES_KEY, JSON.stringify(state)); + return this.context.workspaceState.update(ISSUES_KEY, JSON.stringify(state)); } -} \ No newline at end of file +} diff --git a/src/issues/userCompletionProvider.ts b/src/issues/userCompletionProvider.ts index 4f86b037fb..e0a74f45e4 100644 --- a/src/issues/userCompletionProvider.ts +++ b/src/issues/userCompletionProvider.ts @@ -3,63 +3,153 @@ * 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 { User } from '../github/interface'; -import { userMarkdown, ISSUES_CONFIGURATION, UserCompletion, isComment, getRootUriFromScmInputUri } from './util'; +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 { NEW_ISSUE_SCHEME, extractIssueOriginFromQuery } from './issueFile'; +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 { - constructor(private stateManager: StateManager, private manager: RepositoriesManager, context: vscode.ExtensionContext) { - } + private static readonly ID: string = 'UserCompletionProvider'; + private _gitBlameCache: { [key: string]: string } = {}; + + constructor( + private stateManager: StateManager, + private manager: RepositoriesManager, + _context: vscode.ExtensionContext, + ) { } - async provideCompletionItems(document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken, context: vscode.CompletionContext): Promise { + async provideCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken, + context: vscode.CompletionContext, + ): Promise { + let wordRange = document.getWordRangeAtPosition(position); + let wordAtPos = wordRange ? document.getText(wordRange) : undefined; + if (!wordRange || wordAtPos?.charAt(0) !== '@') { + const start = wordRange?.start ?? position; + const testWordRange = new vscode.Range(start.translate(undefined, start.character ? -1 : 0), position); + const testWord = document.getText(testWordRange); + if (testWord.charAt(0) === '@') { + wordRange = testWordRange; + wordAtPos = testWord; + } + } // 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) && (position.character > 0) && (context.triggerKind === vscode.CompletionTriggerKind.Invoke) && (document.getText(new vscode.Range(position.with(undefined, position.character - 1), position)) !== '@')) { + if ( + document.languageId !== 'scminput' && + document.uri.scheme !== Schemes.NewIssue && + position.character > 0 && + context.triggerKind === vscode.CompletionTriggerKind.Invoke && + wordAtPos?.charAt(0) !== '@' + ) { + return []; + } + + // 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 === Schemes.NewIssue) && + (context.triggerKind === vscode.CompletionTriggerKind.Invoke) && + (document.getText(new vscode.Range(position.with(undefined, 0), position.with(undefined, ASSIGNEES.length))) !== ASSIGNEES) + ) { return []; } - if ((context.triggerKind === vscode.CompletionTriggerKind.TriggerCharacter) && - (vscode.workspace.getConfiguration(ISSUES_CONFIGURATION).get('ignoreUserCompletionTrigger', [])).find(value => value === document.languageId)) { + if ( + context.triggerKind === vscode.CompletionTriggerKind.TriggerCharacter && + vscode.workspace + .getConfiguration(ISSUES_SETTINGS_NAMESPACE) + .get(IGNORE_USER_COMPLETION_TRIGGER, []) + .find(value => value === document.languageId) + ) { return []; } - if ((document.languageId !== 'scminput') && !(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 []; } let range: vscode.Range = new vscode.Range(position, position); if (position.character - 1 >= 0) { - const wordAtPos = document.getText(new vscode.Range(position.translate(0, -1), position)); - if (wordAtPos === '@') { - range = new vscode.Range(position.translate(0, -1), position); + if (wordRange && wordAtPos?.charAt(0) === '@') { + range = wordRange; + } + } + + let uri: vscode.Uri | undefined = 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; + 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; } } - const uri = document.uri.scheme === NEW_ISSUE_SCHEME ? (extractIssueOriginFromQuery(document.uri) ?? document.uri) : - (document.languageId === 'scminput' ? getRootUriFromScmInputUri(document.uri) : document.uri); + if (!uri) { return []; } - const completionItems: vscode.CompletionItem[] = []; - (await this.stateManager.getUserMap(uri)).forEach(item => { - const completionItem: UserCompletion = new UserCompletion(item.login, vscode.CompletionItemKind.User); - completionItem.insertText = `@${item.login}`; + const repoUri = this.manager.getManagerForFile(uri)?.repository.rootUri ?? uri; + + 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: login, description: item.name }, vscode.CompletionItemKind.User); + completionItem.insertText = `@${login}`; completionItem.login = item.login; - completionItem.uri = uri; + 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; } - async resolveCompletionItem(item: UserCompletion, token: vscode.CancellationToken): Promise { + private isCodeownersFiles(uri: vscode.Uri): boolean { + const repositoryManager = this.manager.getManagerForFile(uri); + if (!repositoryManager || !isDescendant(repositoryManager.repository.rootUri.fsPath, uri.fsPath)) { + return false; + } + const subpath = uri.path.substring(repositoryManager.repository.rootUri.path.length).toLowerCase(); + const codeownersFiles = ['/codeowners', '/docs/codeowners', '/.github/codeowners']; + return !!codeownersFiles.find(file => file === subpath); + } + + async resolveCompletionItem(item: UserCompletion, _token: vscode.CancellationToken): Promise { const folderManager = this.manager.getManagerForFile(item.uri); if (!folderManager) { return item; @@ -70,9 +160,188 @@ export class UserCompletionProvider implements vscode.CompletionItemProvider { item.documentation = userMarkdown(repo, user); item.command = { command: 'issues.userCompletion', - title: 'User Completion Chosen' + title: vscode.l10n.t('User Completion Chosen'), }; } return item; } -} \ No newline at end of file + + private cachedPrUsers: UserCompletion[] = []; + private cachedPrTimelineEvents: TimelineEvent[] | undefined; + 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, 'UserCompletionProvider.provideCompletionItems'); + 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 de00ac6b66..671e71510f 100644 --- a/src/issues/userHoverProvider.ts +++ b/src/issues/userHoverProvider.ts @@ -1,54 +1,87 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { userMarkdown, USER_EXPRESSION, shouldShowHover } from './util'; -import { ITelemetry } from '../common/telemetry'; -import { RepositoriesManager } from '../github/repositoriesManager'; - -export class UserHoverProvider implements vscode.HoverProvider { - constructor(private manager: RepositoriesManager, private telemetry: ITelemetry) { } - - async provideHover(document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken): Promise { - if (!(await shouldShowHover(document, position))) { - return; - } - - let wordPosition = document.getWordRangeAtPosition(position, USER_EXPRESSION); - if (wordPosition && (wordPosition.start.character > 0)) { - wordPosition = new vscode.Range(new vscode.Position(wordPosition.start.line, wordPosition.start.character), wordPosition.end); - const word = document.getText(wordPosition); - const match = word.match(USER_EXPRESSION); - if (match) { - return this.createHover(document.uri, match[1], wordPosition); - } - } else { - return; - } - } - - private async createHover(uri: vscode.Uri, username: string, range: vscode.Range): Promise { - try { - const folderManager = this.manager.getManagerForFile(uri); - if (!folderManager) { - return; - } - const origin = await folderManager.getPullRequestDefaults(); - const user = await folderManager.resolveUser(origin.owner, origin.repo, username); - if (user && user.name) { - /* __GDPR__ - "issue.userHover" : {} - */ - this.telemetry.sendTelemetryEvent('issues.userHover'); - return new vscode.Hover(userMarkdown(origin, user), range); - } else { - return; - } - } catch (e) { - // No need to notify about a hover that doesn't work - return; - } - } -} \ 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 * 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'; + +export class UserHoverProvider implements vscode.HoverProvider { + constructor(private manager: RepositoriesManager, private telemetry: ITelemetry) { } + + async provideHover( + document: vscode.TextDocument, + position: vscode.Position, + _token: vscode.CancellationToken, + ): Promise { + if (!(await shouldShowHover(document, position))) { + return; + } + + let wordPosition = document.getWordRangeAtPosition(position, USER_EXPRESSION); + if (wordPosition && wordPosition.start.character > 0) { + wordPosition = new vscode.Range( + new vscode.Position(wordPosition.start.line, wordPosition.start.character), + wordPosition.end, + ); + const word = document.getText(wordPosition); + const match = word.match(USER_EXPRESSION); + if (match) { + const username = match[1]; + // JS and TS doc checks + const JS_TS_LANGUAGE_IDS = [ + 'javascript', + 'javascriptreact', + 'typescript', + 'typescriptreact', + ]; + if (JS_TS_LANGUAGE_IDS.includes(document.languageId) + && JSDOC_NON_USERS.indexOf(username) >= 0) { + return; + } + // PHP doc checks + 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 { + return; + } + } + + private async createHover( + uri: vscode.Uri, + username: string, + range: vscode.Range, + ): Promise { + try { + const folderManager = this.manager.getManagerForFile(uri); + if (!folderManager) { + return; + } + const origin = await folderManager.getPullRequestDefaults(); + const user = await folderManager.resolveUser(origin.owner, origin.repo, username); + if (user && user.name) { + /* __GDPR__ + "issue.userHover" : {} + */ + this.telemetry.sendTelemetryEvent('issues.userHover'); + return new vscode.Hover(userMarkdown(origin, user), range); + } else { + return; + } + } catch (e) { + // No need to notify about a hover that doesn't work + return; + } + } +} diff --git a/src/issues/util.ts b/src/issues/util.ts index 19c448f2be..3e54082abe 100644 --- a/src/issues/util.ts +++ b/src/issues/util.ts @@ -3,64 +3,40 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as marked from 'marked'; +import LRUCache from 'lru-cache'; +import 'url-search-params-polyfill'; import * as vscode from 'vscode'; -import * as path from 'path'; -import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; -import { IssueModel } from '../github/issueModel'; -import { GithubItemStateEnum, User } from '../github/interface'; -import { PullRequestModel } from '../github/pullRequestModel'; import { StateManager } from './stateManager'; -import { ReviewManager } from '../view/reviewManager'; -import { Repository, GitAPI, Remote, Commit, Ref } from '../typings/git'; +import { Ref, Remote, Repository, UpstreamRef } from '../api/api'; +import { GitApiImpl } from '../api/api1'; +import Logger from '../common/logger'; import { Protocol } from '../common/protocol'; -import { getRepositoryForFile } from '../github/utils'; - -export const ISSUE_EXPRESSION = /(([^\s]+)\/([^\s]+))?(#|GH-)([1-9][0-9]*)($|[\s\:\;\-\(\=\)])/; -export const ISSUE_OR_URL_EXPRESSION = /(https?:\/\/github\.com\/(([^\s]+)\/([^\s]+))\/([^\s]+\/)?(issues|pull)\/([0-9]+)(#issuecomment\-([0-9]+))?)|(([^\s]+)\/([^\s]+))?(#|GH-)([1-9][0-9]*)($|[\s\:\;\-\(\=\)])/; +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 type ParsedIssue = { owner: string | undefined, name: string | undefined, issueNumber: number, commentNumber?: number }; -export const ISSUES_CONFIGURATION: string = 'githubIssues'; -export const QUERIES_CONFIGURATION = 'queries'; -export const DEFAULT_QUERY_CONFIGURATION = 'default'; -export const BRANCH_NAME_CONFIGURATION_DEPRECATED = 'workingIssueBranch'; -export const BRANCH_NAME_CONFIGURATION = 'issueBranchTitle'; -export const BRANCH_CONFIGURATION = 'useBranchForIssues'; -export const SCM_MESSAGE_CONFIGURATION = 'workingIssueFormatScm'; - -export function parseIssueExpressionOutput(output: RegExpMatchArray | null): ParsedIssue | undefined { - if (!output) { - return undefined; - } - const issue: ParsedIssue = { owner: undefined, name: undefined, issueNumber: 0 }; - if (output.length === 7) { - issue.owner = output[2]; - issue.name = output[3]; - issue.issueNumber = parseInt(output[5]); - return issue; - } else if (output.length === 16) { - issue.owner = output[3] || output[11]; - issue.name = output[4] || output[12]; - issue.issueNumber = parseInt(output[7] || output[14]); - issue.commentNumber = output[9] !== undefined ? parseInt(output[9]) : undefined; - return issue; - } else { - return undefined; - } -} - -export async function getIssue(stateManager: StateManager, manager: FolderRepositoryManager, issueValue: string, parsed: ParsedIssue): Promise { - if (stateManager.resolvedIssues.has(issueValue)) { - return stateManager.resolvedIssues.get(issueValue); +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 = manager.getGitHubRemotes(); + const remotes = await manager.getGitHubRemotes(); for (const remote of remotes) { if (!parsed) { const tryParse = parseIssueExpressionOutput(issueValue.match(ISSUE_OR_URL_EXPRESSION)); @@ -74,13 +50,22 @@ export async function getIssue(stateManager: StateManager, manager: FolderReposi issueNumber = parsed.issueNumber; } - if (owner && name && (issueNumber !== undefined)) { + 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) { - stateManager.resolvedIssues.set(issueValue, 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; } } @@ -89,163 +74,11 @@ export async function getIssue(stateManager: StateManager, manager: FolderReposi 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$(location) ${user.location}`); - } - if (date) { - markdown.appendMarkdown(` \r\n$(git-commit) Committed to this repository on ${date}`); - } - if (user.company) { - markdown.appendMarkdown(` \r\n$(jersey) Member of ${user.company}`); - } - return markdown; -} - -function convertHexToRgb(hex: string): { r: number, g: number, b: number } | undefined { - const result = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - return result ? { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16) - } : undefined; -} - -function makeLabel(color: string, text: string): string { - const rgbColor = convertHexToRgb(color); - let textColor: string = 'ffffff'; - if (rgbColor) { - // Color algorithm from https://stackoverflow.com/questions/1855884/determine-font-color-based-on-background-color - const luminance = (0.299 * rgbColor.r + 0.587 * rgbColor.g + 0.114 * rgbColor.b) / 255; - if (luminance > 0.5) { - textColor = '000000'; - } - } - - return `  ${text}  `; - -} - -function findLinksInIssue(body: string, issue: IssueModel): string { - let searchResult = body.search(ISSUE_OR_URL_EXPRESSION); - let position = 0; - while ((searchResult >= 0) && (searchResult < body.length)) { - let newBodyFirstPart: string | undefined; - if (searchResult === 0 || body.charAt(searchResult - 1) !== '&') { - const match = body.substring(searchResult).match(ISSUE_OR_URL_EXPRESSION)!; - 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; - } - newBodyFirstPart = body.slice(0, searchResult) + `[${issueNumberLabel}](https://github.com/${tryParse.owner}/${tryParse.name}/issues/${tryParse.issueNumber})`; - body = newBodyFirstPart + body.slice(searchResult + match[0].length); - } - } - position = newBodyFirstPart ? newBodyFirstPart.length : searchResult + 1; - const newSearchResult = body.substring(position).search(ISSUE_OR_URL_EXPRESSION); - searchResult = newSearchResult > 0 ? position + newSearchResult : newSearchResult; - } - return body; -} - -export const ISSUE_BODY_LENGTH: number = 200; -export function issueMarkdown(issue: IssueModel, context: vscode.ExtensionContext, commentNumber?: number): vscode.MarkdownString { - 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, context)} **${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 = findLinksInIssue(body, issue); - - 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 = 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, context: vscode.ExtensionContext) { - if (issue instanceof PullRequestModel) { - return getIconString(issue); - } - switch (issue.state) { - case GithubItemStateEnum.Open: { - return `![Issue State](${vscode.Uri.file(context.asAbsolutePath(path.join('resources', 'icons', 'issues-green.svg'))).toString()})`; - - } - case GithubItemStateEnum.Closed: { - return `![Issue State](${vscode.Uri.file(context.asAbsolutePath(path.join('resources', 'icons', 'issue-closed-red.svg'))).toString()})`; - } - } -} - export interface NewIssue { document: vscode.TextDocument; lineNumber: number; @@ -254,15 +87,56 @@ export interface NewIssue { 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(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; +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. @@ -270,41 +144,61 @@ async function getUpstream(repository: Repository, commit: Commit): Promise { - 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; - }); + 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)) { + 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++) { - 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; + 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 } } } @@ -312,81 +206,276 @@ async function getUpstream(repository: Repository, commit: Commit): Promise { - let document: vscode.TextDocument; - let range: vscode.Range; - if (!positionInfo && vscode.window.activeTextEditor) { - document = vscode.window.activeTextEditor.document; +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) { - document = positionInfo.document; + uri = positionInfo.document.uri; range = positionInfo.range; } else { - return { permalink: undefined, error: 'No active text editor position to create permalink from.' }; + 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, document.uri); + const repository = getRepositoryForFile(gitAPI, uri); if (!repository) { - return { permalink: undefined, error: 'The current file isn\'t part of repository.' }; + return { permalink: undefined, error: vscode.l10n.t('The current file isn\'t part of repository.'), originalFile: uri, range }; } - const log = await repository.log({ maxEntries: 1, path: document.uri.fsPath }); - if (log.length === 0) { - return { permalink: undefined, error: 'No branch on a remote contains the most recent commit for the file.' }; + let commitHash: string | undefined; + if (uri.scheme === Schemes.Review) { + commitHash = fromReviewUri(uri.query).commit; } - const upstream: Remote | undefined = await Promise.race([getUpstream(repository, log[0]), - new Promise(resolve => { - setTimeout(() => { - if (repository.state.HEAD?.upstream) { - for (const remote of repository.state.remotes) { - if (repository.state.HEAD.upstream.remote === remote.name) { - resolve(remote); - } - } + 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 }; } - }, 2000); - }) - ]); - if (!upstream || !upstream.fetchUrl) { - return { permalink: undefined, error: 'There is no suitable remote.' }; + // 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 pathSegment = document.uri.path.substring(repository.rootUri.path.length); - return { permalink: `https://github.com/${new Protocol(upstream.fetchUrl).nameWithOwner}/blob/${log[0].hash}${pathSegment}#L${range.start.line + 1}-L${range.end.line + 1}`, error: undefined }; + 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; } -const VARIABLE_PATTERN = /\$\{(.*?)\}/g; -export async function variableSubstitution(value: string, issueModel?: IssueModel, defaults?: PullRequestDefaults, user?: string): Promise { - return value.replace(VARIABLE_PATTERN, (match: string, variable: string) => { - switch (variable) { - case 'user': return user ? user : match; - case 'issueNumber': return issueModel ? `${issueModel.number}` : match; - case 'issueNumberLabel': return issueModel ? `${getIssueNumberLabel(issueModel, defaults)}` : match; - case 'issueTitle': return issueModel ? issueModel.title : match; - case 'repository': return defaults ? defaults.repo : match; - case 'owner': return defaults ? defaults.owner : match; - case 'sanitizedIssueTitle': return issueModel ? issueModel.title.replace(/[~^:?*[\]@\\{}]|\/\//g, '').trim().replace(/\s+/g, '-') : match; // check what characters are permitted - default: return match; - } +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 getIssueNumberLabel(issue: IssueModel, repo?: PullRequestDefaults) { - const parsedIssue: ParsedIssue = { issueNumber: issue.number, owner: undefined, name: undefined }; - if (repo && ((repo.owner.toLowerCase() !== issue.remote.owner.toLowerCase()) || (repo.repo.toLowerCase() !== issue.remote.repositoryName.toLowerCase()))) { - parsedIssue.owner = issue.remote.owner; - parsedIssue.name = issue.remote.repositoryName; +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 getIssueNumberLabelFromParsed(parsedIssue); + 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('/'); } -function getIssueNumberLabelFromParsed(parsed: ParsedIssue) { - if (!parsed.owner || !parsed.name) { - return `#${parsed.issueNumber}`; - } else { - return `${parsed.owner}/${parsed.name}#${parsed.issueNumber}`; +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) { @@ -396,9 +485,13 @@ async function commitWithDefault(manager: FolderRepositoryManager, stateManager: } } -const commitStaged = 'Commit Staged'; -const commitAll = 'Commit All'; -export async function pushAndCreatePR(manager: FolderRepositoryManager, reviewManager: ReviewManager, stateManager: StateManager, draft: boolean = false): Promise { +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) { @@ -407,7 +500,11 @@ export async function pushAndCreatePR(manager: FolderRepositoryManager, reviewMa if (manager.repository.state.workingTreeChanges) { responseOptions.push(commitAll); } - const changesResponse = await vscode.window.showInformationMessage('There are uncommitted changes. Do you want to commit them with the default commit message?', { modal: true }, ...responseOptions); + 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); @@ -417,39 +514,44 @@ export async function pushAndCreatePR(manager: FolderRepositoryManager, reviewMa await commitWithDefault(manager, stateManager, true); break; } - default: return false; + default: + return false; } } if (manager.repository.state.HEAD?.upstream) { await manager.repository.push(); - await reviewManager.createPullRequest(draft); + 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: 'Remote to push to' }); + 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(draft); + await reviewManager.createPullRequest(undefined); return true; } else { - vscode.window.showWarningMessage('The current repository has no remotes to push to. Please set up a remote and try again.'); + 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; - } + const tokenInfo = await vscode.languages.getTokenInformationAtPosition(document, position); + if (tokenInfo.type !== vscode.StandardTokenType.Comment) { + return false; } + return true; } @@ -466,68 +568,7 @@ export function getRootUriFromScmInputUri(uri: vscode.Uri): vscode.Uri | undefin 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 + ' '; - } - 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 + ' '; - } +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..e33fa4a741 --- /dev/null +++ b/src/lm/issueContextProvider.ts @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * 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.ChatAttachContextProvider, vscode.ChatTabContextProvider { + constructor(private readonly _stateManager: StateManager, + private readonly _reposManager: RepositoriesManager, + private readonly _context: vscode.ExtensionContext + ) { } + + async provideChatTabContext(_options: { tab: vscode.Tab; }, _token: vscode.CancellationToken): Promise { + const item = IssueOverviewPanel.getActivePanel()?.getCurrentItem(); + if (item) { + return this._issueToUnresolvedContext(item); + } + } + + resolveAttachChatContext(context: IssueChatContextItem, token: vscode.CancellationToken): vscode.ProviderResult { + return this._resolveChatContext(context, token); + } + + resolveChatTabContext(context: IssueChatContextItem, token: vscode.CancellationToken): vscode.ProviderResult { + return this._resolveChatContext(context, token); + } + + private 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 provideAttachChatContext(_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/pullRequestContextProvider.ts b/src/lm/pullRequestContextProvider.ts new file mode 100644 index 0000000000..99df8474b8 --- /dev/null +++ b/src/lm/pullRequestContextProvider.ts @@ -0,0 +1,184 @@ +/*--------------------------------------------------------------------------------------------- + * 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 Logger from '../common/logger'; +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 WorkspaceContextProvider extends Disposable implements vscode.ChatWorkspaceContextProvider { + private static readonly ID = 'WorkspaceContextProvider'; + + private readonly _onDidChangeWorkspaceChatContext = this._register(new vscode.EventEmitter()); + readonly onDidChangeWorkspaceChatContext = this._onDidChangeWorkspaceChatContext.event; + + constructor( + private readonly _reposManager: RepositoriesManager, + private readonly _git: GitApiImpl + ) { + 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; + } + let defaults; + try { + defaults = await folderManager.getPullRequestDefaults(); + } catch (e) { + // The folder may have no upstream, a detached HEAD, or an upstream that is not a GitHub + // repository. Skip it instead of letting the rejection escape as an unhandled promise. + Logger.debug(`Skipping workspace chat context for folder ${folderManager.repository.rootUri.toString()}: ${e.message ?? e}`, WorkspaceContextProvider.ID); + continue; + } + + 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; + } +} + +export class PullRequestContextProvider extends Disposable implements vscode.ChatAttachContextProvider, vscode.ChatTabContextProvider { + constructor(private readonly _prsTreeModel: PrsTreeModel, + private readonly _reposManager: RepositoriesManager, + private readonly _context: vscode.ExtensionContext + ) { + super(); + } + + async provideAttachChatContext(_token: vscode.CancellationToken): Promise { + const prs = await this._prsTreeModel.getAllPullRequests(this._reposManager.folderManagers[0], false); + return prs.items.map(pr => { + return this._prToUnresolvedContext(pr); + }); + } + + async provideChatTabContext(_options: { tab: vscode.Tab; }, _token: vscode.CancellationToken): Promise { + const item = PullRequestOverviewPanel.getActivePanel()?.getCurrentItem(); + if (item) { + return this._prToUnresolvedContext(item); + } + } + + async resolveAttachChatContext(context: PRChatContextItem, token: vscode.CancellationToken): Promise { + return this._resolveChatContext(context, token); + } + + async resolveChatTabContext(context: PRChatContextItem, token: vscode.CancellationToken): Promise { + return this._resolveChatContext(context, token); + } + + private 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; + } + + 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/skills/address-pr-comments/SKILL.md b/src/lm/skills/address-pr-comments/SKILL.md new file mode 100644 index 0000000000..831dfe3233 --- /dev/null +++ b/src/lm/skills/address-pr-comments/SKILL.md @@ -0,0 +1,72 @@ +--- +name: address-pr-comments +description: "Address review comments (including Copilot comments) on the active pull request. Use when: responding to PR feedback, fixing review comments, resolving PR threads, implementing requested changes from reviewers, addressing code review, fixing PR issues." +argument-hint: "Optionally specify a reviewer name or file to focus on" +--- + +# Address PR Review Comments + +Read the active pull request, identify unresolved review comments and feedback, implement the requested changes, and resolve the threads. + +## When to Use + +- A reviewer has left comments or change requests on the active PR +- You need to systematically work through all open review threads +- You want to respond to or implement reviewer feedback + +## Procedure + +### 1. Read the Active PR + +Call the `github-pull-request_currentActivePullRequest` tool. + +**Refresh logic**: Check whether a refresh is needed before reading: +- Call the tool once *without* `refresh` to get the cached state +- Inspect the `lastUpdatedAt` field in the result +- If the timestamp is **less than 3 minutes ago**, the PR is actively changing - call the tool again with `refresh: true` to ensure you have the latest comments and state +- If the timestamp is older than 3 minutes, proceed with the cached data + +### 2. Identify Unresolved Comments + +From the tool result, collect all feedback that needs action: + +- **`reviewThreads`** array: inline review thread objects with an `id`, `isResolved` flag, `canResolve` flag, `file` path, and nested `comments`. Focus on threads where `isResolved` is `false`. +- **`timelineComments`** array: general PR comments and reviews where `commentType` is `"CHANGES_REQUESTED"` or `"COMMENTED"` + +Group related threads by file (`file` field) to handle them efficiently. + +### 3. Plan Changes + +Before modifying any files: +1. Read each unresolved comment carefully +2. Identify the file and location each comment refers to +3. Determine the minimal correct fix for each, if a fix is needed (not all comments are worthy of a change) +4. Note dependencies between comments (e.g., a rename that affects multiple files) + +### 4. Implement Changes + +Work through the grouped comments file by file: +- Read the relevant file section before editing +- Apply the requested change +- Do not refactor or modify code outside the scope of each comment +- If a comment is unclear or contradictory, note it for a follow-up reply rather than guessing + +### 5. Verify + +After all changes are made: +- Review that each originally unresolved thread has a corresponding code change or a note about why no code change was needed. +- Ensure no unrelated code was modified + +### 6. Resolve Threads + +For each thread that was addressed (either by a code change or by a deliberate decision not to change): +- Call `github-pull-request_resolveReviewThread` with the `id` from the `reviewThreads` array. +- Only resolve threads where `canResolve` is `true`. +- Skip threads that are already resolved (`isResolved: true`) or where `canResolve` is `false`. + +### 7. Summarize + +Provide a concise summary of: +- Which comments were addressed and what changes were made +- Any comments that were intentionally skipped (with reasoning) +- Any follow-up questions for the reviewer diff --git a/src/lm/skills/create-pull-request/SKILL.md b/src/lm/skills/create-pull-request/SKILL.md new file mode 100644 index 0000000000..41af3c3f1e --- /dev/null +++ b/src/lm/skills/create-pull-request/SKILL.md @@ -0,0 +1,98 @@ +--- +name: create-pull-request +description: "Create a GitHub Pull Request from the current or specified branch. Use when: opening a PR, submitting code for review, creating a draft PR, publishing a branch as a pull request, proposing changes to a repository." +argument-hint: "Optionally specify a title, base branch, or whether to create as a draft" +--- + +# Create a GitHub Pull Request + +Gather the necessary information, prepare a clear title and description, then call the tool to open the pull request. + +## When to Use + +- The user wants to open a PR for their current or a specified branch +- The user has finished a feature or fix and wants to submit it for review +- The user wants to create a draft PR to share work in progress +- The user asks to "open a PR", "create a pull request", or "submit for review" + +## Procedure + +### 1. Gather Information + +Determine the required parameters before calling the tool: + +- **Head branch**: If the user has not specified a branch, use workspace or git context to find the current branch name. Do not use `owner:branch` format - pass just the branch name (e.g. `my-feature`). +- **Base branch**: If the user has not specified a base branch, omit it and let the tool use the repository's default branch. +- **Title**: If the user has not provided a title, derive one from the branch name, recent commits, or the user's description of their work (see Best Practices below). +- **Body**: If the user has not provided a description, prepare a concise summary of what changed and why (see Best Practices below). +- **Draft**: Ask or infer whether the PR should be a draft. Default to non-draft unless the user indicates the work is not ready for review. + +### 2. Check for Uncommitted or Unpushed Changes + +Before creating the PR, inspect the working tree state. If you need to run git commands, give an explanation for why the command needs to be run. + +1. **Check for uncommitted changes**: Use the git tool or VS Code SCM context to determine whether there are staged or unstaged file changes. If yes: + - Ask the user if they want to commit these changes before opening the PR. + - If they do, help them write a commit message and commit the changes (`git add -A && git commit -m ""`). + - If they decline, proceed only if there are already commits on the branch that are ahead of the base - otherwise there is nothing to put in the PR. + +2. **Check for unpushed commits**: Determine whether the local branch has commits that have not been pushed to the remote (i.e. the branch is ahead of its upstream). If yes: + - Ask the user if they want to push before opening the PR, or let them know the tool will attempt to push automatically if needed. + - If pushing manually is preferred, run `git push` (or `git push --set-upstream origin ` if no upstream is set yet) before calling the tool. + +3. **Confirm the branch is on the remote**: The `create_pull_request` tool requires the head branch to be present on the remote. If it is not, push it first. + +If all changes are already committed and pushed, proceed directly to the next step. + +### 3. Prepare PR Details + +Write a good title and description if the user has not provided them: + +**Title**: Use imperative mood, keep it under 72 characters, and describe *what* the PR does (e.g. `Add retry logic for failed API requests`). + +**Body**: Include: +- A short summary of what changed and why +- Any relevant issue references (e.g. `Fixes #123`) +- Notable implementation decisions, if useful for the reviewer + +### 4. Call the Tool + +Use the `github-pull-request_create_pull_request` tool with the gathered parameters: + +``` +github-pull-request_create_pull_request({ + title: '', + head: '', // branch name only, not owner:branch + body: '', // optional but recommended + base: '', // optional; omit to use repo default + draft: false, // set true for work-in-progress + headOwner: '', // optional; omit if same as repo owner + repo: { owner: '', name: '' } // optional +}) +``` + +### 5. Confirm Result + +After the tool returns successfully: + +- Report the PR number and URL to the user as a markdown link. The link should be a VS Code URI like `vscode-insiders://github.vscode-pull-request-github/open-pull-request-webview?uri=https://github.com/microsoft/vscode-css-languageservice/pull/460` or `vscode://github.vscode-pull-request-github/open-pull-request-webview?uri=https://github.com/microsoft/vscode-css-languageservice/pull/460`. +- Mention the base branch the PR targets. +- If the PR was created as a draft, remind the user to mark it ready for review when appropriate. + +## Best Practices + +### Titles +- Use the imperative mood: `Fix`, `Add`, `Update`, `Remove`, `Refactor` - not `Fixed`, `Adding`, etc. +- Be specific: `Fix null pointer in user login flow` beats `Fix bug`. +- Keep it under 72 characters so it displays cleanly in GitHub and email notifications. + +### Descriptions +- Start with a one-sentence summary. +- Explain *why* the change is needed, not just *what* it does - reviewers benefit from context. +- Reference related issues with `Fixes #` or `Closes #` to auto-close them on merge. +- If the change is large, add a brief list of the main files or components touched. + +### Draft PRs +- Use `draft: true` when the code is not yet ready for formal review (e.g. work in progress, awaiting feedback on approach, CI not yet passing). +- Draft PRs are visible to collaborators but will not show as review-requested until marked ready. +- Suggest using a draft when the user mentions they are still working on it or just want early feedback. diff --git a/src/lm/skills/form-github-search-query/SKILL.md b/src/lm/skills/form-github-search-query/SKILL.md new file mode 100644 index 0000000000..9c04763cf0 --- /dev/null +++ b/src/lm/skills/form-github-search-query/SKILL.md @@ -0,0 +1,90 @@ +--- +name: form-github-search-query +description: Forms a GitHub search query based on a natural language query and the type of search (issue or PR). This skill helps users create effective search queries to find relevant issues or pull requests on GitHub. +--- + +# Form GitHub Search Query + +## Purpose + +GitHub has a specific syntax for searching issues and pull requests. This skill takes a natural language query from the user and the type of search they want to perform (issue or PR) and converts it into a properly formatted GitHub search query. This allows users to leverage GitHub's powerful search capabilities without needing to know the specific syntax. + +## Usage + +To use this skill, provide a natural language query and specify whether you want to search for issues or pull requests. The skill will then analyze the input and generate a GitHub search query that can be used to find relevant results on GitHub. + +## Converting Natural Language to GitHub Search Syntax + +### Steps + +1. Identify if there's a repo mention in the query. +2. Fetch labels for the repo if mentioned. +3. Follow the "Tips for Forming Effective Search Queries" to convert the natural language query into GitHub search syntax. + +### Search Syntax Overview + +- 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)'] } + +### Example 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 + +### Tips for Forming Effective Search Queries + +- Always try to include "repo:" or "org:" in your response. +- "repo" is often formated as "owner/name". +- If the user specifies a repo, ALWAYS fetch the labels for that repo and try to match any words in the natural language query to the label names to include them in the search query (See "Adding Labels to the Search Query" section). +- Words in inline codeblocks are likely to refer to labels. Try to match them to labels in the repo and include them in the search 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". +- 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. + +### Adding Labels to the Search Query + +- 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. +- Only choose labels that you're sure are relevant. Having no labels is preferable than labels that aren't relevant. +- Don't choose labels that the user has explicitly excluded. diff --git a/src/lm/skills/show-github-search-result/SKILL.md b/src/lm/skills/show-github-search-result/SKILL.md new file mode 100644 index 0000000000..4fa4bc7d3c --- /dev/null +++ b/src/lm/skills/show-github-search-result/SKILL.md @@ -0,0 +1,24 @@ +--- +name: show-github-search-result +description: Summarizes the results of a GitHub search query in a human friendly markdown table that is easy to read and understand. ALWAYS use this skill when displaying the results of a GitHub search query. +user-invokable: false +--- + +# Render GitHub Search Result + +## Purpose + +To take the results of a GitHub search query, which may include issues or pull requests, and render them in a human-friendly markdown table format. This skill extracts the relevant information from each search result and organizes it in a way that is easy to read and understand, allowing users to quickly grasp the key details of each issue or pull request without having to parse through raw data. + +## Usage + +To use this skill, pass raw search results from a GitHub search query. The skill will then process the data and generate a markdown table that summarizes the key information for each issue or pull request, such as the title, author, labels, state, and any other relevant details. This makes it easier for users to review and analyze the search results at a glance. + +## How to Render GitHub Search Results + +- If you have the original query, use that to help determine the most important fields to include in the table. Ex: + - If the query included a specific label, make sure to not include that label in the table as all results will have it. + - If the query included "is:pr", then focus on fields relevant to pull requests such as "review status" and "merge status". + - Include a column related to the sort value, if given. + - Don't include columns that will all have the same value for all the resulting issues. +- Always include a column for the number and title of the item. Format the number as a markdown link to the issue or PR. Ex: [#123](https://github.com/owner/repo/issues/123) \ No newline at end of file diff --git a/src/lm/skills/suggest-fix-issue/SKILL.md b/src/lm/skills/suggest-fix-issue/SKILL.md new file mode 100644 index 0000000000..612cca9772 --- /dev/null +++ b/src/lm/skills/suggest-fix-issue/SKILL.md @@ -0,0 +1,20 @@ +--- +name: suggest-fix-issue +description: Given the details of an issue, suggests a fix for the issue. +--- + +# Suggest Fix for Issue + +## Purpose + +To propose a potential solution for a given issue based on its details. This skill analyzes the issue's content, including its description, comments, and any relevant context, to generate a suggestion that could help resolve the issue effectively. + +## Usage + +To use this skill, provide the details of an issue, including its description, comments, and any relevant context. The skill will then analyze the information and generate a suggestion for how to fix the issue. This can be particularly useful for developers looking for guidance on how to address a problem or for those who may be new to troubleshooting issues. + +## How to suggest a fix + +- Carefully read through the issue's description and comments to understand the problem fully. +- Where possible output code-blocks and reference real files in the workspace with the fix. +- If the issue is related to a bug, try to identify the root cause and suggest a fix that addresses it directly. \ No newline at end of file diff --git a/src/lm/skills/summarize-github-issue-pr-notification/SKILL.md b/src/lm/skills/summarize-github-issue-pr-notification/SKILL.md new file mode 100644 index 0000000000..30c37baefa --- /dev/null +++ b/src/lm/skills/summarize-github-issue-pr-notification/SKILL.md @@ -0,0 +1,26 @@ +--- +name: summarize-github-issue-pr-notification +description: Summarizes the content of a GitHub issue, pull request (PR), or notification, providing a concise overview of the main points and key details. ALWAYS use the skill when asked to summarize an issue, PR, or notification. +--- + +# Summarize Issue + +## Purpose + +Given a json GitHub issue, PR, or notification, this skill summarizes the content, providing a concise overview of the main points and key details. This skill helps users quickly understand the essence of an issue without having to read through the entire content. + +## Usage + +To use this skill, provide a JSON representation of a GitHub issue. The skill will extract the relevant information and generate a summary that captures the main points and key details of the issue. + +## Tips on How to Summarize an Issue + +- 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. +- 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) +- Comments should be summarized with the author first. Ex: + - @username: This is a comment that summarizes the main point of the 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/activePullRequestTool.ts b/src/lm/tools/activePullRequestTool.ts new file mode 100644 index 0000000000..40b5a7a154 --- /dev/null +++ b/src/lm/tools/activePullRequestTool.ts @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; + +interface PullRequestToolParams { + refresh?: boolean; +} + +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')]); + } + + if ((options.input as PullRequestToolParams | undefined)?.refresh) { + await Promise.all([ + pullRequest.githubRepository.getPullRequest(pullRequest.number, 'ActivePullRequestTool.invoke'), + pullRequest.getTimelineEvents(), + pullRequest.initializeReviewThreadCacheAndReviewComments(), + ]); + } + + const timeline = (pullRequest.timelineEvents && pullRequest.timelineEvents.length > 0) ? pullRequest.timelineEvents : await pullRequest.getTimelineEvents(); + const reviewAndCommentEvents = timeline?.filter((event): event is ReviewEvent | CommentEvent => event.event === EventType.Reviewed || event.event === EventType.Commented) || []; + + if ((pullRequest.comments.length === 0) && (reviewAndCommentEvents.length !== 0)) { + // Probably missing some comments + await pullRequest.initializeReviewThreadCacheAndReviewComments(); + } + + const pullRequestInfo = { + title: pullRequest.title, + body: pullRequest.body, + author: pullRequest.author, + assignees: pullRequest.assignees, + reviewThreads: pullRequest.reviewThreadsCache.map(thread => { + return { + id: thread.id, + isResolved: thread.isResolved, + canResolve: thread.viewerCanResolve, + file: thread.path, + comments: thread.comments.map(c => ({ + author: c.user?.login, + body: c.body, + })), + }; + }), + timelineComments: reviewAndCommentEvents.map(event => { + return { + author: event.user?.login, + body: event.body, + commentType: event.event === EventType.Reviewed ? event.state : 'COMMENTED', + }; + }), + state: pullRequest.state, + 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'}.`; + } + }), + lastUpdatedAt: new Date(pullRequest.updatedAt).toLocaleString() + }; + + 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_currentActivePullRequest'; + + 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/createPullRequestTool.ts b/src/lm/tools/createPullRequestTool.ts new file mode 100644 index 0000000000..fb00192972 --- /dev/null +++ b/src/lm/tools/createPullRequestTool.ts @@ -0,0 +1,104 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CredentialStore } from '../../github/credentials'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; +import { RepositoriesManager } from '../../github/repositoriesManager'; + +interface CreatePullRequestToolParameters { + title: string; + body?: string; + head: string; + headOwner?: string; + base?: string; + draft?: boolean; + repo?: { + owner?: string; + name?: string; + }; +} + +interface ResolvedPullRequestParams { + owner: string; + name: string; + head: string; + base: string; + folderManager: FolderRepositoryManager; +} + +export class CreatePullRequestTool extends RepoToolBase { + public static readonly toolId = 'github-pull-request_create_pull_request'; + + constructor(credentialStore: CredentialStore, repositoriesManager: RepositoriesManager) { + super(credentialStore, repositoriesManager); + } + + private async resolveParams(input: CreatePullRequestToolParameters): Promise { + const { owner, name, folderManager } = await this.getRepoInfo({ owner: input.repo?.owner, name: input.repo?.name }); + const defaults = await folderManager.getPullRequestDefaults(); + const headOwner = input.headOwner ?? defaults.owner; + const head = `${headOwner}:${input.head}`; + const base = input.base ?? defaults.base; + return { owner, name, head, base, folderManager }; + } + + async invoke(options: vscode.LanguageModelToolInvocationOptions, _token: vscode.CancellationToken): Promise { + const { owner, name, head, base, folderManager } = await this.resolveParams(options.input); + + const result = await folderManager.createPullRequest({ + owner, + repo: name, + title: options.input.title, + body: options.input.body, + head, + base, + draft: options.input.draft, + }); + + if (!result) { + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart('Failed to create the pull request.')]); + } + + const prInfo = { + number: result.number, + title: result.title, + body: result.body, + url: result.html_url, + isDraft: result.isDraft, + state: result.state, + base: result.base?.ref, + head: result.head?.ref, + }; + + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(JSON.stringify(prInfo))]); + } + + async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions): Promise { + const resolved = await this.resolveParams(options.input); + const { owner, name, base } = resolved; + // resolved.head is "owner:branch"; extract just the branch part for display + const headBranch = resolved.head.slice(resolved.head.indexOf(':') + 1); + + const repoLabel = `${owner}/${name}`; + const message = new vscode.MarkdownString(); + message.appendMarkdown(`**Title:** ${options.input.title}\n\n`); + if (options.input.body) { + message.appendMarkdown(`**Description:** ${options.input.body}\n\n`); + } + message.appendMarkdown(`**Branch:** \`${headBranch}\` → \`${base}\`\n\n`); + message.appendMarkdown(`**Repository:** ${repoLabel}\n\n`); + + return { + invocationMessage: vscode.l10n.t('Creating pull request'), + confirmationMessages: { + title: vscode.l10n.t('Create Pull Request'), + message, + }, + }; + } +} 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/fetchLabelsTool.ts b/src/lm/tools/fetchLabelsTool.ts new file mode 100644 index 0000000000..0b09746765 --- /dev/null +++ b/src/lm/tools/fetchLabelsTool.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. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import * as vscode from 'vscode'; +import { RepoToolBase } from './toolsUtils'; + +interface FetchLabelsToolParameters { + repo?: { + owner?: string; + name?: string; + }; +} + +export interface FetchLabelsResult { + owner: string; + repo: string; + labels: { + name: string; + color: string; + description?: string; + }[]; +} + +export class FetchLabelsTool extends RepoToolBase { + public static readonly toolId = 'github-pull-request_labels_fetch'; + + 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 labels = await folderManager.getLabels(undefined, { owner, repo: name }); + const result: FetchLabelsResult = { + owner, + repo: name, + labels: labels.map(label => ({ + name: label.name, + color: label.color, + description: label.description + })) + }; + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(JSON.stringify(result)), + new vscode.LanguageModelTextPart('Above is a stringified JSON representation of the labels for the repository.') + ]); + } + + async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions): Promise { + 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}/labels` : undefined; + const message = url + ? new vscode.MarkdownString(vscode.l10n.t('Fetching labels from [{0}/{1}]({2})', owner, name, url)) + : vscode.l10n.t('Fetching labels from GitHub'); + return { + invocationMessage: message, + }; + } +} 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..58ac7f364d --- /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_pullRequestInViewport'; + + protected _findActivePullRequest(): PullRequestModel | undefined { + // First check if there's an active PR overview panel + const panelPR = PullRequestOverviewPanel.getActivePanel()?.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/pullRequestStatusChecksTool.ts b/src/lm/tools/pullRequestStatusChecksTool.ts new file mode 100644 index 0000000000..6f9ffa0fc6 --- /dev/null +++ b/src/lm/tools/pullRequestStatusChecksTool.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * 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 Logger from '../../common/logger'; +import { CheckState } from '../../github/interface'; +import { PullRequestModel } from '../../github/pullRequestModel'; + +interface StatusChecksToolParameters { + pullRequestNumber: number; + repo?: { + owner?: string; + name?: string; + }; +} + +export class PullRequestStatusChecksTool extends RepoToolBase { + public static readonly toolId = 'github-pull-request_pullRequestStatusChecks'; + + async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions): Promise { + if (!options.input.pullRequestNumber) { + return { + invocationMessage: vscode.l10n.t('Fetching status checks 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}/pull/${options.input.pullRequestNumber}` : undefined; + const message = url + ? new vscode.MarkdownString(vscode.l10n.t('Fetching status checks for [#{0}]({1}) from GitHub', options.input.pullRequestNumber, url)) + : vscode.l10n.t('Fetching status checks for #{0} from GitHub', options.input.pullRequestNumber); + return { + invocationMessage: message, + }; + } + + async invoke(options: vscode.LanguageModelToolInvocationOptions, _token: vscode.CancellationToken): Promise { + const pullRequestNumber = options.input.pullRequestNumber; + if (!pullRequestNumber) { + throw new Error('No 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, pullRequestNumber); + if (!(issueOrPullRequest instanceof PullRequestModel)) { + throw new Error(`No pull request found for ${owner}/${name}#${pullRequestNumber}. Make sure the pull request exists.`); + } + + const pullRequest = issueOrPullRequest; + const status = await pullRequest.getStatusChecks(); + const statuses = status[0]?.statuses ?? []; + + // Return all status checks, but only fetch logs for failures + const statusChecks = await Promise.all(statuses.map(async (s) => { + const entry: Record = { + context: s.context, + description: s.description, + state: s.state, + name: s.workflowName, + targetUrl: s.targetUrl, + }; + if (s.state === CheckState.Failure && s.isCheckRun && s.databaseId) { + try { + entry.logs = await pullRequest.githubRepository.getCheckRunLogs(s.databaseId); + } catch (e) { + Logger.error(`Failed to fetch check run logs for ${s.context}: ${e}`, 'PullRequestStatusChecksTool'); + } + } + return entry; + })); + + const statusChecksInfo = { + statusChecks, + reviewRequirements: { + approvalsNeeded: status[1]?.count ?? 0, + currentApprovals: status[1]?.approvals.length ?? 0, + areChangesRequested: (status[1]?.requestedChanges.length ?? 0) > 0, + }, + }; + + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(JSON.stringify(statusChecksInfo))]); + } +} diff --git a/src/lm/tools/resolveReviewThreadTool.ts b/src/lm/tools/resolveReviewThreadTool.ts new file mode 100644 index 0000000000..d703cf1cfb --- /dev/null +++ b/src/lm/tools/resolveReviewThreadTool.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { PullRequestModel } from '../../github/pullRequestModel'; +import { RepositoriesManager } from '../../github/repositoriesManager'; + +interface ResolveReviewThreadToolParameters { + threadId: string; +} + +export class ResolveReviewThreadTool implements vscode.LanguageModelTool { + public static readonly toolId = 'github-pull-request_resolveReviewThread'; + + constructor(private readonly folderManagers: RepositoriesManager) { } + + private _findActivePullRequest(): PullRequestModel | undefined { + const folderManager = this.folderManagers.folderManagers.find((manager) => manager.activePullRequest); + return folderManager?.activePullRequest; + } + + async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions): Promise { + const pullRequest = this._findActivePullRequest(); + const threadId = options.input?.threadId; + + if (!pullRequest) { + return { + invocationMessage: vscode.l10n.t('Resolving review thread'), + }; + } + + const thread = pullRequest.reviewThreadsCache.find(t => t.id === threadId); + const file = thread?.path ? ` in \`${thread.path}\`` : ''; + const firstComment = thread?.comments[0]?.body; + const snippet = firstComment ? `: "${firstComment.length > 60 ? firstComment.slice(0, 57) + '...' : firstComment}"` : ''; + + return { + invocationMessage: vscode.l10n.t('Resolving review thread{0}{1}', file, snippet), + }; + } + + async invoke(options: vscode.LanguageModelToolInvocationOptions, _token: vscode.CancellationToken): Promise { + const pullRequest = this._findActivePullRequest(); + if (!pullRequest) { + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart('There is no active pull request.')]); + } + + const { threadId } = options.input; + if (!threadId) { + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart('No threadId provided.')]); + } + + const thread = pullRequest.reviewThreadsCache.find(t => t.id === threadId); + if (!thread) { + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(`Review thread with id "${threadId}" not found on the active pull request.`)]); + } + + if (thread.isResolved) { + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(`Review thread "${threadId}" is already resolved.`)]); + } + + if (!thread.viewerCanResolve) { + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(`You do not have permission to resolve review thread "${threadId}".`)]); + } + + await pullRequest.resolveReviewThread(threadId); + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(`Review thread "${threadId}" resolved successfully.`)]); + } +} diff --git a/src/lm/tools/searchTools.ts b/src/lm/tools/searchTools.ts new file mode 100644 index 0000000000..92d623e7c5 --- /dev/null +++ b/src/lm/tools/searchTools.ts @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * 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 Logger from '../../common/logger'; +import { escapeMarkdown } from '../../issues/util'; + + +interface ConvertToQuerySyntaxResult { + query: string; + repo?: { + owner?: string; + name?: string; + }; +} + +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/tools.ts b/src/lm/tools/tools.ts new file mode 100644 index 0000000000..5d122caa42 --- /dev/null +++ b/src/lm/tools/tools.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ActivePullRequestTool } from './activePullRequestTool'; +import { CreatePullRequestTool } from './createPullRequestTool'; +import { FetchIssueTool } from './fetchIssueTool'; +import { FetchLabelsTool } from './fetchLabelsTool'; +import { FetchNotificationTool } from './fetchNotificationTool'; +import { OpenPullRequestTool } from './openPullRequestTool'; +import { PullRequestStatusChecksTool } from './pullRequestStatusChecksTool'; +import { ResolveReviewThreadTool } from './resolveReviewThreadTool'; +import { SearchTool } from './searchTools'; +import { CredentialStore } from '../../github/credentials'; +import { RepositoriesManager } from '../../github/repositoriesManager'; + +export function registerTools(context: vscode.ExtensionContext, credentialStore: CredentialStore, repositoriesManager: RepositoriesManager) { + registerFetchingTools(context, credentialStore, repositoriesManager); + registerSearchTools(context, credentialStore, repositoriesManager); + context.subscriptions.push(vscode.lm.registerTool(ActivePullRequestTool.toolId, new ActivePullRequestTool(repositoriesManager))); + context.subscriptions.push(vscode.lm.registerTool(OpenPullRequestTool.toolId, new OpenPullRequestTool(repositoriesManager))); + context.subscriptions.push(vscode.lm.registerTool(PullRequestStatusChecksTool.toolId, new PullRequestStatusChecksTool(credentialStore, repositoriesManager))); + context.subscriptions.push(vscode.lm.registerTool(CreatePullRequestTool.toolId, new CreatePullRequestTool(credentialStore, repositoriesManager))); + context.subscriptions.push(vscode.lm.registerTool(ResolveReviewThreadTool.toolId, new ResolveReviewThreadTool(repositoriesManager))); +} + +function registerFetchingTools(context: vscode.ExtensionContext, credentialStore: CredentialStore, repositoriesManager: RepositoriesManager) { + context.subscriptions.push(vscode.lm.registerTool(FetchIssueTool.toolId, new FetchIssueTool(credentialStore, repositoriesManager))); + context.subscriptions.push(vscode.lm.registerTool(FetchLabelsTool.toolId, new FetchLabelsTool(credentialStore, repositoriesManager))); + context.subscriptions.push(vscode.lm.registerTool(FetchNotificationTool.toolId, new FetchNotificationTool(credentialStore, repositoriesManager))); +} + +function registerSearchTools(context: vscode.ExtensionContext, credentialStore: CredentialStore, repositoriesManager: RepositoriesManager) { + context.subscriptions.push(vscode.lm.registerTool(SearchTool.toolId, new SearchTool(credentialStore, repositoriesManager))); +} \ 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..07692d35d3 --- /dev/null +++ b/src/lm/tools/toolsUtils.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; + +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() { } + 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) { + super(); + } + + 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..9ce8f846fa --- /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('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..54e5dac295 --- /dev/null +++ b/src/notifications/notificationsManager.ts @@ -0,0 +1,508 @@ +/*--------------------------------------------------------------------------------------------- + * 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')) + : (model.stateReason && model.stateReason !== 'COMPLETED' + ? new vscode.ThemeIcon('issue-closed', new vscode.ThemeColor('pullRequests.draft')) + : new vscode.ThemeIcon('issue-closed', new vscode.ThemeColor('github.issues.closed'))); + } + if (notification.subject.type === NotificationSubjectType.PullRequest && model instanceof PullRequestModel) { + item.iconPath = model.isOpen + ? (model.isDraft + ? new vscode.ThemeIcon('git-pull-request-draft', new vscode.ThemeColor('pullRequests.draft')) + : 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 new file mode 100644 index 0000000000..ead80a2baf --- /dev/null +++ b/src/test/browser/index.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * 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'); +import { mockWebviewEnvironment } from '../mocks/mockWebviewEnvironment'; +import { EXTENSION_ID } from '../../constants'; + +async function runAllExtensionTests(testsRoot: string, clb: (error: Error | null, failures?: number) => void): Promise<any> { + // Ensure the dev-mode extension is activated + await vscode.extensions.getExtension(EXTENSION_ID)!.activate(); + + mockWebviewEnvironment.install(global); + + mocha.setup({ + ui: 'bdd', + reporter: undefined + }); + + try { + const importAll = (r: __WebpackModuleApi.RequireContext) => r.keys().forEach(r); + importAll(require.context('../', true, /\.test$/)); + } catch (e) { + console.log(e); + } + + if (process.env.TEST_JUNIT_XML_PATH) { + mocha.reporter('mocha-multi-reporters', { + reporterEnabled: 'mocha-junit-reporter, spec', + mochaJunitReporterReporterOptions: { + mochaFile: process.env.TEST_JUNIT_XML_PATH, + suiteTitleSeparatedBy: ' / ', + outputs: true, + }, + }); + } + + return mocha.run(failures => clb(null, failures)); +} + +export function run(testsRoot: string, clb: (error: Error | null, failures?: number) => void): void { + runAllExtensionTests(testsRoot, clb); +} diff --git a/src/test/browser/runTests.ts b/src/test/browser/runTests.ts new file mode 100644 index 0000000000..fc9e45ff83 --- /dev/null +++ b/src/test/browser/runTests.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 path from 'path'; +import { BrowserType, runTests } from '@vscode/test-web'; + +async function go() { + try { + const extensionDevelopmentPath = path.resolve(__dirname, '../../../'); + const extensionTestsPath = path.resolve(__dirname, './index'); + console.log(extensionDevelopmentPath, extensionTestsPath); + const attachArgName = '--waitForDebugger='; + const waitForDebugger = process.argv.find(arg => arg.startsWith(attachArgName)); + const browserTypeName = '--browserType='; + const browserType = process.argv.find(arg => arg.startsWith(browserTypeName)); + + /** + * Basic usage + */ + await runTests({ + browserType: browserType ? <BrowserType>browserType.slice(browserTypeName.length) : 'chromium', + extensionDevelopmentPath, + extensionTestsPath, + waitForDebugger: waitForDebugger ? Number(waitForDebugger.slice(attachArgName.length)) : undefined, + quality: 'stable' + }); + } catch (e) { + console.log(e); + } +} + +go(); diff --git a/src/test/builders.test.ts b/src/test/builders.test.ts index 1b9c56acf7..a8129f59ea 100644 --- a/src/test/builders.test.ts +++ b/src/test/builders.test.ts @@ -1,5 +1,5 @@ import { createBuilderClass, createLink } from './builders/base'; -import assert = require('assert'); +import { default as assert } from 'assert'; interface IGrandChild { attr: number; @@ -20,7 +20,7 @@ const ChildBuilder = createBuilderClass<IChild>()({ }); const b = new ChildBuilder(); -b.grandchild((gc) => gc.attr(20)); +b.grandchild(gc => gc.attr(20)); interface IParent { aStringProp: string; @@ -42,9 +42,9 @@ describe('Builders', function () { .aStringProp('def') .aNumberProp(456) .aBooleanProp(false) - .aChildProp((child) => { + .aChildProp(child => { child.name('non-default'); - child.grandchild((gc) => { + child.grandchild(gc => { gc.attr(5); }); }) @@ -58,9 +58,7 @@ describe('Builders', function () { }); it('uses default values for unspecified fields', function () { - const parent = new ParentBuilder() - .aNumberProp(1000) - .build(); + const parent = new ParentBuilder().aNumberProp(1000).build(); assert.strictEqual(parent.aStringProp, 'abc'); assert.strictEqual(parent.aNumberProp, 1000); @@ -76,7 +74,7 @@ describe('Builders', function () { numberProp: number; grandchild: { boolProp: boolean; - } + }; }; } @@ -85,9 +83,9 @@ describe('Builders', function () { child: createLink<IInline['child']>()({ numberProp: { default: 123 }, grandchild: createLink<IInline['child']['grandchild']>()({ - boolProp: { default: true } - }) - }) + boolProp: { default: true }, + }), + }), }); const inline = new InlineBuilder() diff --git a/src/test/builders/README.md b/src/test/builders/README.md index 7f504e19b6..66cfd18ab5 100644 --- a/src/test/builders/README.md +++ b/src/test/builders/README.md @@ -29,7 +29,7 @@ interface PullRequest { author: User; head: Repository; // Yeah, these are Refs in the real schema. base: Repository; -}; +} ``` Say the builders for these types already existing in `src/test/builders/example/{user,repository,pullRequest}Builder.ts` modules. We may construct mocked responses for tests using the builders like so: @@ -37,15 +37,12 @@ Say the builders for these types already existing in `src/test/builders/example/ ```ts import { PullRequestBuilder } from '../builders/example/pullRequestBuilder'; -describe('Some model that uses PullRequest responses', function() { - it('does something based on the PR number and title', function() { +describe('Some model that uses PullRequest responses', function () { + it('does something based on the PR number and title', function () { // PullRequestBuilder has a fluent interface with a setter method corresponding to each // property of the PullRequest interface. Its .build() method returns a fully-constructed // PullRequest object. - const pr = new PullRequestBuilder() - .number(1234) - .title('This is the title') - .build(); + const pr = new PullRequestBuilder().number(1234).title('This is the title').build(); // The fields we populated directly will be set to the values we gave them. assert.strictEqual(pr.number, 1234); @@ -60,7 +57,7 @@ describe('Some model that uses PullRequest responses', function() { assert(pr.author.login instanceof string); }); - it('needs to specify some deep property, like the owner of the base repository', function() { + it('needs to specify some deep property, like the owner of the base repository', function () { // Setter methods that construct other objects that have builders work a little differently. // // The "setter" for each instead accepts a function that will be called with an instance of @@ -71,7 +68,7 @@ describe('Some model that uses PullRequest responses', function() { // The type of the builder argument can be inferred from the setter function's signature, // which saves you from needing to import a builder for every nested type. const pr = new PullRequestBuilder() - .base((b) => { + .base(b => { b.owner(o => o.login('someone')); }) .build(); @@ -108,8 +105,8 @@ import { User } from '../../../../src/common/interfaces'; // The odd double-function-call usage here is done so that the type of the template object may be // inferred while explicitly specifying the <User> type parameter. export const UserBuilder = createBuilderClass<User>()({ - login: {default: 'me'}, - avatarUrl: {default: 'https://avatars-r-us.com/me.jpg'}, + login: { default: 'me' }, + avatarUrl: { default: 'https://avatars-r-us.com/me.jpg' }, }); // Exporting the instance type is useful in cases when you wish to create custom builders that reference @@ -132,10 +129,10 @@ export const RepositoryBuilder = createBuilderClass<Repository>()({ // The type constructed by UserBuilder is checked against the type of the "owner" field on Repository // at compile time. The template used to define UserBuilder in user.ts is used to construct a default // value for this field if unspecified. - owner: {linked: UserBuilder}, + owner: { linked: UserBuilder }, - name: {default: 'some-default'}, - viewerCanAdmin: {default: false}, + name: { default: 'some-default' }, + viewerCanAdmin: { default: false }, }); export type RepositoryBuilder = InstanceType<typeof RepositoryBuilder>; @@ -151,12 +148,12 @@ import { UserBuilder } from './userBuilder'; import { RepositoryBuilder } from './repositoryBuilder'; export const PullRequestBuilder = createBuilderClass<PullRequest>()({ - number: {default: 100}, - title: {default: 'default title'}, - description: {default: 'default description'}, - author: {linked: UserBuilder}, - head: {linked: RepositoryBuilder}, - base: {linked: RepositoryBuilder}, + number: { default: 100 }, + title: { default: 'default title' }, + description: { default: 'default description' }, + author: { linked: UserBuilder }, + head: { linked: RepositoryBuilder }, + base: { linked: RepositoryBuilder }, }); export type PullRequestBuilder = InstanceType<typeof PullRequestBuilder>; @@ -194,4 +191,4 @@ export const DeeplyNestedBuilder = createBuilderClass<DeeplyNested>()({ }), }); }); -``` \ No newline at end of file +``` diff --git a/src/test/builders/base.ts b/src/test/builders/base.ts index 85046f2cf7..95a17003cf 100644 --- a/src/test/builders/base.ts +++ b/src/test/builders/base.ts @@ -20,7 +20,7 @@ /** * {@link FieldTemplate} that describes a _scalar_ value; one that is initialized directly by a setter rather than constructed by a sub-builder. * The default value provided is used by the builder if this field's value is not explicitly set. The default value's type must be - * assignable to the corresponding field in the buider's record type. + * assignable to the corresponding field in the builder's record type. * * @example * interface Example { @@ -73,7 +73,9 @@ type FieldTemplate<F, T extends Template<F>> = ScalarFieldTemplate<F> | LinkedFi * * @param fieldTemplate Instance of a field template from some template object. */ -function isLinked<F, T extends Template<F>>(fieldTemplate: FieldTemplate<F, T>): fieldTemplate is LinkedFieldTemplate<F, T> { +function isLinked<F, T extends Template<F>>( + fieldTemplate: FieldTemplate<F, T>, +): fieldTemplate is LinkedFieldTemplate<F, T> { return (fieldTemplate as LinkedFieldTemplate<F, T>).linked !== undefined; } @@ -119,7 +121,9 @@ type ScalarSetterFn<F, Self> = (value: F) => Self; * Conditional type used to infer the call signature of a single setter function on a generated {@link Builder} type based on * the (compile-time) type of a {@link Template} property. */ -type SetterFn<F, FT, Self> = FT extends LinkedFieldTemplate<any, infer T> ? LinkedSetterFn<F, T, Self> : ScalarSetterFn<F, Self>; +type SetterFn<F, FT, Self> = FT extends LinkedFieldTemplate<any, infer T> + ? LinkedSetterFn<F, T, Self> + : ScalarSetterFn<F, Self>; /** * Instance that progressively assembles an object of record type `R` as you call a sequence of {@link SetterFn|setter functions}. @@ -141,7 +145,7 @@ export type Builder<R, T extends Template<R>> = { * Class that constructs {@link Builder} instances for a specific record type `R`, according to a specific template `T`. */ type BuilderClass<R, T extends Template<R>> = { - new(): Builder<R, T>; + new (): Builder<R, T>; }; /** @@ -285,4 +289,4 @@ export function createBuilderClass<R>() { */ export function createLink<R>(): <T extends Template<R>>(template: T) => LinkedFieldTemplate<R, T> { return <T extends Template<R>>(template: T) => ({ linked: createBuilderClass<R>()(template) }); -} \ No newline at end of file +} diff --git a/src/test/builders/graphql/latestReviewCommitBuilder.ts b/src/test/builders/graphql/latestReviewCommitBuilder.ts new file mode 100644 index 0000000000..49c9a42f6f --- /dev/null +++ b/src/test/builders/graphql/latestReviewCommitBuilder.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { LatestReviewCommitResponse } from '../../../github/graphql'; + +import { RateLimitBuilder } from './rateLimitBuilder'; + +type Repository = NonNullable<LatestReviewCommitResponse['repository']>; +type PullRequest = Repository['pullRequest']; +type Reviews = PullRequest['reviews']; + +export const LatestReviewCommitBuilder = createBuilderClass<LatestReviewCommitResponse>()({ + repository: createLink<Repository>()({ + pullRequest: createLink<PullRequest>()({ + reviews: createLink<Reviews>()({ + nodes: { default: [] }, + }), + }), + }), + rateLimit: { linked: RateLimitBuilder }, +}); + +export type LatestReviewCommitBuilder = InstanceType<typeof LatestReviewCommitBuilder>; diff --git a/src/test/builders/graphql/pullRequestBuilder.ts b/src/test/builders/graphql/pullRequestBuilder.ts index 6a28afaf28..cd8b73682e 100644 --- a/src/test/builders/graphql/pullRequestBuilder.ts +++ b/src/test/builders/graphql/pullRequestBuilder.ts @@ -1,24 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { PullRequestResponse, Ref } from '../../../github/graphql'; +import { BaseRefRepository, DefaultCommitMessage, DefaultCommitTitle, PullRequestResponse, Ref, RefRepository } from '../../../github/graphql'; import { RateLimitBuilder } from './rateLimitBuilder'; +import { AccountType } from '../../../github/interface'; -const RefBuilder = createBuilderClass<Ref>()({ - name: { default: 'master' }, - repository: createLink<Ref['repository']>()({ - owner: createLink<Ref['repository']['owner']>()({ - login: { default: 'me' }, - }), - url: { default: 'https://github.com/owner/repo' }, +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 }, target: createLink<Ref['target']>()({ oid: { default: '0000000000000000000000000000000000000000' }, - }) + }), }); -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>()({ @@ -32,23 +55,64 @@ export const PullRequestBuilder = createBuilderClass<PullRequestResponse>()({ body: { default: '**markdown**' }, bodyHTML: { default: '<h1>markdown</h1>' }, title: { default: 'plz merge' }, + titleHTML: { default: 'plz merge' }, + assignees: createLink<AssigneesConn>()({ + nodes: { + default: [ + { + avatarUrl: '', + email: '', + login: 'me', + url: 'https://github.com/me', + id: '123', + __typename: 'User', + name: 'Me' + }, + ], + }, + }), author: createLink<Author>()({ login: { default: 'me' }, - url: { default: 'https//github.com/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' }, headRef: { linked: RefBuilder }, + headRefName: { default: 'pr-branch' }, + headRefOid: { default: '0000000000000000000000000000000000000000' }, + headRepository: { linked: RefRepositoryBuilder }, baseRef: { linked: RefBuilder }, + baseRefName: { default: 'main' }, + baseRefOid: { default: '0000000000000000000000000000000000000000' }, + 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: [] } - }), + suggestedReviewers: { default: [] }, + viewerCanEnableAutoMerge: { 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/rateLimitBuilder.ts b/src/test/builders/graphql/rateLimitBuilder.ts index 310da8588a..66cbe6d365 100644 --- a/src/test/builders/graphql/rateLimitBuilder.ts +++ b/src/test/builders/graphql/rateLimitBuilder.ts @@ -8,4 +8,4 @@ export const RateLimitBuilder = createBuilderClass<RateLimit>()({ resetAt: { default: '3019-01-01T00:00:00Z' }, }); -export type RateLimitBuilder = InstanceType<typeof RateLimitBuilder>; \ No newline at end of file +export type RateLimitBuilder = InstanceType<typeof RateLimitBuilder>; diff --git a/src/test/builders/graphql/timelineEventsBuilder.ts b/src/test/builders/graphql/timelineEventsBuilder.ts index 271c47ec61..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']; @@ -18,4 +23,4 @@ export const TimelineEventsBuilder = createBuilderClass<TimelineEventsResponse>( rateLimit: { linked: RateLimitBuilder }, }); -export type TimelineEventsBuilder = InstanceType<typeof TimelineEventsBuilder>; \ No newline at end of file +export type TimelineEventsBuilder = InstanceType<typeof TimelineEventsBuilder>; diff --git a/src/test/builders/managedPullRequestBuilder.ts b/src/test/builders/managedPullRequestBuilder.ts index 25878ca78e..a1ca3865cc 100644 --- a/src/test/builders/managedPullRequestBuilder.ts +++ b/src/test/builders/managedPullRequestBuilder.ts @@ -1,34 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { PullRequestResponse as PullRequestGraphQL, - TimelineEventsResponse as TimelineEventsGraphQL + TimelineEventsResponse as TimelineEventsGraphQL, + LatestReviewCommitResponse as LatestReviewCommitGraphQL } from '../../github/graphql'; -import { - PullsListRequestedReviewersResponseData as ReviewRequestsREST, - IssuesListEventsForTimelineResponseData as TimelineEventREST, -} from '@octokit/types'; - import { PullRequestBuilder as PullRequestGraphQLBuilder } from './graphql/pullRequestBuilder'; -import { PullRequestBuilder as PullRequestRESTBuilder, PullRequestUnion as PullRequestREST } from './rest/pullRequestBuilder'; +import { + PullRequestBuilder as PullRequestRESTBuilder, + PullRequestUnion as PullRequestREST, +} from './rest/pullRequestBuilder'; import { TimelineEventsBuilder as TimelineEventsGraphQLBuilder } from './graphql/timelineEventsBuilder'; +import { LatestReviewCommitBuilder as LatestReviewCommitGraphQLBuilder } from './graphql/latestReviewCommitBuilder'; import { RepoUnion as RepositoryREST, RepositoryBuilder as RepositoryRESTBuilder } from './rest/repoBuilder'; import { CombinedStatusBuilder as CombinedStatusRESTBuilder } from './rest/combinedStatusBuilder'; import { ReviewRequestsBuilder as ReviewRequestsRESTBuilder } from './rest/reviewRequestsBuilder'; import { createBuilderClass } from './base'; import { PullRequestChecks } from '../../github/interface'; +import { OctokitCommon } from '../../github/common'; type ResponseFlavor<APIFlavor, GQL, RST> = APIFlavor extends 'graphql' ? GQL : RST; export interface ManagedPullRequest<APIFlavor> { pullRequest: ResponseFlavor<APIFlavor, PullRequestGraphQL, PullRequestREST>; - timelineEvents: ResponseFlavor<APIFlavor, TimelineEventsGraphQL, TimelineEventREST[]>; + timelineEvents: ResponseFlavor< + APIFlavor, + TimelineEventsGraphQL, + OctokitCommon.IssuesListEventsForTimelineResponseData[] + >; + latestReviewCommit: ResponseFlavor<APIFlavor, LatestReviewCommitGraphQL, any>; repositoryREST: RepositoryREST; combinedStatusREST: PullRequestChecks; - reviewRequestsREST: ReviewRequestsREST; + reviewRequestsREST: OctokitCommon.PullsListRequestedReviewersResponseData; } export const ManagedGraphQLPullRequestBuilder = createBuilderClass<ManagedPullRequest<'graphql'>>()({ pullRequest: { linked: PullRequestGraphQLBuilder }, timelineEvents: { linked: TimelineEventsGraphQLBuilder }, + latestReviewCommit: { linked: LatestReviewCommitGraphQLBuilder }, repositoryREST: { linked: RepositoryRESTBuilder }, combinedStatusREST: { linked: CombinedStatusRESTBuilder }, reviewRequestsREST: { linked: ReviewRequestsRESTBuilder }, @@ -39,9 +51,10 @@ export type ManagedGraphQLPullRequestBuilder = InstanceType<typeof ManagedGraphQ export const ManagedRESTPullRequestBuilder = createBuilderClass<ManagedPullRequest<'rest'>>()({ pullRequest: { linked: PullRequestRESTBuilder }, timelineEvents: { default: [] }, + latestReviewCommit: { default: 'abc' }, repositoryREST: { linked: RepositoryRESTBuilder }, combinedStatusREST: { linked: CombinedStatusRESTBuilder }, reviewRequestsREST: { linked: ReviewRequestsRESTBuilder }, }); -export type ManagedRESTPullRequestBuilder = InstanceType<typeof ManagedRESTPullRequestBuilder>; \ No newline at end of file +export type ManagedRESTPullRequestBuilder = InstanceType<typeof ManagedRESTPullRequestBuilder>; diff --git a/src/test/builders/rest/combinedStatusBuilder.ts b/src/test/builders/rest/combinedStatusBuilder.ts index e8bb15b67e..dc87383acc 100644 --- a/src/test/builders/rest/combinedStatusBuilder.ts +++ b/src/test/builders/rest/combinedStatusBuilder.ts @@ -1,10 +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 { createBuilderClass } from '../base'; import { OctokitCommon } from '../../../github/common'; -import { PullRequestChecks } from '../../../github/interface'; +import { CheckState, PullRequestChecks } from '../../../github/interface'; export const StatusItemBuilder = createBuilderClass<OctokitCommon.ReposGetCombinedStatusForRefResponseStatusesItem>()({ - url: { default: 'https://api.github.com/repos/octocat/Hello-World/statuses/0000000000000000000000000000000000000000' }, + url: { + default: 'https://api.github.com/repos/octocat/Hello-World/statuses/0000000000000000000000000000000000000000', + }, avatar_url: { default: 'https://github.com/images/error/hubot_happy.gif' }, id: { default: 1 }, node_id: { default: 'MDY6U3RhdHVzMQ==' }, @@ -13,14 +19,14 @@ export const StatusItemBuilder = createBuilderClass<OctokitCommon.ReposGetCombin target_url: { default: 'https://ci.example.com/1000/output' }, context: { default: 'continuous-integration/jenkins' }, created_at: { default: '2012-07-20T01:19:13Z' }, - updated_at: { default: '2012-07-20T01:19:13Z' } + updated_at: { default: '2012-07-20T01:19:13Z' }, }); export type StatusItemBuilder = InstanceType<typeof StatusItemBuilder>; export const CombinedStatusBuilder = createBuilderClass<PullRequestChecks>()({ - state: { default: 'success' }, - statuses: { default: [] } + state: { default: CheckState.Success }, + statuses: { default: [] }, }); -export type CombinedStatusBuilder = InstanceType<typeof CombinedStatusBuilder>; \ No newline at end of file +export type CombinedStatusBuilder = InstanceType<typeof CombinedStatusBuilder>; diff --git a/src/test/builders/rest/organizationBuilder.ts b/src/test/builders/rest/organizationBuilder.ts index 00281e3b91..509ac90858 100644 --- a/src/test/builders/rest/organizationBuilder.ts +++ b/src/test/builders/rest/organizationBuilder.ts @@ -22,4 +22,4 @@ export const OrganizationBuilder = createBuilderClass<OctokitCommon.ReposGetResp site_admin: { default: false }, }); -export type OrganizationBuilder = InstanceType<typeof OrganizationBuilder>; \ No newline at end of file +export type OrganizationBuilder = InstanceType<typeof OrganizationBuilder>; diff --git a/src/test/builders/rest/pullRequestBuilder.ts b/src/test/builders/rest/pullRequestBuilder.ts index 8cdb0d98bc..d65f476c16 100644 --- a/src/test/builders/rest/pullRequestBuilder.ts +++ b/src/test/builders/rest/pullRequestBuilder.ts @@ -1,10 +1,14 @@ -import * as OctokitTypes from '@octokit/types'; +/*--------------------------------------------------------------------------------------------- + * 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'; -export type PullRequestUnion = - OctokitTypes.PullsGetResponseData; +export type PullRequestUnion = OctokitCommon.PullsGetResponseData; type Links = PullRequestUnion['_links']; type Milestone = PullRequestUnion['milestone']; @@ -22,7 +26,9 @@ export const PullRequestBuilder = createBuilderClass<PullRequestUnion>()({ review_comments_url: { default: 'https://api.github.com/repos/octocat/reponame/pulls/1347/comments' }, review_comment_url: { default: 'https://api.github.com/repos/octocat/reponame/pulls/comments{/number}' }, comments_url: { default: 'https://api.github.com/repos/octocat/reponame/issues/1347/comments' }, - statuses_url: { default: 'https://api.github.com/repos/octocat/reponame/statuses/6dcb09b5b57875f334f61aebed695e2e4193db5e' }, + statuses_url: { + default: 'https://api.github.com/repos/octocat/reponame/statuses/6dcb09b5b57875f334f61aebed695e2e4193db5e', + }, locked: { default: false }, title: { default: 'New feature' }, body: { default: 'Please merge thx' }, @@ -39,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 }, @@ -70,34 +76,38 @@ export const PullRequestBuilder = createBuilderClass<PullRequestUnion>()({ created_at: { default: '2019-01-01T10:00:00Z' }, updated_at: { default: '2019-01-01T10:00:00Z' }, closed_at: { default: '2019-01-01T10:00:00Z' }, - due_on: { default: '2019-01-01T10:00:00Z' } + due_on: { default: '2019-01-01T10:00:00Z' }, }), _links: createLink<Links>()({ self: createLink<Links['self']>()({ - href: { default: 'https://api.github.com/repos/octocat/reponame/pulls/1347' } + href: { default: 'https://api.github.com/repos/octocat/reponame/pulls/1347' }, }), html: createLink<Links['html']>()({ - href: { default: 'https://github.com/octocat/reponame/pull/1347' } + href: { default: 'https://github.com/octocat/reponame/pull/1347' }, }), issue: createLink<Links['issue']>()({ - href: { default: 'https://api.github.com/repos/octocat/reponame/issues/1347' } + href: { default: 'https://api.github.com/repos/octocat/reponame/issues/1347' }, }), comments: createLink<Links['comments']>()({ - href: { default: 'https://api.github.com/repos/octocat/reponame/issues/1347/comments' } + href: { default: 'https://api.github.com/repos/octocat/reponame/issues/1347/comments' }, }), review_comments: createLink<Links['review_comments']>()({ - href: { default: 'https://api.github.com/repos/octocat/reponame/pulls/1347/comments' } + href: { default: 'https://api.github.com/repos/octocat/reponame/pulls/1347/comments' }, }), review_comment: createLink<Links['review_comment']>()({ - href: { default: 'https://api.github.com/repos/octocat/reponame/pulls/comments{/number}' } + href: { default: 'https://api.github.com/repos/octocat/reponame/pulls/comments{/number}' }, }), commits: createLink<Links['commits']>()({ - href: { default: 'https://api.github.com/repos/octocat/reponame/pulls/1347/commits' } + href: { default: 'https://api.github.com/repos/octocat/reponame/pulls/1347/commits' }, }), statuses: createLink<Links['statuses']>()({ - href: { default: 'https://api.github.com/repos/octocat/reponame/statuses/6dcb09b5b57875f334f61aebed695e2e4193db5e' } - }) + href: { + default: + 'https://api.github.com/repos/octocat/reponame/statuses/6dcb09b5b57875f334f61aebed695e2e4193db5e', + }, + }), }), + auto_merge: { default: null }, }); -export type PullRequestBuilder = InstanceType<typeof PullRequestBuilder>; \ No newline at end of file +export type PullRequestBuilder = InstanceType<typeof PullRequestBuilder>; diff --git a/src/test/builders/rest/refBuilder.ts b/src/test/builders/rest/refBuilder.ts index 58a6f77857..8e4f070616 100644 --- a/src/test/builders/rest/refBuilder.ts +++ b/src/test/builders/rest/refBuilder.ts @@ -1,18 +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 { UserBuilder } from './userBuilder'; import { RepositoryBuilder } from './repoBuilder'; import { createBuilderClass } from '../base'; import { OctokitCommon } from '../../../github/common'; -type RefUnion = - OctokitCommon.PullsListResponseItemHead & - OctokitCommon.PullsListResponseItemBase; +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 }, sha: { default: '0000000000000000000000000000000000000000' }, - repo: { linked: RepositoryBuilder }, + // Must cast to any here to prevent recursive type. + 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>; \ No newline at end of file +export type RefBuilder = InstanceType<typeof RefBuilder>; diff --git a/src/test/builders/rest/repoBuilder.ts b/src/test/builders/rest/repoBuilder.ts index 6ae468ace8..3f05aaf494 100644 --- a/src/test/builders/rest/repoBuilder.ts +++ b/src/test/builders/rest/repoBuilder.ts @@ -1,11 +1,14 @@ -import * as OctokitTypes from '@octokit/types'; +/*--------------------------------------------------------------------------------------------- + * 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'; -export type RepoUnion = - OctokitTypes.ReposGetResponseData & +export type RepoUnion = OctokitCommon.ReposGetResponseData & OctokitCommon.PullsListResponseItemHeadRepo & OctokitCommon.PullsListResponseItemBaseRepo; @@ -13,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' }, @@ -54,7 +57,9 @@ export const RepositoryBuilder = createBuilderClass<RepoUnion>()({ languages_url: { default: 'http://api.github.com/repos/octocat/reponame/languages' }, merges_url: { default: 'http://api.github.com/repos/octocat/reponame/merges' }, milestones_url: { default: 'http://api.github.com/repos/octocat/reponame/milestones{/number}' }, - notifications_url: { default: 'http://api.github.com/repos/octocat/reponame/notifications{?since,all,participating}' }, + notifications_url: { + default: 'http://api.github.com/repos/octocat/reponame/notifications{?since,all,participating}', + }, pulls_url: { default: 'http://api.github.com/repos/octocat/reponame/pulls{/number}' }, releases_url: { default: 'http://api.github.com/repos/octocat/reponame/releases{/id}' }, ssh_url: { default: 'git@github.com:octocat/reponame.git' }, @@ -82,7 +87,7 @@ export const RepositoryBuilder = createBuilderClass<RepoUnion>()({ stargazers_count: { default: 80 }, watchers_count: { default: 80 }, size: { default: 108 }, - default_branch: { default: 'master' }, + default_branch: { default: 'main' }, open_issues_count: { default: 0 }, topics: { default: [] }, has_issues: { default: true }, @@ -90,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' }, @@ -99,7 +105,7 @@ export const RepositoryBuilder = createBuilderClass<RepoUnion>()({ push: { default: false }, pull: { default: true }, maintain: { default: false }, - triage: { default: false } + triage: { default: false }, }), allow_rebase_merge: { default: true }, allow_squash_merge: { default: true }, @@ -115,8 +121,11 @@ export const RepositoryBuilder = createBuilderClass<RepoUnion>()({ html_url: { default: 'https://github.com/octocat/reponame' }, key: { default: 'key' }, name: { default: 'name' }, - url: { default: 'https://github.com/octocat/reponame' } - }) + url: { default: 'https://github.com/octocat/reponame' }, + }), + forks: { default: 0 }, + open_issues: { default: 0 }, + watchers: { default: 0 }, }); -export type RepositoryBuilder = InstanceType<typeof RepositoryBuilder>; \ No newline at end of file +export type RepositoryBuilder = InstanceType<typeof RepositoryBuilder>; diff --git a/src/test/builders/rest/reviewRequestsBuilder.ts b/src/test/builders/rest/reviewRequestsBuilder.ts index dc46c904c8..bd7f3e4ab2 100644 --- a/src/test/builders/rest/reviewRequestsBuilder.ts +++ b/src/test/builders/rest/reviewRequestsBuilder.ts @@ -1,10 +1,10 @@ -import * as OctokitTypes from '@octokit/types'; +import { OctokitCommon } from '../../../github/common'; import { createBuilderClass } from '../base'; -export const ReviewRequestsBuilder = createBuilderClass<OctokitTypes.PullsListRequestedReviewersResponseData>()({ +export const ReviewRequestsBuilder = createBuilderClass<OctokitCommon.PullsListRequestedReviewersResponseData>()({ users: { default: [] }, teams: { default: [] }, }); -export type ReviewRequestsBuilder = InstanceType<typeof ReviewRequestsBuilder>; \ No newline at end of file +export type ReviewRequestsBuilder = InstanceType<typeof ReviewRequestsBuilder>; diff --git a/src/test/builders/rest/teamBuilder.ts b/src/test/builders/rest/teamBuilder.ts index b51803096e..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'; @@ -14,8 +19,8 @@ export const TeamBuilder = createBuilderClass<TeamUnion>()({ permission: { default: 'admin' }, members_url: { default: 'https://api.github.com/teams/1/members{/member}' }, repositories_url: { default: 'https://api.github.com/teams/1/repos' }, - parent: { default: { test: 'test' } }, - html_url: { default: 'https://api.github.com/teams/1' } + html_url: { default: 'https://api.github.com/teams/1' }, + parent: { default: null } }); -export type TeamBuilder = InstanceType<typeof TeamBuilder>; \ No newline at end of file +export type TeamBuilder = InstanceType<typeof TeamBuilder>; diff --git a/src/test/builders/rest/timelineEventItemBuilder.ts b/src/test/builders/rest/timelineEventItemBuilder.ts index 255d5cee05..ec3510632b 100644 --- a/src/test/builders/rest/timelineEventItemBuilder.ts +++ b/src/test/builders/rest/timelineEventItemBuilder.ts @@ -1,17 +1,31 @@ -import * as OctokitTypes from '@octokit/types'; - import { UserBuilder } from './userBuilder'; import { createBuilderClass } from '../base'; +import { OctokitCommon } from '../../../github/common'; -export const TimelineEventItemBuilder = createBuilderClass<OctokitTypes.IssuesListEventsForTimelineResponseData[0]>()({ +export const TimelineEventItemBuilder = createBuilderClass<OctokitCommon.IssuesListEventsForTimelineResponseData[0]>()({ id: { default: 1 }, node_id: { default: 'MDEwOklzc3VlRXZlbnQx' }, url: { default: 'https://api.github.com/repos/octocat/Hello-World/issues/events/1' }, actor: { linked: UserBuilder }, event: { default: 'closed' }, commit_id: { default: '0000000000000000000000000000000000000000' }, - commit_url: { default: 'https://api.github.com/repos/octocat/Hello-World/commits/0000000000000000000000000000000000000000' }, + commit_url: { + default: 'https://api.github.com/repos/octocat/Hello-World/commits/0000000000000000000000000000000000000000', + }, created_at: { default: '2019-01-01T10:00:00Z' }, + sha: { default: '00000000000000000000000000000000' }, + author_association: { default: 'COLLABORATOR' }, + body: { default: '' }, + body_html: { default: '' }, + body_text: { default: '' }, + html_url: { default: 'https://github.com/octocat' }, + issue_url: { default: 'https://github.com/octocat/issues/1' }, + lock_reason: { default: '' }, + message: { default: '' }, + pull_request_url: { default: 'https://github.com/octocat/pulls/1' }, + state: { default: '' }, + submitted_at: { default: '' }, + updated_at: { default: '' }, }); -export type TimelineEventItemBuilder = InstanceType<typeof TimelineEventItemBuilder>; \ No newline at end of file +export type TimelineEventItemBuilder = InstanceType<typeof TimelineEventItemBuilder>; diff --git a/src/test/builders/rest/userBuilder.ts b/src/test/builders/rest/userBuilder.ts index 6d5cb9ae56..412b21548d 100644 --- a/src/test/builders/rest/userBuilder.ts +++ b/src/test/builders/rest/userBuilder.ts @@ -1,18 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; type UserUnion = - OctokitCommon.PullsListResponseItemUser | - OctokitCommon.PullsListResponseItemAssignee | - OctokitCommon.PullsListResponseItemAssigneesItem | - OctokitCommon.PullsListResponseItemRequestedReviewersItem | - OctokitCommon.PullsListResponseItemBaseUser | - OctokitCommon.PullsListResponseItemBaseRepoOwner | - OctokitCommon.PullsListResponseItemHeadUser | - OctokitCommon.PullsListResponseItemHeadRepoOwner | - OctokitCommon.IssuesListEventsForTimelineResponseItemActor; + | OctokitCommon.PullsListResponseItemUser + | OctokitCommon.PullsListResponseItemAssignee + | OctokitCommon.PullsListResponseItemAssigneesItem + | OctokitCommon.PullsListResponseItemRequestedReviewersItem + | OctokitCommon.PullsListResponseItemBaseUser + | OctokitCommon.PullsListResponseItemBaseRepoOwner + | OctokitCommon.PullsListResponseItemHeadUser + | OctokitCommon.PullsListResponseItemHeadRepoOwner + | OctokitCommon.IssuesListEventsForTimelineResponseItemActor; + +type NonNullUser = NonNullable<UserUnion>; -export const UserBuilder = createBuilderClass<UserUnion>()({ +export const UserBuilder = createBuilderClass<NonNullUser>()({ id: { default: 0 }, node_id: { default: 'node0' }, login: { default: 'octocat' }, @@ -30,7 +37,11 @@ export const UserBuilder = createBuilderClass<UserUnion>()({ events_url: { default: 'https://api.github.com/users/octocat/events{/privacy}' }, received_events_url: { default: 'https://api.github.com/users/octocat/received_events' }, type: { default: 'User' }, - site_admin: { default: false } + site_admin: { default: false }, + starred_at: { default: '' }, + email: { default: 'email' }, + name: { default: 'Name' }, + user_view_type: { default: 'User' } }); -export type UserBuilder = InstanceType<typeof UserBuilder>; \ No newline at end of file +export type UserBuilder = InstanceType<typeof UserBuilder>; diff --git a/src/test/common/commentingRanges.test.ts b/src/test/common/commentingRanges.test.ts index e3c1df0278..9d389839b6 100644 --- a/src/test/common/commentingRanges.test.ts +++ b/src/test/common/commentingRanges.test.ts @@ -3,12 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import assert = require('assert'); +import { default as assert } from 'assert'; import { getCommentingRanges } from '../../common/commentingRanges'; import { parsePatch } from '../../common/diffHunk'; const patch = [ - `"@@ -8,6 +8,7 @@ import { Terminal } from './Terminal';`, + `@@ -8,6 +8,7 @@ import { Terminal } from './Terminal';`, ` import { MockViewport, MockCompositionHelper, MockRenderer } from './TestUtils.test';`, ` import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';`, ` import { CellData } from 'common/buffer/CellData';`, @@ -32,51 +32,51 @@ const patch = [ `+ expect(term.buffer.lines.get(0).loadCell(term.cols - 1, cell).getChars().length).eql(2);`, ` expect(term.buffer.lines.get(1).loadCell(1, cell).getChars()).eql('');`, ` term.reset();`, - ` }"` + ` }` ].join('\n'); const deletionPatch = [ - `"@@ -1,5 +0,0 @@`, + `@@ -1,5 +0,0 @@`, `-var express = require('express');`, `-var path = require('path');`, `-var favicon = require('serve-favicon');`, `-var logger = require('morgan');`, - `-var cookieParser = require('cookie-parser');` + `-var cookieParser = require('cookie-parser');`, ].join('\n'); const diffHunks = parsePatch(patch); describe('getCommentingRanges', () => { - it('shoud return only ranges for deleted lines, mapped to full file, for the base file', () => { + it('should return only ranges for deleted lines, mapped to full file, for the base file', () => { const commentingRanges = getCommentingRanges(diffHunks, true); - assert.equal(commentingRanges.length, 1); - assert.equal(commentingRanges[0].start.line, 754); - assert.equal(commentingRanges[0].start.character, 0); - assert.equal(commentingRanges[0].end.line, 755); - assert.equal(commentingRanges[0].end.character, 0); + assert.strictEqual(commentingRanges.length, 1); + assert.strictEqual(commentingRanges[0].start.line, 754); + assert.strictEqual(commentingRanges[0].start.character, 0); + assert.strictEqual(commentingRanges[0].end.line, 755); + assert.strictEqual(commentingRanges[0].end.character, 0); }); - it('shoud return only ranges for changes, mapped to full file, for the modified file', () => { + it('should return only ranges for changes, mapped to full file, for the modified file', () => { const commentingRanges = getCommentingRanges(diffHunks, false); - assert.equal(commentingRanges.length, 2); - assert.equal(commentingRanges[0].start.line, 7); - assert.equal(commentingRanges[0].start.character, 0); - assert.equal(commentingRanges[0].end.line, 13); - assert.equal(commentingRanges[0].end.character, 0); + assert.strictEqual(commentingRanges.length, 2); + assert.strictEqual(commentingRanges[0].start.line, 7); + assert.strictEqual(commentingRanges[0].start.character, 0); + assert.strictEqual(commentingRanges[0].end.line, 13); + assert.strictEqual(commentingRanges[0].end.character, 0); - assert.equal(commentingRanges[1].start.line, 750); - assert.equal(commentingRanges[1].start.character, 0); - assert.equal(commentingRanges[1].end.line, 763); - assert.equal(commentingRanges[1].end.character, 0); + assert.strictEqual(commentingRanges[1].start.line, 750); + assert.strictEqual(commentingRanges[1].start.character, 0); + assert.strictEqual(commentingRanges[1].end.line, 763); + assert.strictEqual(commentingRanges[1].end.character, 0); }); it('should handle the last part of the diff being a deletion, for the base file', () => { const diffHunksForDeletion = parsePatch(deletionPatch); const commentingRanges = getCommentingRanges(diffHunksForDeletion, true); - assert.equal(commentingRanges.length, 1); - assert.equal(commentingRanges[0].start.line, 0); - assert.equal(commentingRanges[0].start.character, 0); - assert.equal(commentingRanges[0].end.line, 4); - assert.equal(commentingRanges[0].end.character, 0); + assert.strictEqual(commentingRanges.length, 1); + assert.strictEqual(commentingRanges[0].start.line, 0); + assert.strictEqual(commentingRanges[0].start.character, 0); + assert.strictEqual(commentingRanges[0].end.line, 4); + assert.strictEqual(commentingRanges[0].end.character, 0); }); -}); \ No newline at end of file +}); diff --git a/src/test/common/diff.test.ts b/src/test/common/diff.test.ts index 083cde47cc..15c91fb38b 100644 --- a/src/test/common/diff.test.ts +++ b/src/test/common/diff.test.ts @@ -1,7 +1,9 @@ -import assert = require('assert'); +import { default as assert } from 'assert'; import { parseDiffHunk, DiffHunk, getModifiedContentFromDiffHunk } from '../../common/diffHunk'; import { DiffLine, DiffChangeType } from '../../common/diffHunk'; -import { getDiffLineByPosition, mapHeadLineToDiffHunkPosition, mapCommentsToHead } from '../../common/diffPositionMapping'; +import { + getDiffLineByPosition, +} from '../../common/diffPositionMapping'; const diff_hunk_0 = [ `@@ -1,5 +1,6 @@`, @@ -13,7 +15,7 @@ const diff_hunk_0 = [ `\\ No newline at end of file`, `+ ],`, `+ "editor.insertSpaces": false`, - `+}` + `+}`, ].join('\n'); describe('diff hunk parsing', () => { @@ -21,52 +23,120 @@ describe('diff hunk parsing', () => { const diffHunkReader = parseDiffHunk(diff_hunk_0); const diffHunkIter = diffHunkReader.next(); const diffHunk = diffHunkIter.value; - assert.equal(diffHunk.diffLines.length, 9); - - assert.deepEqual(diffHunk.diffLines[0], new DiffLine(DiffChangeType.Control, -1, -1, 0, `@@ -1,5 +1,6 @@`)); - assert.deepEqual(diffHunk.diffLines[1], new DiffLine(DiffChangeType.Context, 1, 1, 1, ` {`)); - assert.deepEqual(diffHunk.diffLines[2], new DiffLine(DiffChangeType.Context, 2, 2, 2, ` "appService.zipIgnorePattern": [`)); - assert.deepEqual(diffHunk.diffLines[3], new DiffLine(DiffChangeType.Context, 3, 3, 3, ` "node_modules{,/**}"`)); - assert.deepEqual(diffHunk.diffLines[4], new DiffLine(DiffChangeType.Delete, 4, -1, 4, `- ]`)); - assert.deepEqual(diffHunk.diffLines[5], new DiffLine(DiffChangeType.Delete, 5, -1, 5, `-}`, false)); - assert.deepEqual(diffHunk.diffLines[6], new DiffLine(DiffChangeType.Add, -1, 4, 7, `+ ],`)); - assert.deepEqual(diffHunk.diffLines[7], new DiffLine(DiffChangeType.Add, -1, 5, 8, `+ "editor.insertSpaces": false`)); - assert.deepEqual(diffHunk.diffLines[8], new DiffLine(DiffChangeType.Add, -1, 6, 9, `+}`)); + assert.strictEqual(diffHunk.diffLines.length, 9); + + assert.deepStrictEqual(diffHunk.diffLines[0], new DiffLine(DiffChangeType.Control, -1, -1, 0, `@@ -1,5 +1,6 @@`)); + assert.deepStrictEqual(diffHunk.diffLines[1], new DiffLine(DiffChangeType.Context, 1, 1, 1, ` {`)); + assert.deepStrictEqual( + diffHunk.diffLines[2], + new DiffLine(DiffChangeType.Context, 2, 2, 2, ` "appService.zipIgnorePattern": [`), + ); + assert.deepStrictEqual( + diffHunk.diffLines[3], + new DiffLine(DiffChangeType.Context, 3, 3, 3, ` "node_modules{,/**}"`), + ); + assert.deepStrictEqual(diffHunk.diffLines[4], new DiffLine(DiffChangeType.Delete, 4, -1, 4, `- ]`)); + assert.deepStrictEqual(diffHunk.diffLines[5], new DiffLine(DiffChangeType.Delete, 5, -1, 5, `-}`, false)); + assert.deepStrictEqual(diffHunk.diffLines[6], new DiffLine(DiffChangeType.Add, -1, 4, 7, `+ ],`)); + assert.deepStrictEqual( + diffHunk.diffLines[7], + new DiffLine(DiffChangeType.Add, -1, 5, 8, `+ "editor.insertSpaces": false`), + ); + assert.deepStrictEqual(diffHunk.diffLines[8], new DiffLine(DiffChangeType.Add, -1, 6, 9, `+}`)); }); - it('getDiffLineByPosition', () => { - const diffHunkReader = parseDiffHunk(diff_hunk_0); + // #GH-2000 + it('should handle parsing diffs of diff patches', () => { + const diffDiffHunk = [ + '@@ -4,9 +4,9 @@ https://bugs.python.org/issue24844', + " Compiling python fails in Xcode 4 (clang < 3.3) where existence of 'atomic'", + ' is detected by configure, but it is not fully functional.', + ' ', + '---- configure.orig 2019-12-21 15:43:09.000000000 -0500', + '-+++ configure 2019-12-21 15:45:31.000000000 -0500', + '-@@ -16791,6 +16791,24 @@', + '+--- configure.orig 2020-07-13 07:11:53.000000000 -0500', + '++++ configure 2020-07-15 10:20:09.000000000 -0500', + '+@@ -16837,6 +16837,24 @@', + ' volatile int val = 1;', + ' int main() {', + " __atomic_load_n(&val, __ATOMIC_SEQ_CST);'", + ].join('\n'); + + const diffHunkReader = parseDiffHunk(diffDiffHunk); const diffHunkIter = diffHunkReader.next(); const diffHunk = diffHunkIter.value; - - for (let i = 0; i < diffHunk.diffLines.length; i++) { - const diffLine = diffHunk.diffLines[i]; - assert.deepEqual(getDiffLineByPosition([diffHunk], diffLine.positionInHunk), diffLine, `diff line ${i}`); - } + assert.strictEqual(diffHunk.diffLines.length, 13); + assert.strictEqual(diffHunk.newLength, 9); + assert.strictEqual(diffHunk.newLineNumber, 4); + assert.strictEqual(diffHunk.oldLength, 9); + assert.strictEqual(diffHunk.oldLineNumber, 4); + + assert.deepStrictEqual( + diffHunk.diffLines[0], + new DiffLine(DiffChangeType.Control, -1, -1, 0, '@@ -4,9 +4,9 @@ https://bugs.python.org/issue24844'), + ); + assert.deepStrictEqual( + diffHunk.diffLines[1], + new DiffLine( + DiffChangeType.Context, + 4, + 4, + 1, + " Compiling python fails in Xcode 4 (clang < 3.3) where existence of 'atomic'", + ), + ); + assert.deepStrictEqual( + diffHunk.diffLines[2], + new DiffLine(DiffChangeType.Context, 5, 5, 2, ' is detected by configure, but it is not fully functional.'), + ); + assert.deepStrictEqual(diffHunk.diffLines[3], new DiffLine(DiffChangeType.Context, 6, 6, 3, ' ')); + assert.deepStrictEqual( + diffHunk.diffLines[4], + new DiffLine(DiffChangeType.Delete, 7, -1, 4, '---- configure.orig\t2019-12-21 15:43:09.000000000 -0500'), + ); + assert.deepStrictEqual( + diffHunk.diffLines[5], + new DiffLine(DiffChangeType.Delete, 8, -1, 5, '-+++ configure\t2019-12-21 15:45:31.000000000 -0500'), + ); + assert.deepStrictEqual( + diffHunk.diffLines[6], + new DiffLine(DiffChangeType.Delete, 9, -1, 6, '-@@ -16791,6 +16791,24 @@'), + ); + assert.deepStrictEqual( + diffHunk.diffLines[7], + new DiffLine(DiffChangeType.Add, -1, 7, 7, '+--- configure.orig\t2020-07-13 07:11:53.000000000 -0500'), + ); + assert.deepStrictEqual( + diffHunk.diffLines[8], + new DiffLine(DiffChangeType.Add, -1, 8, 8, '++++ configure\t2020-07-15 10:20:09.000000000 -0500'), + ); + assert.deepStrictEqual( + diffHunk.diffLines[9], + new DiffLine(DiffChangeType.Add, -1, 9, 9, '+@@ -16837,6 +16837,24 @@'), + ); + assert.deepStrictEqual( + diffHunk.diffLines[10], + new DiffLine(DiffChangeType.Context, 10, 10, 10, ' volatile int val = 1;'), + ); + assert.deepStrictEqual( + diffHunk.diffLines[11], + new DiffLine(DiffChangeType.Context, 11, 11, 11, ' int main() {'), + ); + assert.deepStrictEqual( + diffHunk.diffLines[12], + new DiffLine(DiffChangeType.Context, 12, 12, 12, " __atomic_load_n(&val, __ATOMIC_SEQ_CST);'"), + ); }); - it('mapHeadLineToDiffHunkPosition', () => { + it('getDiffLineByPosition', () => { const diffHunkReader = parseDiffHunk(diff_hunk_0); const diffHunkIter = diffHunkReader.next(); const diffHunk = diffHunkIter.value; for (let i = 0; i < diffHunk.diffLines.length; i++) { const diffLine = diffHunk.diffLines[i]; - switch (diffLine.type) { - case DiffChangeType.Delete: - assert.equal(mapHeadLineToDiffHunkPosition([diffHunk], '', diffLine.oldLineNumber, true), diffLine.positionInHunk); - break; - case DiffChangeType.Add: - assert.equal(mapHeadLineToDiffHunkPosition([diffHunk], '', diffLine.newLineNumber, false), diffLine.positionInHunk); - break; - case DiffChangeType.Context: - assert.equal(mapHeadLineToDiffHunkPosition([diffHunk], '', diffLine.oldLineNumber, true), diffLine.positionInHunk); - assert.equal(mapHeadLineToDiffHunkPosition([diffHunk], '', diffLine.newLineNumber, false), diffLine.positionInHunk); - break; - - default: - break; - } + assert.deepStrictEqual(getDiffLineByPosition([diffHunk], diffLine.positionInHunk), diffLine, `diff line ${i}`); } }); @@ -74,7 +144,7 @@ describe('diff hunk parsing', () => { const diffHunkReader = parseDiffHunk('@@ -0,0 +1 @@'); const diffHunkIter = diffHunkReader.next(); const diffHunk = diffHunkIter.value; - assert.equal(diffHunk.diffLines.length, 1); + assert.strictEqual(diffHunk.diffLines.length, 1); }); it('', () => { @@ -86,35 +156,7 @@ describe('diff hunk parsing', () => { +Another line"`); const diffHunkIter = diffHunkReader.next(); const diffHunk = diffHunkIter.value; - assert.equal(diffHunk.diffLines.length, 5); - }); - - describe('mapCommentsToHead', () => { - it('should ignore comments that are on a deleted diff line', () => { - const comments = [{ - position: 66 - }]; - - const diffHunk = new DiffHunk(481, 16, 489, 10, 54); - diffHunk.diffLines.push(new DiffLine(DiffChangeType.Delete, 489, -1, 66, '- this.editorBlurTimeout.cancelAndSet(() => {')); - - const mappedComments = mapCommentsToHead([diffHunk], '', comments as any); - assert(mappedComments.length === 1); - assert.equal(mappedComments[0].absolutePosition, undefined); - }); - - it('should handle comments that are on an added diff line', () => { - const comments = [{ - position: 55 - }]; - - const diffHunk = new DiffHunk(481, 16, 481, 17, 54); - diffHunk.diffLines.push(new DiffLine(DiffChangeType.Add, 481, 482, 55, '+ () this.editorBlurTimeout.cancelAndSet(() => {')); - - const mappedComments = mapCommentsToHead([diffHunk], '', comments as any); - assert(mappedComments.length === 1); - assert.equal(mappedComments[0].absolutePosition, 482); - }); + assert.strictEqual(diffHunk.diffLines.length, 5); }); describe('getModifiedContentFromDiffHunk', () => { @@ -150,16 +192,16 @@ describe('diff hunk parsing', () => { ` quickPick.onDidHide(() => quickPick.dispose());`, ` quickPick.show();`, ` }));`, - `}` + `}`, ].join('\n'); it('returns the original file when there is no patch', () => { - assert.equal(getModifiedContentFromDiffHunk(originalContent, ''), originalContent); + assert.strictEqual(getModifiedContentFromDiffHunk(originalContent, ''), originalContent); }); it('returns modified content for patch with multiple additions', () => { const patch = [ - `"@@ -9,6 +9,7 @@ import { window, commands, ExtensionContext } from 'vscode';`, + `@@ -9,6 +9,7 @@ import { window, commands, ExtensionContext } from 'vscode';`, ` import { showQuickPick, showInputBox } from './basicInput';`, ` import { multiStepInput } from './multiStepInput';`, ` import { quickOpen } from './quickOpen';`, @@ -174,7 +216,7 @@ describe('diff hunk parsing', () => { `+ promptCommand`, ` };`, ` const quickPick = window.createQuickPick();`, - ` quickPick.items = Object.keys(options).map(label => ({ label }));` + ` quickPick.items = Object.keys(options).map(label => ({ label }));`, ].join('\n'); const lines = originalContent.split('\n'); @@ -184,7 +226,7 @@ describe('diff hunk parsing', () => { const expectedModifiedContent = lines.join('\n'); const modifiedContent = getModifiedContentFromDiffHunk(originalContent, patch); - assert.equal(modifiedContent, expectedModifiedContent); + assert.strictEqual(modifiedContent, expectedModifiedContent); }); }); -}); \ No newline at end of file +}); 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/protocol.test.ts b/src/test/common/protocol.test.ts index ae34fc750e..8e2d0927f6 100644 --- a/src/test/common/protocol.test.ts +++ b/src/test/common/protocol.test.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import assert = require('assert'); -import * as ssh from '../../common/ssh'; +import { default as assert } from 'assert'; +import * as ssh from '../../env/node/ssh'; import { Protocol, ProtocolType } from '../../common/protocol'; const SSH_CONFIG_WITH_HOST_ALIASES = ` @@ -19,68 +19,230 @@ Host gh_cap const str = (x: any) => JSON.stringify(x); -const testRemote = (remote: { uri: any, expectedType: ProtocolType, expectedHost: string, expectedOwner: string, expectedRepositoryName: string }) => describe(`new Protocol(${str(remote.uri)})`, () => { - let protocol: Protocol; - before(() => protocol = new Protocol(remote.uri)); +const testRemote = (remote: { + uri: any; + expectedType: ProtocolType; + expectedHost: string; + expectedOwner: string; + expectedRepositoryName: string; +}) => + describe(`new Protocol(${str(remote.uri)})`, () => { + let protocol: Protocol; + before(() => (protocol = new Protocol(remote.uri))); - it(`type should be ${ProtocolType[remote.expectedType]}`, () => - assert.equal(protocol.type, remote.expectedType)); - it(`host should be ${str(remote.expectedHost)}`, () => - assert.equal(protocol.host, remote.expectedHost)); - it(`owner should be ${str(remote.expectedOwner)}`, () => - assert.equal(protocol.owner, remote.expectedOwner)); - it(`repositoryName should be ${str(remote.expectedRepositoryName)}`, () => - assert.equal(protocol.repositoryName, remote.expectedRepositoryName)); -}); + it(`type should be ${ProtocolType[remote.expectedType]}`, () => + assert.strictEqual(protocol.type, remote.expectedType)); + it(`host should be ${str(remote.expectedHost)}`, () => assert.strictEqual(protocol.host, remote.expectedHost)); + it(`owner should be ${str(remote.expectedOwner)}`, () => assert.strictEqual(protocol.owner, remote.expectedOwner)); + it(`repositoryName should be ${str(remote.expectedRepositoryName)}`, () => + assert.strictEqual(protocol.repositoryName, remote.expectedRepositoryName)); + }); describe('Protocol', () => { describe('with HTTP and HTTPS remotes', () => [ - { uri: 'http://rmacfarlane@github.com/Microsoft/vscode', expectedType: ProtocolType.HTTP, expectedHost: 'github.com', expectedOwner: 'Microsoft', expectedRepositoryName: 'vscode' }, - { uri: 'http://rmacfarlane:password@github.com/Microsoft/vscode', expectedType: ProtocolType.HTTP, expectedHost: 'github.com', expectedOwner: 'Microsoft', expectedRepositoryName: 'vscode' }, - { uri: 'http://rmacfarlane:password@www.github.com/Microsoft/vscode', expectedType: ProtocolType.HTTP, expectedHost: 'github.com', expectedOwner: 'Microsoft', expectedRepositoryName: 'vscode' }, - { uri: 'http://github.com/Microsoft/vscode', expectedType: ProtocolType.HTTP, expectedHost: 'github.com', expectedOwner: 'Microsoft', expectedRepositoryName: 'vscode' }, - { uri: 'http://github.com/Microsoft/vscode.git', expectedType: ProtocolType.HTTP, expectedHost: 'github.com', expectedOwner: 'Microsoft', expectedRepositoryName: 'vscode' }, - { uri: 'http://www.github.com/Microsoft/vscode.git', expectedType: ProtocolType.HTTP, expectedHost: 'github.com', expectedOwner: 'Microsoft', expectedRepositoryName: 'vscode' }, - { uri: 'http://github.com:/Microsoft/vscode.git', expectedType: ProtocolType.HTTP, expectedHost: 'github.com', expectedOwner: 'Microsoft', expectedRepositoryName: 'vscode' }, - { uri: 'http://github.com:433/Microsoft/vscode.git', expectedType: ProtocolType.HTTP, expectedHost: 'github.com', expectedOwner: 'Microsoft', expectedRepositoryName: 'vscode' }, - { uri: 'https://rmacfarlane@github.com/Microsoft/vscode', expectedType: ProtocolType.HTTP, expectedHost: 'github.com', expectedOwner: 'Microsoft', expectedRepositoryName: 'vscode' }, - { uri: 'https://rmacfarlane:password@github.com/Microsoft/vscode', expectedType: ProtocolType.HTTP, expectedHost: 'github.com', expectedOwner: 'Microsoft', expectedRepositoryName: 'vscode' }, - { uri: 'https://github.com/Microsoft/vscode', expectedType: ProtocolType.HTTP, expectedHost: 'github.com', expectedOwner: 'Microsoft', expectedRepositoryName: 'vscode' }, - { uri: 'https://github.com/Microsoft/vscode.git', expectedType: ProtocolType.HTTP, expectedHost: 'github.com', expectedOwner: 'Microsoft', expectedRepositoryName: 'vscode' }, - { uri: 'https://github.com:/Microsoft/vscode.git', expectedType: ProtocolType.HTTP, expectedHost: 'github.com', expectedOwner: 'Microsoft', expectedRepositoryName: 'vscode' }, - { uri: 'https://github.com:433/Microsoft/vscode.git', expectedType: ProtocolType.HTTP, expectedHost: 'github.com', expectedOwner: 'Microsoft', expectedRepositoryName: 'vscode' }, - { uri: 'https://www.github.com:433/Microsoft/vscode.git', expectedType: ProtocolType.HTTP, expectedHost: 'github.com', expectedOwner: 'Microsoft', expectedRepositoryName: 'vscode' }, - { uri: 'https://github.enterprise.corp/Microsoft/vscode.git', expectedType: ProtocolType.HTTP, expectedHost: 'github.enterprise.corp', expectedOwner: 'Microsoft', expectedRepositoryName: 'vscode' } - ].forEach(testRemote) - ); + { + uri: 'http://rmacfarlane@github.com/Microsoft/vscode', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'http://rmacfarlane:password@github.com/Microsoft/vscode', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'http://rmacfarlane:password@www.github.com/Microsoft/vscode', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'http://github.com/Microsoft/vscode', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'http://github.com/Microsoft/vscode.git', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'http://www.github.com/Microsoft/vscode.git', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'http://github.com:/Microsoft/vscode.git', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'http://github.com:433/Microsoft/vscode.git', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'https://rmacfarlane@github.com/Microsoft/vscode', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'https://rmacfarlane:password@github.com/Microsoft/vscode', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'https://github.com/Microsoft/vscode', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'https://github.com/Microsoft/vscode.git', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'https://github.com:/Microsoft/vscode.git', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'https://github.com:433/Microsoft/vscode.git', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'https://www.github.com:433/Microsoft/vscode.git', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'https://github.enterprise.corp/Microsoft/vscode.git', + expectedType: ProtocolType.HTTP, + expectedHost: 'github.enterprise.corp', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + ].forEach(testRemote)); it('should handle SSH remotes', () => [ - { uri: 'ssh://git@github.com/Microsoft/vscode', expectedType: ProtocolType.SSH, expectedHost: 'github.com', expectedOwner: 'Microsoft', expectedRepositoryName: 'vscode' }, - { uri: 'ssh://github.com:Microsoft/vscode.git', expectedType: ProtocolType.SSH, expectedHost: 'github.com', expectedOwner: 'Microsoft', expectedRepositoryName: 'vscode' }, - { uri: 'ssh://git@github.com:433/Microsoft/vscode', expectedType: ProtocolType.SSH, expectedHost: 'github.com', expectedOwner: 'Microsoft', expectedRepositoryName: 'vscode' }, - { uri: 'ssh://user@git.server.org:project.git', expectedType: ProtocolType.SSH, expectedHost: 'git.server.org', expectedOwner: '', expectedRepositoryName: 'project' } - - ].forEach(testRemote) - ); + { + uri: 'ssh://git@github.com/Microsoft/vscode', + expectedType: ProtocolType.SSH, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'ssh://github.com:Microsoft/vscode.git', + expectedType: ProtocolType.SSH, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'ssh://git@github.com:433/Microsoft/vscode', + expectedType: ProtocolType.SSH, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'ssh://user@git.server.org:project.git', + expectedType: ProtocolType.SSH, + expectedHost: 'git.server.org', + expectedOwner: '', + expectedRepositoryName: 'project', + }, + ].forEach(testRemote)); it('should handle SCP-like remotes', () => [ - { uri: 'git@github.com:Microsoft/vscode', expectedType: ProtocolType.SSH, expectedHost: 'github.com', expectedOwner: 'Microsoft', expectedRepositoryName: 'vscode' }, - { uri: 'git@github.com:Microsoft/vscode.git', expectedType: ProtocolType.SSH, expectedHost: 'github.com', expectedOwner: 'Microsoft', expectedRepositoryName: 'vscode' }, - { uri: 'github.com:Microsoft/vscode.git', expectedType: ProtocolType.SSH, expectedHost: 'github.com', expectedOwner: 'Microsoft', expectedRepositoryName: 'vscode' }, - { uri: 'user@git.server.org:project.git', expectedType: ProtocolType.SSH, expectedHost: 'git.server.org', expectedOwner: '', expectedRepositoryName: 'project' }, - { uri: 'git.server2.org:project.git', expectedType: ProtocolType.SSH, expectedHost: 'git.server2.org', expectedOwner: '', expectedRepositoryName: 'project' } - ].forEach(testRemote) - ); + { + uri: 'git@github.com:Microsoft/vscode', + expectedType: ProtocolType.SSH, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'git@github.com:Microsoft/vscode.git', + expectedType: ProtocolType.SSH, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'github.com:Microsoft/vscode.git', + expectedType: ProtocolType.SSH, + expectedHost: 'github.com', + expectedOwner: 'Microsoft', + expectedRepositoryName: 'vscode', + }, + { + uri: 'user@git.server.org:project.git', + expectedType: ProtocolType.SSH, + expectedHost: 'git.server.org', + expectedOwner: '', + expectedRepositoryName: 'project', + }, + { + uri: 'git.server2.org:project.git', + expectedType: ProtocolType.SSH, + expectedHost: 'git.server2.org', + expectedOwner: '', + expectedRepositoryName: 'project', + }, + ].forEach(testRemote)); it('should handle local remotes', () => [ - { uri: '/opt/git/project.git', expectedType: ProtocolType.Local, expectedHost: '', expectedOwner: '', expectedRepositoryName: '' }, - { uri: 'file:///opt/git/project.git', expectedType: ProtocolType.Local, expectedHost: '', expectedOwner: '', expectedRepositoryName: '' } - ].forEach(testRemote) - ); + { + uri: '/opt/git/project.git', + expectedType: ProtocolType.Local, + expectedHost: '', + expectedOwner: '', + expectedRepositoryName: '', + }, + { + uri: 'file:///opt/git/project.git', + expectedType: ProtocolType.Local, + expectedHost: '', + expectedOwner: '', + expectedRepositoryName: '', + }, + ].forEach(testRemote)); describe('toString when generating github remotes', () => [ @@ -89,46 +251,94 @@ describe('Protocol', () => { { uri: 'ssh://git@github.com:433/Microsoft/vscode', expected: 'git@github.com:Microsoft/vscode' }, ].forEach(remote => it(`should generate "${remote.expected}" from "${remote.uri}`, () => - assert.equal(new Protocol(remote.uri).toString(), remote.expected)) - ) - ); + assert.strictEqual(new Protocol(remote.uri).toString(), remote.expected)), + )); describe('Protocol.update when changing protocol type to SSH', () => [ - { uri: 'http://rmacfarlane@github.com/Microsoft/vscode', change: { type: ProtocolType.SSH }, expected: 'git@github.com:Microsoft/vscode' }, - { uri: 'http://rmacfarlane:password@github.com/Microsoft/vscode', change: { type: ProtocolType.SSH }, expected: 'git@github.com:Microsoft/vscode' }, - { uri: 'http://github.com/Microsoft/vscode', change: { type: ProtocolType.SSH }, expected: 'git@github.com:Microsoft/vscode' }, - { uri: 'http://github.com/Microsoft/vscode.git', change: { type: ProtocolType.SSH }, expected: 'git@github.com:Microsoft/vscode' }, - { uri: 'http://github.com:/Microsoft/vscode.git', change: { type: ProtocolType.SSH }, expected: 'git@github.com:Microsoft/vscode' }, - { uri: 'http://github.com:433/Microsoft/vscode.git', change: { type: ProtocolType.SSH }, expected: 'git@github.com:Microsoft/vscode' }, - { uri: 'https://rmacfarlane@github.com/Microsoft/vscode', change: { type: ProtocolType.SSH }, expected: 'git@github.com:Microsoft/vscode' }, - { uri: 'https://rmacfarlane:password@github.com/Microsoft/vscode', change: { type: ProtocolType.SSH }, expected: 'git@github.com:Microsoft/vscode' }, - { uri: 'https://github.com/Microsoft/vscode', change: { type: ProtocolType.SSH }, expected: 'git@github.com:Microsoft/vscode' }, - { uri: 'https://github.com/Microsoft/vscode.git', change: { type: ProtocolType.SSH }, expected: 'git@github.com:Microsoft/vscode' }, - { uri: 'https://github.com:/Microsoft/vscode.git', change: { type: ProtocolType.SSH }, expected: 'git@github.com:Microsoft/vscode' }, - { uri: 'https://github.com:433/Microsoft/vscode.git', change: { type: ProtocolType.SSH }, expected: 'git@github.com:Microsoft/vscode' }, - { uri: 'https://github.enterprise.corp/Microsoft/vscode.git', change: { type: ProtocolType.SSH }, expected: 'git@github.enterprise.corp:Microsoft/vscode' } + { + uri: 'http://rmacfarlane@github.com/Microsoft/vscode', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'http://rmacfarlane:password@github.com/Microsoft/vscode', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'http://github.com/Microsoft/vscode', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'http://github.com/Microsoft/vscode.git', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'http://github.com:/Microsoft/vscode.git', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'http://github.com:433/Microsoft/vscode.git', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'https://rmacfarlane@github.com/Microsoft/vscode', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'https://rmacfarlane:password@github.com/Microsoft/vscode', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'https://github.com/Microsoft/vscode', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'https://github.com/Microsoft/vscode.git', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'https://github.com:/Microsoft/vscode.git', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'https://github.com:433/Microsoft/vscode.git', + change: { type: ProtocolType.SSH }, + expected: 'git@github.com:Microsoft/vscode', + }, + { + uri: 'https://github.enterprise.corp/Microsoft/vscode.git', + change: { type: ProtocolType.SSH }, + expected: 'git@github.enterprise.corp:Microsoft/vscode', + }, ].forEach(remote => it(`should change "${remote.uri}" to "${remote.expected}"`, () => { const protocol = new Protocol(remote.uri); protocol.update(remote.change); - assert.equal(protocol.toString(), remote.expected); - }) - ) - ); + assert.strictEqual(protocol.toString(), remote.expected); + }), + )); describe('with a ~/.ssh/config', () => { - before(() => - ssh.Resolvers.current = ssh.Resolvers.fromConfig(SSH_CONFIG_WITH_HOST_ALIASES)); - after(() => - ssh.Resolvers.current = ssh.Resolvers.default); + before(() => (ssh.Resolvers.current = ssh.Resolvers.fromConfig(SSH_CONFIG_WITH_HOST_ALIASES))); + after(() => (ssh.Resolvers.current = ssh.Resolvers.default)); testRemote({ uri: 'gh_cap:queerviolet/vscode', expectedType: ProtocolType.SSH, expectedHost: 'github.com', expectedOwner: 'queerviolet', - expectedRepositoryName: 'vscode' + expectedRepositoryName: 'vscode', }); testRemote({ @@ -136,7 +346,7 @@ describe('Protocol', () => { expectedType: ProtocolType.SSH, expectedHost: 'github.com', expectedOwner: 'queerviolet', - expectedRepositoryName: 'vscode' + expectedRepositoryName: 'vscode', }); }); -}); \ No newline at end of file +}); diff --git a/src/test/common/remote.test.ts b/src/test/common/remote.test.ts new file mode 100644 index 0000000000..9e8e3976a4 --- /dev/null +++ b/src/test/common/remote.test.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { parseRepositoryRemotesAsync } from '../../common/remote'; +import { MockRepository } from '../mocks/mockRepository'; + +describe('parseRepositoryRemotesAsync', () => { + it('resolves a remote URL using a global "url.<base>.insteadOf" alias', async () => { + const repository = new MockRepository(); + // Simulate `git config url."https://github.abc.com/".insteadOf github:` + await repository.setConfig('url.https://github.abc.com/.insteadof', 'github:'); + // And a remote stored as `github:org/repo` (as returned by `git config --get remote.origin.url` + // when the user clones with `git clone github:org/repo`). + await repository.addRemote('origin', 'github:org/repo'); + + const remotes = await parseRepositoryRemotesAsync(repository); + + assert.strictEqual(remotes.length, 1); + assert.strictEqual(remotes[0].remoteName, 'origin'); + assert.strictEqual(remotes[0].host, 'github.abc.com'); + assert.strictEqual(remotes[0].owner, 'org'); + assert.strictEqual(remotes[0].repositoryName, 'repo'); + }); + + it('applies the longest matching insteadOf prefix when multiple match', async () => { + const repository = new MockRepository(); + await repository.setConfig('url.https://short.example.com/.insteadof', 'gh:'); + await repository.setConfig('url.https://long.example.com/.insteadof', 'gh:org/'); + await repository.addRemote('origin', 'gh:org/repo'); + + const remotes = await parseRepositoryRemotesAsync(repository); + + assert.strictEqual(remotes.length, 1); + assert.strictEqual(remotes[0].host, 'long.example.com'); + assert.strictEqual(remotes[0].repositoryName, 'repo'); + }); + + it('leaves URLs unchanged when no insteadOf alias matches', async () => { + const repository = new MockRepository(); + await repository.setConfig('url.https://github.abc.com/.insteadof', 'github:'); + await repository.addRemote('origin', 'https://github.com/owner/repo'); + + const remotes = await parseRepositoryRemotesAsync(repository); + + assert.strictEqual(remotes.length, 1); + assert.strictEqual(remotes[0].host, 'github.com'); + assert.strictEqual(remotes[0].owner, 'owner'); + assert.strictEqual(remotes[0].repositoryName, 'repo'); + }); +}); 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/telemetry.test.ts b/src/test/common/telemetry.test.ts deleted file mode 100644 index f71909e378..0000000000 --- a/src/test/common/telemetry.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import assert = require('assert'); -import * as vscode from 'vscode'; - -describe('Telemetry', () => { - let disposable: vscode.Disposable; - - beforeEach(function () { - disposable = new vscode.Disposable(() => { }); - }); - - afterEach(function () { - disposable.dispose(); - }); - - it('should migrate deprecated optout setting', async () => { - const deprecated = vscode.workspace.getConfiguration('telemetry'); - const migrated = vscode.workspace.getConfiguration('githubPullRequests.telemetry'); - const target = vscode.ConfigurationTarget.Global; - - // Reset the workspace - await deprecated.update('optout', undefined, target); - await migrated.update('enabled', undefined, target); - - // Change setting to force migration to run - await deprecated.update('optout', true, target); - - disposable = vscode.workspace.onDidChangeConfiguration(async () => { - assert.equal(deprecated.get('optout'), undefined); - assert.equal(migrated.get('enabled'), false); - - // Clean up the workspace - await deprecated.update('optout', undefined, target); - await migrated.update('enabled', undefined, target); - }); - }); -}); diff --git a/src/test/common/uri.test.ts b/src/test/common/uri.test.ts new file mode 100644 index 0000000000..bf4551a25b --- /dev/null +++ b/src/test/common/uri.test.ts @@ -0,0 +1,195 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { fromOpenIssueWebviewUri, 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); + }); + }); + + describe('fromOpenIssueWebviewUri', () => { + it('should parse the new simplified format with uri parameter', () => { + const uri = vscode.Uri.parse('vscode://github.vscode-pull-request-github/open-issue-webview?uri=https://github.com/microsoft/vscode-tools/issues/964'); + const result = fromOpenIssueWebviewUri(uri); + + assert.strictEqual(result?.owner, 'microsoft'); + assert.strictEqual(result?.repo, 'vscode-tools'); + assert.strictEqual(result?.issueNumber, 964); + }); + + it('should parse the new simplified format with http (not https)', () => { + const uri = vscode.Uri.parse('vscode://github.vscode-pull-request-github/open-issue-webview?uri=http://github.com/owner/repo/issues/123'); + const result = fromOpenIssueWebviewUri(uri); + + assert.strictEqual(result?.owner, 'owner'); + assert.strictEqual(result?.repo, 'repo'); + assert.strictEqual(result?.issueNumber, 123); + }); + + it('should parse the old JSON format for backward compatibility', () => { + const uri = vscode.Uri.parse('vscode://github.vscode-pull-request-github/open-issue-webview?%7B%22owner%22%3A%22microsoft%22%2C%22repo%22%3A%22vscode-tools%22%2C%22issueNumber%22%3A964%7D'); + const result = fromOpenIssueWebviewUri(uri); + + assert.strictEqual(result?.owner, 'microsoft'); + assert.strictEqual(result?.repo, 'vscode-tools'); + assert.strictEqual(result?.issueNumber, 964); + }); + + it('should return undefined for invalid authority', () => { + const uri = vscode.Uri.parse('vscode://invalid-authority/open-issue-webview?uri=https://github.com/owner/repo/issues/1'); + const result = fromOpenIssueWebviewUri(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/issues/1'); + const result = fromOpenIssueWebviewUri(uri); + + assert.strictEqual(result, undefined); + }); + + it('should return undefined for non-github host', () => { + const uri = vscode.Uri.parse('vscode://github.vscode-pull-request-github/open-issue-webview?uri=https://example.com/owner/repo/issues/1'); + const result = fromOpenIssueWebviewUri(uri); + + assert.strictEqual(result, undefined); + }); + + it('should return undefined for non-numeric issue number', () => { + const uri = vscode.Uri.parse('vscode://github.vscode-pull-request-github/open-issue-webview?uri=https://github.com/owner/repo/issues/abc'); + const result = fromOpenIssueWebviewUri(uri); + + assert.strictEqual(result, undefined); + }); + + it('should reject URLs with extra path segments after issue number', () => { + const uri = vscode.Uri.parse('vscode://github.vscode-pull-request-github/open-issue-webview?uri=https://github.com/owner/repo/issues/123/something'); + const result = fromOpenIssueWebviewUri(uri); + + assert.strictEqual(result, undefined); + }); + }); +}); diff --git a/src/test/common/utils.test.ts b/src/test/common/utils.test.ts index 68e40cd6e7..b7e096775e 100644 --- a/src/test/common/utils.test.ts +++ b/src/test/common/utils.test.ts @@ -1,9 +1,14 @@ -import assert = require('assert'); +/*--------------------------------------------------------------------------------------------- + * 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 utils from '../../common/utils'; import { EventEmitter } from 'vscode'; +import * as timers from 'timers'; describe('utils', () => { - class HookError extends Error { public errors: any[]; @@ -16,159 +21,260 @@ describe('utils', () => { describe('formatError', () => { it('should format a normal error', () => { const error = new Error('No!'); - assert.equal(utils.formatError(error), 'No!'); + assert.strictEqual(utils.formatError(error), 'No!'); }); it('should format an error with submessages', () => { - const error = new HookError('Validation Failed', [{ message: 'user_id can only have one pending review per pull request' }]); - assert.equal(utils.formatError(error), 'Validation Failed: user_id can only have one pending review per pull request'); + const error = new HookError('Validation Failed', [ + { message: 'user_id can only have one pending review per pull request' }, + ]); + assert.strictEqual(utils.formatError(error), 'user_id can only have one pending review per pull request'); + }); + + it('should not format when error message contains all information', () => { + const error = new HookError('Validation Failed: Some Validation error', []); + assert.strictEqual(utils.formatError(error), 'Validation Failed: Some Validation error'); }); it('should format an error with submessages that are strings', () => { const error = new HookError('Validation Failed', ['Can not approve your own pull request']); - assert.equal(utils.formatError(error), 'Validation Failed: Can not approve your own pull request'); + assert.strictEqual(utils.formatError(error), 'Can not approve your own pull request'); }); it('should format an error with field errors', () => { - const error = new HookError('Validation Failed', [{ field: 'title', value: 'garbage', code: 'custom' }]); - assert.equal(utils.formatError(error), 'Validation Failed: Value "garbage" cannot be set for field title (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 ' }]); - assert.equal(utils.formatError(error), 'Validation Failed: Cannot push to this repo'); + const error = new HookError('Validation Failed', [{ message: 'Cannot push to this repo', status: 'custom' }]); + assert.strictEqual(utils.formatError(error), 'Cannot push to this repo'); + }); + }); + + describe('processPermalinks', () => { + const repoName = 'vscode'; + const authority = 'github.com'; + const sha = 'a'.repeat(40); + + function makePermalink(filePath: string, startLine: number, endLine?: number): string { + const lineRef = endLine ? `#L${startLine}-L${endLine}` : `#L${startLine}`; + return `<a href="https://github.com/microsoft/vscode/blob/${sha}/${filePath}${lineRef}">link text</a>`; + } + + it('should add data attributes when file exists locally', async () => { + const html = makePermalink('src/file.ts', 10); + const result = await utils.processPermalinks(html, repoName, authority, async () => true); + + assert(result.includes('data-local-file="src/file.ts"')); + assert(result.includes('data-start-line="10"')); + assert(result.includes('data-end-line="10"')); + assert(result.includes('data-link-type="blob"')); + assert(result.includes('data-permalink-processed="true"')); + assert(result.includes('view on GitHub')); + }); + + it('should set end line when range is specified', async () => { + const html = makePermalink('src/file.ts', 10, 20); + const result = await utils.processPermalinks(html, repoName, authority, async () => true); + + assert(result.includes('data-start-line="10"')); + assert(result.includes('data-end-line="20"')); + }); + + it('should not modify links when file does not exist locally', async () => { + const html = makePermalink('src/file.ts', 10); + const result = await utils.processPermalinks(html, repoName, authority, async () => false); + + assert.strictEqual(result, html); + }); + + it('should not modify non-permalink links', async () => { + const html = '<a href="https://example.com">example</a>'; + const result = await utils.processPermalinks(html, repoName, authority, async () => true); + + assert.strictEqual(result, html); + }); + + it('should not modify links to a different repo', async () => { + const html = `<a href="https://github.com/other/repo/blob/${sha}/src/file.ts#L10">link</a>`; + const result = await utils.processPermalinks(html, repoName, authority, async () => true); + + assert.strictEqual(result, html); + }); + + it('should skip already processed links', async () => { + const html = `<a data-permalink-processed="true" href="https://github.com/microsoft/vscode/blob/${sha}/src/file.ts#L10">link</a>`; + const result = await utils.processPermalinks(html, repoName, authority, async () => true); + + assert.strictEqual(result, html); + }); + + it('should process multiple links independently', async () => { + const html = makePermalink('src/exists.ts', 1) + makePermalink('src/missing.ts', 2); + const result = await utils.processPermalinks(html, repoName, authority, async (path) => path === 'src/exists.ts'); + + assert(result.includes('data-local-file="src/exists.ts"')); + assert(!result.includes('data-local-file="src/missing.ts"')); + }); + + it('should return original HTML when fileExistsCheck throws', async () => { + const html = makePermalink('src/file.ts', 10); + const result = await utils.processPermalinks(html, repoName, authority, async () => { throw new Error('fail'); }); + + assert.strictEqual(result, html); + }); + + it('should handle links without surrounding text', async () => { + const html = makePermalink('src/file.ts', 5); + const result = await utils.processPermalinks(html, repoName, authority, async () => true); + + assert(result.includes('link text')); + assert(result.includes('data-local-file="src/file.ts"')); + }); + + it('should escape HTML special characters in file paths', async () => { + const html = makePermalink('src/file&name-test.ts', 10); + const result = await utils.processPermalinks(html, repoName, authority, async () => true); + + assert(result.includes('data-local-file="src/file&name-test.ts"')); + assert(!result.includes('data-local-file="src/file&name-test.ts"')); + }); + }); + + describe('processDiffLinks', () => { + const repoOwner = 'microsoft'; + const repoName = 'vscode'; + const authority = 'github.com'; + const prNumber = 123; + const diffHash = 'a'.repeat(64); + + function makeDiffLink(hash: string, startLine?: number, endLine?: number, variant: 'files' | 'changes' = 'files'): string { + let fragment = `diff-${hash}`; + if (startLine !== undefined) { + fragment += `R${startLine}`; + if (endLine !== undefined) { + fragment += `-R${endLine}`; + } + } + return `<a href="https://github.com/microsoft/vscode/pull/${prNumber}/${variant}#${fragment}">link text</a>`; + } + + it('should add data attributes when hash maps to a file', async () => { + const hashMap: Record<string, string> = { [diffHash]: 'src/file.ts' }; + const html = makeDiffLink(diffHash, 10); + const result = await utils.processDiffLinks(html, repoOwner, repoName, authority, hashMap, prNumber); + + assert(result.includes('data-local-file="src/file.ts"')); + assert(result.includes('data-start-line="10"')); + assert(result.includes('data-end-line="10"')); + assert(result.includes('data-link-type="diff"')); + assert(result.includes('data-permalink-processed="true"')); + assert(result.includes('view on GitHub')); + }); + + it('should set end line when range is specified', async () => { + const hashMap: Record<string, string> = { [diffHash]: 'src/file.ts' }; + const html = makeDiffLink(diffHash, 10, 20); + const result = await utils.processDiffLinks(html, repoOwner, repoName, authority, hashMap, prNumber); + + assert(result.includes('data-start-line="10"')); + assert(result.includes('data-end-line="20"')); + }); + + it('should default start line to 1 when no line is specified', async () => { + const hashMap: Record<string, string> = { [diffHash]: 'src/file.ts' }; + const html = makeDiffLink(diffHash); + const result = await utils.processDiffLinks(html, repoOwner, repoName, authority, hashMap, prNumber); + + assert(result.includes('data-start-line="1"')); + assert(result.includes('data-end-line="1"')); + }); + + it('should not modify links when hash is not in the map', async () => { + const hashMap: Record<string, string> = {}; + const html = makeDiffLink(diffHash, 10); + const result = await utils.processDiffLinks(html, repoOwner, repoName, authority, hashMap, prNumber); + + assert.strictEqual(result, html); + }); + + it('should not modify non-diff links', async () => { + const hashMap: Record<string, string> = { [diffHash]: 'src/file.ts' }; + const html = '<a href="https://example.com">example</a>'; + const result = await utils.processDiffLinks(html, repoOwner, repoName, authority, hashMap, prNumber); + + assert.strictEqual(result, html); + }); + + it('should not modify links to a different repo', async () => { + const hashMap: Record<string, string> = { [diffHash]: 'src/file.ts' }; + const html = `<a href="https://github.com/other/repo/pull/${prNumber}/files#diff-${diffHash}R10">link</a>`; + const result = await utils.processDiffLinks(html, repoOwner, repoName, authority, hashMap, prNumber); + + assert.strictEqual(result, html); + }); + + it('should skip already processed links', async () => { + const hashMap: Record<string, string> = { [diffHash]: 'src/file.ts' }; + const html = `<a data-permalink-processed="true" href="https://github.com/microsoft/vscode/pull/${prNumber}/files#diff-${diffHash}R10">link</a>`; + const result = await utils.processDiffLinks(html, repoOwner, repoName, authority, hashMap, prNumber); + + assert.strictEqual(result, html); + }); + + it('should match links using changes variant', async () => { + const hashMap: Record<string, string> = { [diffHash]: 'src/file.ts' }; + const html = makeDiffLink(diffHash, 5, undefined, 'changes'); + const result = await utils.processDiffLinks(html, repoOwner, repoName, authority, hashMap, prNumber); + + assert(result.includes('data-local-file="src/file.ts"')); + assert(result.includes('data-start-line="5"')); + }); + + it('should process multiple links independently', async () => { + const otherHash = 'b'.repeat(64); + const hashMap: Record<string, string> = { [diffHash]: 'src/found.ts' }; + const html = makeDiffLink(diffHash, 1) + makeDiffLink(otherHash, 2); + const result = await utils.processDiffLinks(html, repoOwner, repoName, authority, hashMap, prNumber); + + assert(result.includes('data-local-file="src/found.ts"')); + assert(!result.includes('data-local-file="src/other.ts"')); + }); + + it('should escape HTML special characters in file names', async () => { + const hashMap: Record<string, string> = { [diffHash]: 'src/file&name"test.ts' }; + const html = makeDiffLink(diffHash, 10); + const result = await utils.processDiffLinks(html, repoOwner, repoName, authority, hashMap, prNumber); + + assert(result.includes('data-local-file="src/file&name"test.ts"')); + assert(!result.includes('data-local-file="src/file&name"test.ts"')); }); }); - describe('promiseFromEvent', () => { - const hasListeners = (emitter: any) => - !emitter._listeners!.isEmpty(); - - describe('without arguments', () => { - it('should return a promise for the next event', async () => { - const emitter = new EventEmitter<string>(); - const promise = utils.promiseFromEvent(emitter.event); - emitter.fire('hello'); - emitter.fire('world'); - const value = await promise; - assert.equal(value, 'hello'); - }); - - it('should unsubscribe after the promise resolves', async () => { - const emitter = new EventEmitter<string>(); - const promise = utils.promiseFromEvent(emitter.event); - assert(hasListeners(emitter), 'should subscribe'); - emitter.fire('hello'); - await promise; - assert(!hasListeners(emitter), 'should unsubscribe'); - }); - }); - - describe('with an adapter', () => { - const count: utils.PromiseAdapter<string, number> = - (str, resolve, reject) => - str.length <= 4 - ? resolve(str.length) - : reject(new Error('the string is too damn long')); - - it('should return a promise that uses the adapter\'s value', async () => { - const emitter = new EventEmitter<string>(); - const promise = utils.promiseFromEvent(emitter.event, count); - assert(hasListeners(emitter), 'should subscribe'); - emitter.fire('hell'); - const value = await promise; - assert(!hasListeners(emitter), 'should unsubscribe'); - assert.equal(value, 'hell'.length); - }); - - it('should return a promise that rejects if the adapter does', async () => { - const emitter = new EventEmitter<string>(); - const promise = utils.promiseFromEvent(emitter.event, count); - assert(hasListeners(emitter), 'should subscribe'); - emitter.fire('hello'); - await promise - .then( - () => { throw new Error('promise should have rejected'); }, - e => assert.equal(e.message, 'the string is too damn long') - ); - assert(!hasListeners(emitter), 'should unsubscribe'); - }); - - it('should return a promise that rejects if the adapter throws', async () => { - const emitter = new EventEmitter<string>(); - const promise = utils.promiseFromEvent( - emitter.event, - () => { throw new Error('kaboom'); } - ); - assert(hasListeners(emitter), 'should subscribe'); - emitter.fire('hello'); - await promise - .then( - () => { throw new Error('promise should have rejected'); }, - e => assert.equal(e.message, 'kaboom') - ); - assert(!hasListeners(emitter), 'should unsubscribe'); - }); - - it('should return a promise that rejects if the adapter returns a rejecting Promise', async () => { - const emitter = new EventEmitter<string>(); - const promise = utils.promiseFromEvent( - emitter.event, - async () => { throw new Error('kaboom'); } - ); - assert(hasListeners(emitter), 'should subscribe'); - emitter.fire('hello'); - await promise - .then( - () => { throw new Error('promise should have rejected'); }, - e => assert.equal(e.message, 'kaboom') - ); - assert(!hasListeners(emitter), 'should unsubscribe'); - }); - - const door: utils.PromiseAdapter<string, boolean> = - (password, resolve, reject) => - password === 'sesame' - ? resolve(true) - : - password === 'mellon' - ? reject(new Error('wrong fable')) - : - {/* the door is silent */ }; - - const tick = () => new Promise(resolve => setImmediate(resolve)); - it('should stay subscribed until the adapter resolves', async () => { - const emitter = new EventEmitter<string>(); - const promise = utils.promiseFromEvent(emitter.event, door); - let hasResolved = false; promise.then(() => hasResolved = true); - emitter.fire('password'); - emitter.fire('12345'); - await tick(); - assert.equal(hasResolved, false, 'shouldn\'t have resolved yet'); - assert(hasListeners(emitter), 'should still be listening'); - emitter.fire('sesame'); - await tick(); - assert.equal(hasResolved, true, 'should have resolved'); - assert(!hasListeners(emitter), 'should have unsubscribed'); - }); - - it('should stay subscribed until the adapter rejects', async () => { - const emitter = new EventEmitter<string>(); - const promise = utils.promiseFromEvent(emitter.event, door); - let hasResolved = false, hasRejected = false; - promise.then(() => hasResolved = true, () => hasRejected = true); - emitter.fire('password'); - emitter.fire('12345'); - await tick(); - assert.equal(hasResolved, false, 'shouldn\'t resolve'); - assert.equal(hasRejected, false, 'shouldn\'t have rejected yet'); - assert(hasListeners(emitter), 'should still be listening'); - emitter.fire('mellon'); - await tick(); - assert.equal(hasResolved, false, 'shouldn\'t resolve'); - assert.equal(hasRejected, true, 'should have rejected'); - assert(!hasListeners(emitter), 'should have unsubscribed'); - }); + describe('escapeHtmlAttr', () => { + it('should escape ampersands', () => { + assert.strictEqual(utils.escapeHtmlAttr('foo&bar'), 'foo&bar'); + }); + + it('should escape double quotes', () => { + assert.strictEqual(utils.escapeHtmlAttr('foo"bar'), 'foo"bar'); + }); + + it('should escape single quotes', () => { + assert.strictEqual(utils.escapeHtmlAttr('foo\'bar'), 'foo'bar'); + }); + + it('should escape angle brackets', () => { + assert.strictEqual(utils.escapeHtmlAttr('<script>'), '<script>'); + }); + + it('should escape all special characters together', () => { + assert.strictEqual(utils.escapeHtmlAttr('a&b"c\'d<e>f'), 'a&b"c'd<e>f'); + }); + + it('should return the same string when no special characters', () => { + assert.strictEqual(utils.escapeHtmlAttr('normal-file.ts'), 'normal-file.ts'); }); }); -}); \ No newline at end of file +}); 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/extension.test.ts b/src/test/extension.test.ts index fd3aebc402..3038c33295 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -1,13 +1,12 @@ -import assert = require('assert'); +import { default as assert } from 'assert'; import { parseDiffHunk } from '../common/diffHunk'; describe('Extension Tests', function () { - describe('parseDiffHunk', () => { it('should handle empty string', () => { const diffHunk = parseDiffHunk(''); const itr = diffHunk.next(); - assert.equal(itr.done, true); + assert.strictEqual(itr.done, true); }); it('should handle additions', () => { @@ -27,12 +26,12 @@ describe('Extension Tests', function () { const itr = diffHunk.next(); assert.notEqual(itr.value, undefined); - assert.equal(itr.value.oldLineNumber, 5); - assert.equal(itr.value.newLineNumber, 5); - assert.equal(itr.value.oldLength, 6); - assert.equal(itr.value.newLength, 9); - assert.equal(itr.value.positionInHunk, 0); - assert.equal(itr.value.diffLines.length, 10); + assert.strictEqual(itr.value.oldLineNumber, 5); + assert.strictEqual(itr.value.newLineNumber, 5); + assert.strictEqual(itr.value.oldLength, 6); + assert.strictEqual(itr.value.newLength, 9); + assert.strictEqual(itr.value.positionInHunk, 0); + assert.strictEqual(itr.value.diffLines.length, 10); }); it('should handle deletions', () => { @@ -52,12 +51,12 @@ describe('Extension Tests', function () { const itr = diffHunk.next(); assert.notEqual(itr.value, undefined); - assert.equal(itr.value.oldLineNumber, 5); - assert.equal(itr.value.newLineNumber, 5); - assert.equal(itr.value.oldLength, 9); - assert.equal(itr.value.newLength, 6); - assert.equal(itr.value.positionInHunk, 0); - assert.equal(itr.value.diffLines.length, 10); + assert.strictEqual(itr.value.oldLineNumber, 5); + assert.strictEqual(itr.value.newLineNumber, 5); + assert.strictEqual(itr.value.oldLength, 9); + assert.strictEqual(itr.value.newLength, 6); + assert.strictEqual(itr.value.positionInHunk, 0); + assert.strictEqual(itr.value.diffLines.length, 10); }); it('should handle replacements', () => { @@ -78,12 +77,12 @@ describe('Extension Tests', function () { const itr = diffHunk.next(); assert.notEqual(itr.value, undefined); - assert.equal(itr.value.oldLineNumber, 5); - assert.equal(itr.value.newLineNumber, 5); - assert.equal(itr.value.oldLength, 9); - assert.equal(itr.value.newLength, 7); - assert.equal(itr.value.positionInHunk, 0); - assert.equal(itr.value.diffLines.length, 11); + assert.strictEqual(itr.value.oldLineNumber, 5); + assert.strictEqual(itr.value.newLineNumber, 5); + assert.strictEqual(itr.value.oldLength, 9); + assert.strictEqual(itr.value.newLength, 7); + assert.strictEqual(itr.value.positionInHunk, 0); + assert.strictEqual(itr.value.diffLines.length, 11); }); }); }); 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 9dfb66265d..ef746bf208 100644 --- a/src/test/github/folderRepositoryManager.test.ts +++ b/src/test/github/folderRepositoryManager.test.ts @@ -1,32 +1,47 @@ -import assert = require('assert'); +/*--------------------------------------------------------------------------------------------- + * 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 { createSandbox, SinonSandbox } from 'sinon'; -import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; +import { FolderRepositoryManager, titleAndBodyFrom } from '../../github/folderRepositoryManager'; import { MockRepository } from '../mocks/mockRepository'; import { MockTelemetry } from '../mocks/mockTelemetry'; import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; import { PullRequestModel } from '../../github/pullRequestModel'; -import { Remote } from '../../common/remote'; +import { GitHubRemote, Remote } from '../../common/remote'; import { Protocol } from '../../common/protocol'; import { GitHubRepository } from '../../github/githubRepository'; import { PullRequestBuilder } from '../builders/rest/pullRequestBuilder'; import { convertRESTPullRequestToRawPullRequest } from '../../github/utils'; -import { ApiImpl } from '../../api/api1'; +import { GitApiImpl } from '../../api/api1'; 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 credentialStore = new CredentialStore(telemetry); - manager = new FolderRepositoryManager(repository, telemetry, new ApiImpl(), credentialStore); + const context = new MockExtensionContext(); + const credentialStore = new CredentialStore(telemetry, context); + const repositoriesManager = new RepositoriesManager(credentialStore, telemetry); + manager = new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(repositoriesManager), credentialStore, new CreatePullRequestHelper(), mockThemeWatcher); }); afterEach(function () { @@ -42,14 +57,329 @@ describe('PullRequestManager', function () { const url = 'https://github.com/aaa/bbb.git'; const protocol = new Protocol(url); - const remote = new Remote('origin', url, protocol); - const repository = new GitHubRepository(remote, manager.credentialStore, telemetry); + const remote = new GitHubRemote('origin', url, protocol, GitHubServerType.GitHubDotCom); + const rootUri = Uri.file('C:\\users\\test\\repo'); + 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); assert.deepStrictEqual(manager.activePullRequest, pr); }); }); -}); \ No newline at end of file +}); + +describe('titleAndBodyFrom', function () { + it('separates title and body', async function () { + const message = Promise.resolve('title\n\ndescription 1\n\ndescription 2\n'); + + 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', async function () { + const message = Promise.resolve('title'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, ''); + }); + + it('returns only title when body contains only whitespace', async function () { + const message = Promise.resolve('title\n\n'); + + 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('unwraps 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 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 and 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 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 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'); + }); + + it('handles basic numeric list continuation', async function () { + const message = Promise.resolve('title\n\n1. Basic numeric list\n continuation.\n Third line'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '1. Basic numeric list continuation. Third line'); + }); + + it('handles additional spaces OK for continuation', async function () { + const message = Promise.resolve('title\n\n2. Additional spaces are\n OK for a continuation (unless it\'s 4 spaces which would be a code block).\n Third line'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '2. Additional spaces are OK for a continuation (unless it\'s 4 spaces which would be a code block). Third line'); + }); + + it('handles asterisk list with extra spaces', async function () { + const message = Promise.resolve('title\n\n* Additional spaces are\n OK for a continuation (unless it\'s 4 spaces which would be a code block).\n Third line'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '* Additional spaces are OK for a continuation (unless it\'s 4 spaces which would be a code block). Third line'); + }); + + it('handles multi-digit numbers (10.)', async function () { + const message = Promise.resolve('title\n\n10. Multi-digit numbers should also\n work for a continuation.\n Third line'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '10. Multi-digit numbers should also work for a continuation. Third line'); + }); + + it('handles multi-paragraph list - numbered', async function () { + const message = Promise.resolve('title\n\n11. Multi-paragraph lists are also supported.\n\n Second paragraph in the same list item.\n Third line'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '11. Multi-paragraph lists are also supported.\n\n Second paragraph in the same list item. Third line'); + }); + + it('handles multi-paragraph list - asterisk', async function () { + const message = Promise.resolve('title\n\n* Multi-paragraph lists are also supported.\n\n Second paragraph in the same list item.\n Third line'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '* Multi-paragraph lists are also supported.\n\n Second paragraph in the same list item. Third line'); + }); + + it('handles item with code block - numbered', async function () { + const message = Promise.resolve('title\n\n1. Item with code:\n\n ```\n code line\n code line\n ```'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '1. Item with code:\n\n ```\n code line\n code line\n ```'); + }); + + it('handles item with code block - asterisk', async function () { + const message = Promise.resolve('title\n\n* Item with code:\n\n ```\n code line\n code line\n ```'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '* Item with code:\n\n ```\n code line\n code line\n ```'); + }); + + it('handles fewer spaces OK - numbered (1 space)', async function () { + const message = Promise.resolve('title\n\n1. Fewer spaces are also OK\n for a list continuation (as long as there\'s at least one space)'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '1. Fewer spaces are also OK for a list continuation (as long as there\'s at least one space)'); + }); + + it('handles fewer spaces OK - asterisk (1 space)', async function () { + const message = Promise.resolve('title\n\n* Fewer spaces are also OK\n for a list continuation (as long as there\'s at least one space)'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '* Fewer spaces are also OK for a list continuation (as long as there\'s at least one space)'); + }); + + it('handles nested numbered lists', async function () { + const message = Promise.resolve('title\n\n1. First level item\n continuation of first level\n 1. Nested numbered item\n with continuation'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '1. First level item continuation of first level\n 1. Nested numbered item with continuation'); + }); + + it('handles nested multi-digit numbered lists', async function () { + const message = Promise.resolve('title\n\n10. First level item with\n multi-line content\n 10. Nested with multi-digit\n number and continuation'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '10. First level item with multi-line content\n 10. Nested with multi-digit number and continuation'); + }); + + it('handles nested multi-paragraph lists', async function () { + const message = Promise.resolve('title\n\n* Outer item\n\n Second paragraph of outer\n with continuation\n * Inner item\n\n Second paragraph of inner\n with continuation'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '* Outer item\n\n Second paragraph of outer with continuation\n * Inner item\n\n Second paragraph of inner with continuation'); + }); + + it('handles first list item needs to be unwrapped', async function () { + const message = Promise.resolve('This is a test\n\n- A fslilenfilnf flen felslnf lsefl fnels Leknef\nLkdfnle lfkenSlefn Lnkef LefnLienf LIfnels\n- B\n- C'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'This is a test'); + assert.strictEqual(result?.body, '- A fslilenfilnf flen felslnf lsefl fnels Leknef Lkdfnle lfkenSlefn Lnkef LefnLienf LIfnels\n- B\n- C'); + }); + + it('strips Co-authored-by trailer lines from body', async function () { + const message = Promise.resolve('title\n\nSome description.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'Some description.'); + }); + + it('strips multiple Co-authored-by trailer lines', async function () { + const message = Promise.resolve('title\n\nSome description.\n\nCo-authored-by: Alice <alice@example.com>\nCo-authored-by: Bob <bob@example.com>'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'Some description.'); + }); + + it('strips Co-authored-by case-insensitively', async function () { + const message = Promise.resolve('title\n\nSome description.\n\nco-authored-by: Alice <alice@example.com>'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'Some description.'); + }); + + it('preserves other trailers when stripping Co-authored-by', async function () { + const message = Promise.resolve('title\n\nSome description.\n\nSigned-off-by: Alice <alice@example.com>\nCo-authored-by: Bob <bob@example.com>'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'Some description.\n\nSigned-off-by: Alice <alice@example.com>'); + }); + + it('returns empty body when only Co-authored-by trailers are present', async function () { + const message = Promise.resolve('title\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, ''); + }); +}); diff --git a/src/test/github/githubRepository.test.ts b/src/test/github/githubRepository.test.ts index 3fd8a9109c..e41ba80e0b 100644 --- a/src/test/github/githubRepository.test.ts +++ b/src/test/github/githubRepository.test.ts @@ -1,23 +1,35 @@ -import assert = require('assert'); +/*--------------------------------------------------------------------------------------------- + * 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 { SinonSandbox, createSandbox } from 'sinon'; import { CredentialStore } from '../../github/credentials'; import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; import { MockTelemetry } from '../mocks/mockTelemetry'; -import { Remote } from '../../common/remote'; +import { GitHubRemote, Remote } from '../../common/remote'; import { Protocol } from '../../common/protocol'; import { GitHubRepository } from '../../github/githubRepository'; +import { Uri } from 'vscode'; +import { MockExtensionContext } from '../mocks/mockExtensionContext'; +import { GitHubManager } from '../../authentication/githubServer'; +import { GitHubServerType } from '../../common/authentication'; +import { CheckState, PullRequestCheckStatus } from '../../github/interface'; describe('GitHubRepository', function () { let sinon: SinonSandbox; let credentialStore: CredentialStore; let telemetry: MockTelemetry; + let context: MockExtensionContext; beforeEach(function () { sinon = createSandbox(); MockCommandRegistry.install(sinon); telemetry = new MockTelemetry(); - credentialStore = new CredentialStore(telemetry); + context = new MockExtensionContext(); + credentialStore = new CredentialStore(telemetry, context); }); afterEach(function () { @@ -27,16 +39,185 @@ describe('GitHubRepository', function () { describe('isGitHubDotCom', function () { it('detects when the remote is pointing to github.com', function () { const url = 'https://github.com/some/repo'; - const remote = new Remote('origin', url, new Protocol(url)); - const dotcomRepository = new GitHubRepository(remote, credentialStore, telemetry); - assert(dotcomRepository.isGitHubDotCom); + const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); + const rootUri = Uri.file('C:\\users\\test\\repo'); + const dotcomRepository = new GitHubRepository(1, remote, rootUri, credentialStore, telemetry); + assert(GitHubManager.isGithubDotCom(Uri.parse(remote.url).authority)); }); it('detects when the remote is pointing somewhere other than github.com', function () { const url = 'https://github.enterprise.horse/some/repo'; - const remote = new Remote('origin', url, new Protocol(url)); - const dotcomRepository = new GitHubRepository(remote, credentialStore, telemetry); - assert(!dotcomRepository.isGitHubDotCom); + const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); + const rootUri = Uri.file('C:\\users\\test\\repo'); + const dotcomRepository = new GitHubRepository(1, remote, rootUri, credentialStore, telemetry); + // assert(! dotcomRepository.isGitHubDotCom); + }); + }); + + describe('deduplicateStatusChecks', function () { + function createStatus(overrides: Partial<PullRequestCheckStatus> & { id: string; context: string }): PullRequestCheckStatus { + return { + databaseId: undefined, + url: undefined, + avatarUrl: undefined, + state: CheckState.Success, + description: null, + targetUrl: null, + workflowName: undefined, + event: undefined, + isRequired: false, + isCheckRun: true, + ...overrides, + }; + } + + function callDeduplicateStatusChecks(repo: GitHubRepository, statuses: PullRequestCheckStatus[]): PullRequestCheckStatus[] { + return (repo as any).deduplicateStatusChecks(statuses); + } + + let repo: GitHubRepository; + + beforeEach(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'); + repo = new GitHubRepository(1, remote, rootUri, credentialStore, telemetry); + }); + + it('keeps checks with different events as separate entries', function () { + const statuses = [ + createStatus({ id: '1', context: 'Build Linux / x86-64', event: 'push', workflowName: 'Build Linux' }), + createStatus({ id: '2', context: 'Build Linux / x86-64', event: 'pull_request', workflowName: 'Build Linux' }), + ]; + const result = callDeduplicateStatusChecks(repo, statuses); + assert.strictEqual(result.length, 2); + }); + + it('deduplicates checks with the same name, event, and workflow', function () { + const statuses = [ + createStatus({ id: '1', context: 'Build Linux / x86-64', event: 'push', workflowName: 'Build Linux', state: CheckState.Success }), + createStatus({ id: '2', context: 'Build Linux / x86-64', event: 'push', workflowName: 'Build Linux', state: CheckState.Success }), + ]; + const result = callDeduplicateStatusChecks(repo, statuses); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].id, '2'); // higher ID preferred + }); + + it('keeps checks from different workflows as separate entries', function () { + const statuses = [ + createStatus({ id: '1', context: 'build', event: 'push', workflowName: 'CI' }), + createStatus({ id: '2', context: 'build', event: 'push', workflowName: 'Nightly' }), + ]; + const result = callDeduplicateStatusChecks(repo, statuses); + assert.strictEqual(result.length, 2); + }); + + it('prefers pending checks over completed ones during deduplication', function () { + const statuses = [ + createStatus({ id: '1', context: 'test', event: 'push', workflowName: 'CI', state: CheckState.Success }), + createStatus({ id: '2', context: 'test', event: 'push', workflowName: 'CI', state: CheckState.Pending }), + ]; + const result = callDeduplicateStatusChecks(repo, statuses); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].state, CheckState.Pending); + }); + + it('handles status contexts without event or workflowName', function () { + const statuses = [ + createStatus({ id: '1', context: 'ci/jenkins', isCheckRun: false }), + createStatus({ id: '2', context: 'ci/travis', isCheckRun: false }), + ]; + const result = callDeduplicateStatusChecks(repo, statuses); + assert.strictEqual(result.length, 2); + }); + }); + + describe('computeAwaitingApprovalStatuses', function () { + function callComputeAwaitingApprovalStatuses( + repo: GitHubRepository, + checkSuites: any[] | undefined, + existingStatuses: PullRequestCheckStatus[], + prUrl: string, + ): PullRequestCheckStatus[] { + return (repo as any).computeAwaitingApprovalStatuses(checkSuites, existingStatuses, prUrl); + } + + function createSuite(overrides: Partial<{ status: string; conclusion: string | null; workflowName: string; event: string }>) { + const { status = 'WAITING', conclusion = null, workflowName, event } = overrides; + return { + status, + conclusion, + workflowRun: workflowName ? { event: event ?? 'pull_request', workflow: { name: workflowName } } : null, + app: null, + }; + } + + let repo: GitHubRepository; + + beforeEach(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'); + repo = new GitHubRepository(1, remote, rootUri, credentialStore, telemetry); + }); + + it('returns nothing when there are no check suites', function () { + assert.strictEqual(callComputeAwaitingApprovalStatuses(repo, undefined, [], 'url').length, 0); + assert.strictEqual(callComputeAwaitingApprovalStatuses(repo, [], [], 'url').length, 0); + }); + + it('surfaces a pending status for a waiting workflow', function () { + const suites = [createSuite({ status: 'WAITING', workflowName: 'CI' })]; + const result = callComputeAwaitingApprovalStatuses(repo, suites, [], 'https://github.com/some/repo/pull/1'); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].state, CheckState.Pending); + assert.strictEqual(result[0].context, 'CI'); + assert.strictEqual(result[0].workflowName, 'CI'); + assert.strictEqual(result[0].targetUrl, 'https://github.com/some/repo/pull/1'); + }); + + it('surfaces a pending status for a requested workflow', function () { + const suites = [createSuite({ status: 'REQUESTED', workflowName: 'CI' })]; + const result = callComputeAwaitingApprovalStatuses(repo, suites, [], 'url'); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].state, CheckState.Pending); + }); + + it('ignores suites that have already concluded', function () { + const suites = [createSuite({ status: 'COMPLETED', conclusion: 'SUCCESS', workflowName: 'CI' })]; + assert.strictEqual(callComputeAwaitingApprovalStatuses(repo, suites, [], 'url').length, 0); + }); + + it('ignores suites that are in progress', function () { + const suites = [createSuite({ status: 'IN_PROGRESS', workflowName: 'CI' })]; + assert.strictEqual(callComputeAwaitingApprovalStatuses(repo, suites, [], 'url').length, 0); + }); + + it('does not duplicate a workflow already represented by an existing status', function () { + const suites = [createSuite({ status: 'WAITING', workflowName: 'CI' })]; + const existing = [{ + id: '1', + databaseId: undefined, + url: undefined, + avatarUrl: undefined, + state: CheckState.Success, + description: null, + targetUrl: null, + context: 'CI / build', + workflowName: 'CI', + event: 'pull_request', + isRequired: false, + isCheckRun: true, + } as PullRequestCheckStatus]; + assert.strictEqual(callComputeAwaitingApprovalStatuses(repo, suites, existing, 'url').length, 0); + }); + + it('falls back to a generic context when the workflow name is unknown', function () { + const suites = [createSuite({ status: 'WAITING' })]; + const result = callComputeAwaitingApprovalStatuses(repo, suites, [], 'url'); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].workflowName, undefined); + assert.ok(result[0].context.length > 0); }); }); -}); \ No newline at end of file +}); 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/loggingOctokit.test.ts b/src/test/github/loggingOctokit.test.ts new file mode 100644 index 0000000000..715fc75461 --- /dev/null +++ b/src/test/github/loggingOctokit.test.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { isAuthError, RateLogger } from '../../github/loggingOctokit'; +import { MockTelemetry } from '../mocks/mockTelemetry'; + +describe('loggingOctokit', () => { + describe('isAuthError', () => { + it('returns true for an Octokit-style 401 with "Bad credentials" message', () => { + const e: any = new Error('Bad credentials'); + e.status = 401; + assert.strictEqual(isAuthError(e), true); + }); + + it('returns true for an error whose message is exactly "Bad credentials"', () => { + assert.strictEqual(isAuthError(new Error('Bad credentials')), true); + }); + + it('returns true for an error whose message includes "Bad credentials"', () => { + assert.strictEqual(isAuthError(new Error('HttpError: Bad credentials - https://docs.github.com/rest')), true); + }); + + it('returns true for a GraphQL networkError with statusCode 401', () => { + const e: any = new Error('Network error'); + e.networkError = { statusCode: 401 }; + assert.strictEqual(isAuthError(e), true); + }); + + it('returns true for a GraphQL error message including "401 Unauthorized"', () => { + assert.strictEqual(isAuthError(new Error('Response not successful: Received status code 401 Unauthorized')), true); + }); + + it('returns false for unrelated errors', () => { + const e: any = new Error('Not Found'); + e.status = 404; + assert.strictEqual(isAuthError(e), false); + assert.strictEqual(isAuthError(new Error('Server Error')), false); + assert.strictEqual(isAuthError(undefined), false); + assert.strictEqual(isAuthError(null), false); + assert.strictEqual(isAuthError('Bad credentials'), false); + }); + }); + + describe('RateLogger.logApiError', () => { + it('invokes the auth error handler when the API call fails with a 401/Bad credentials', async () => { + const telemetry = new MockTelemetry(); + let handlerCalls = 0; + const handler = () => { handlerCalls++; }; + const rateLogger = new RateLogger(telemetry, false, handler); + + const e: any = new Error('Bad credentials'); + e.status = 401; + rateLogger.logApiError('/test', Promise.reject(e)); + + // allow microtasks to run + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(handlerCalls, 1); + }); + + it('does not invoke the auth error handler for non-auth errors', async () => { + const telemetry = new MockTelemetry(); + let handlerCalls = 0; + const handler = () => { handlerCalls++; }; + const rateLogger = new RateLogger(telemetry, false, handler); + + const e: any = new Error('Not Found'); + e.status = 404; + rateLogger.logApiError('/test', Promise.reject(e)); + + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(handlerCalls, 0); + }); + + it('swallows exceptions thrown by the auth error handler', async () => { + const telemetry = new MockTelemetry(); + const handler = () => { throw new Error('handler failure'); }; + const rateLogger = new RateLogger(telemetry, false, handler); + + const e: any = new Error('Bad credentials'); + e.status = 401; + // Should not throw. + rateLogger.logApiError('/test', Promise.reject(e)); + await new Promise(resolve => setImmediate(resolve)); + }); + + it('works without an auth error handler', async () => { + const telemetry = new MockTelemetry(); + const rateLogger = new RateLogger(telemetry, false); + + const e: any = new Error('Bad credentials'); + e.status = 401; + rateLogger.logApiError('/test', Promise.reject(e)); + await new Promise(resolve => setImmediate(resolve)); + }); + }); +}); 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 911a06abeb..8c51164df0 100644 --- a/src/test/github/pullRequestGitHelper.test.ts +++ b/src/test/github/pullRequestGitHelper.test.ts @@ -1,10 +1,15 @@ -import assert = require('assert'); +/*--------------------------------------------------------------------------------------------- + * 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 { MockRepository } from '../mocks/mockRepository'; import { PullRequestGitHelper } from '../../github/pullRequestGitHelper'; import { PullRequestModel } from '../../github/pullRequestModel'; import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; -import { Remote } from '../../common/remote'; +import { GitHubRemote, Remote } from '../../common/remote'; import { Protocol } from '../../common/protocol'; import { CredentialStore } from '../../github/credentials'; import { MockTelemetry } from '../mocks/mockTelemetry'; @@ -12,13 +17,17 @@ import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; import { SinonSandbox, createSandbox } from 'sinon'; import { convertRESTPullRequestToRawPullRequest } from '../../github/utils'; import { PullRequestBuilder } from '../builders/rest/pullRequestBuilder'; -import { RefType } from '../../api/api'; +import { RefType } from '../../api/api1'; +import { RepositoryBuilder } from '../builders/rest/repoBuilder'; +import { MockExtensionContext } from '../mocks/mockExtensionContext'; +import { GitHubServerType } from '../../common/authentication'; describe('PullRequestGitHelper', function () { let sinon: SinonSandbox; let repository: MockRepository; let telemetry: MockTelemetry; let credentialStore: CredentialStore; + let context: MockExtensionContext; beforeEach(function () { sinon = createSandbox(); @@ -27,17 +36,110 @@ describe('PullRequestGitHelper', function () { repository = new MockRepository(); telemetry = new MockTelemetry(); - credentialStore = new CredentialStore(telemetry); + context = new MockExtensionContext(); + credentialStore = new CredentialStore(telemetry, context); }); afterEach(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'; - const remote = new Remote('elsewhere', url, new Protocol(url)); + const remote = new GitHubRemote('elsewhere', url, new Protocol(url), GitHubServerType.GitHubDotCom); const gitHubRepository = new MockGitHubRepository(remote, credentialStore, telemetry, sinon); const prItem = convertRESTPullRequestToRawPullRequest( @@ -45,44 +147,98 @@ describe('PullRequestGitHelper', function () { .number(100) .user(u => u.login('me')) .base(b => { - b.repo(r => r.clone_url('git@github.com:owner/name.git')); + (b.repo)(r => (<RepositoryBuilder>r).clone_url('git@github.com:owner/name.git')); }) .head(h => { - h.repo(r => r.clone_url('git@github.com:you/name.git')); + h.repo(r => (<RepositoryBuilder>r).clone_url('git@github.com:you/name.git')); h.ref('my-branch'); }) .build(), 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(pullRequest.isResolved(), 'pull request head not resolved successfully'); - return; + assert(false, 'pull request head not resolved successfully'); } - await PullRequestGitHelper.checkoutFromFork(repository, pullRequest, undefined); + await PullRequestGitHelper.checkoutFromFork(repository, pullRequest, undefined, { report: () => undefined }); - assert.deepEqual(repository.state.remotes, [{ - name: 'you', - fetchUrl: 'git@github.com:you/name', - pushUrl: 'git@github.com:you/name', - isReadOnly: false, - }]); - assert.deepEqual(repository.state.HEAD, { + assert.deepStrictEqual(repository.state.remotes, [ + { + name: 'you', + fetchUrl: 'git@github.com:you/name', + pushUrl: 'git@github.com:you/name', + isReadOnly: false, + }, + ]); + assert.deepStrictEqual(repository.state.HEAD, { type: RefType.Head, name: 'pr/me/100', commit: undefined, upstream: { remote: 'you', name: 'my-branch', - } + }, }); assert.strictEqual(await repository.getConfig('branch.pr/me/100.github-pr-owner-number'), 'owner#name#100'); }); }); -}); \ No newline at end of file + + describe('getMatchingPullRequestMetadataForBranch', function () { + it('returns the highest-numbered PR when duplicate config entries exist for the branch', async function () { + // Simulate the case where a branch name has been associated with multiple + // PRs over time and `git config --get-all` returns duplicate entries. + // The helper should prefer the most recent association (highest PR + // number for the same owner/repo), not the lowest. + sinon.stub(repository, 'getConfigs').resolves([ + { key: 'branch.feature.github-pr-owner-number', value: 'owner#name#5' }, + { key: 'branch.feature.github-pr-owner-number', value: 'owner#name#42' }, + { key: 'branch.feature.github-pr-owner-number', value: 'owner#name#17' }, + { key: 'branch.other.github-pr-owner-number', value: 'owner#name#999' }, + ]); + + const metadata = await PullRequestGitHelper.getMatchingPullRequestMetadataForBranch(repository, 'feature'); + + assert.deepStrictEqual(metadata, { + owner: 'owner', + repositoryName: 'name', + prNumber: 42, + }); + }); + + it('ignores entries whose PR number is not a finite integer', async function () { + // Malformed config values (e.g. `owner#name#abc`) parse to a metadata + // object with `prNumber: NaN`. Such entries must be filtered out so + // they do not poison the numeric sort and cause invalid metadata to + // be returned in preference to a valid entry. + sinon.stub(repository, 'getConfigs').resolves([ + { key: 'branch.feature.github-pr-owner-number', value: 'owner#name#abc' }, + { key: 'branch.feature.github-pr-owner-number', value: 'owner#name#7' }, + ]); + + const metadata = await PullRequestGitHelper.getMatchingPullRequestMetadataForBranch(repository, 'feature'); + + assert.deepStrictEqual(metadata, { + owner: 'owner', + repositoryName: 'name', + prNumber: 7, + }); + }); + + it('returns undefined when no config entries parse to valid metadata', async function () { + sinon.stub(repository, 'getConfigs').resolves([ + { key: 'branch.feature.github-pr-owner-number', value: 'owner#name#abc' }, + { key: 'branch.feature.github-pr-owner-number', value: 'not-valid' }, + ]); + + const metadata = await PullRequestGitHelper.getMatchingPullRequestMetadataForBranch(repository, 'feature'); + + assert.strictEqual(metadata, undefined); + }); + }); +}); diff --git a/src/test/github/pullRequestModel.test.ts b/src/test/github/pullRequestModel.test.ts index 62bfd08f8f..5497cde4ab 100644 --- a/src/test/github/pullRequestModel.test.ts +++ b/src/test/github/pullRequestModel.test.ts @@ -1,55 +1,151 @@ -import assert = require('assert'); +/*--------------------------------------------------------------------------------------------- + * 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 { MockCommandRegistry } from '../mocks/mockCommandRegistry'; import { CredentialStore } from '../../github/credentials'; -import { GitHubRepository } from '../../github/githubRepository'; import { PullRequestModel } from '../../github/pullRequestModel'; import { GithubItemStateEnum } from '../../github/interface'; import { Protocol } from '../../common/protocol'; -import { Remote } from '../../common/remote'; +import { GitHubRemote, Remote } from '../../common/remote'; import { convertRESTPullRequestToRawPullRequest } from '../../github/utils'; import { SinonSandbox, createSandbox } from 'sinon'; import { PullRequestBuilder } from '../builders/rest/pullRequestBuilder'; import { MockTelemetry } from '../mocks/mockTelemetry'; +import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; +import { NetworkStatus } from 'apollo-client'; +import { MockExtensionContext } from '../mocks/mockExtensionContext'; +import { GitHubServerType } from '../../common/authentication'; +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'); -const remote = new Remote('test', 'github/test', protocol); +const remote = new GitHubRemote('test', 'github/test', protocol, GitHubServerType.GitHubDotCom); + +const reviewThreadResponse = { + id: '1', + isResolved: false, + viewerCanResolve: true, + path: 'README.md', + diffSide: 'RIGHT', + startLine: null, + line: 4, + originalStartLine: null, + originalLine: 4, + isOutdated: false, + comments: { + nodes: [ + { + id: 1, + body: "the world's largest frog weighs up to 7.2 lbs", + graphNodeId: '1', + diffHunk: '', + commit: { + oid: '' + }, + reactionGroups: [] + }, + ], + }, +}; describe('PullRequestModel', function () { let sinon: SinonSandbox; let credentials: CredentialStore; - let repo: GitHubRepository; + let repo: MockGitHubRepository; + let context: MockExtensionContext; beforeEach(function () { sinon = createSandbox(); MockCommandRegistry.install(sinon); - credentials = new CredentialStore(telemetry); - repo = new GitHubRepository(remote, credentials, telemetry); + context = new MockExtensionContext(); + credentials = new CredentialStore(telemetry, context); + repo = new MockGitHubRepository(remote, credentials, telemetry, sinon); }); afterEach(function () { + repo.dispose(); + context.dispose(); + credentials.dispose(); sinon.restore(); }); 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.equal(open.state, GithubItemStateEnum.Open); + 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.equal(open.state, GithubItemStateEnum.Closed); + 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); + }); + + describe('reviewThreadCache', 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, + convertRESTPullRequestToRawPullRequest(pr, repo), + ); + + repo.queryProvider.expectGraphQLQuery( + { + query: queries.PullRequestComments, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: pr.number, + }, + }, + { + data: { + repository: { + pullRequest: { + reviewThreads: { + nodes: [ + reviewThreadResponse + ], + pageInfo: { + hasNextPage: false + } + }, + }, + }, + }, + loading: false, + stale: false, + networkStatus: NetworkStatus.ready, + }, + ); + + const onDidChangeReviewThreads = sinon.spy(); + model.onDidChangeReviewThreads(onDidChangeReviewThreads); + + await model.initializeReviewThreadCache(); - assert.equal(open.state, GithubItemStateEnum.Merged); + assert.strictEqual(Object.keys(model.reviewThreadsCache).length, 1); + assert(onDidChangeReviewThreads.calledOnce); + assert.strictEqual(onDidChangeReviewThreads.getCall(0).args[0]['added'].length, 1); + assert.strictEqual(onDidChangeReviewThreads.getCall(0).args[0]['changed'].length, 0); + assert.strictEqual(onDidChangeReviewThreads.getCall(0).args[0]['removed'].length, 0); + }); }); }); diff --git a/src/test/github/pullRequestOverview.test.ts b/src/test/github/pullRequestOverview.test.ts index d6d546d308..d0469014fa 100644 --- a/src/test/github/pullRequestOverview.test.ts +++ b/src/test/github/pullRequestOverview.test.ts @@ -1,34 +1,44 @@ -import assert = require('assert'); +/*--------------------------------------------------------------------------------------------- + * 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 * as path from 'path'; import { SinonSandbox, createSandbox, match as sinonMatch } from 'sinon'; import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; import { MockTelemetry } from '../mocks/mockTelemetry'; import { MockRepository } from '../mocks/mockRepository'; import { PullRequestOverviewPanel } from '../../github/pullRequestOverview'; +import { panelKey } from '../../github/issueOverview'; import { PullRequestModel } from '../../github/pullRequestModel'; import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; -import { Remote } from '../../common/remote'; import { Protocol } from '../../common/protocol'; import { convertRESTPullRequestToRawPullRequest } from '../../github/utils'; import { PullRequestBuilder } from '../builders/rest/pullRequestBuilder'; -import { DescriptionNode } from '../../view/treeNodes/descriptionNode'; -import { TreeNode } from '../../view/treeNodes/treeNode'; import { MockExtensionContext } from '../mocks/mockExtensionContext'; import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; -import { ApiImpl } from '../../api/api1'; +import { GitApiImpl } from '../../api/api1'; 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_PATH = path.resolve(__dirname, '../../..'); +const EXTENSION_URI = vscode.Uri.joinPath(vscode.Uri.file(__dirname), '../../..'); describe('PullRequestOverview', function () { let sinon: SinonSandbox; let pullRequestManager: FolderRepositoryManager; let context: MockExtensionContext; - let remote: Remote; + let remote: GitHubRemote; let repo: MockGitHubRepository; let telemetry: MockTelemetry; + let credentialStore: CredentialStore; + let mockThemeWatcher: MockThemeWatcher; beforeEach(async function () { sinon = createSandbox(); @@ -37,17 +47,21 @@ describe('PullRequestOverview', function () { const repository = new MockRepository(); telemetry = new MockTelemetry(); - const credentialStore = new CredentialStore(telemetry); - pullRequestManager = new FolderRepositoryManager(repository, telemetry, new ApiImpl(), 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 Remote('origin', url, new Protocol(url)); + remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); repo = new MockGitHubRepository(remote, pullRequestManager.credentialStore, telemetry, sinon); }); afterEach(function () { - if (PullRequestOverviewPanel.currentPanel) { - PullRequestOverviewPanel.currentPanel.dispose(); + // Dispose all open panels + for (const panel of (PullRequestOverviewPanel as any)._panels.values()) { + panel.dispose(); } pullRequestManager.dispose(); @@ -57,10 +71,10 @@ describe('PullRequestOverview', function () { describe('createOrShow', function () { it('creates a new panel', async function () { - assert.strictEqual(PullRequestOverviewPanel.currentPanel, undefined); + assert.strictEqual(PullRequestOverviewPanel.findPanel('aaa', 'bbb', 1000), undefined); const createWebviewPanel = sinon.spy(vscode.window, 'createWebviewPanel'); - repo.addGraphQLPullRequest((builder) => { + repo.addGraphQLPullRequest(builder => { builder.pullRequest(response => { response.repository(r => { r.pullRequest(pr => pr.number(1000)); @@ -68,45 +82,34 @@ describe('PullRequestOverview', function () { }); }); - const prItem = convertRESTPullRequestToRawPullRequest( - new PullRequestBuilder().number(1000).build(), - repo, - ); - const prModel = new PullRequestModel(telemetry, repo, remote, prItem); + const prItem = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(1000).build(), repo); + const prModel = new PullRequestModel(credentialStore, telemetry, repo, remote, prItem); + const identity = { owner: prModel.remote.owner, repo: prModel.remote.repositoryName, number: prModel.number }; - const descriptionNode = new DescriptionNode( - new OrphanedTreeNode(), - 'label', - 'https://avatars3.githubusercontent.com/u/17565?v=4', - prModel, - ); + await PullRequestOverviewPanel.createOrShow(telemetry, EXTENSION_URI, pullRequestManager, identity, prModel); - await PullRequestOverviewPanel.createOrShow(EXTENSION_PATH, pullRequestManager, prModel, descriptionNode); - - assert(createWebviewPanel.calledWith( - sinonMatch.string, - 'Pull Request #1000', - vscode.ViewColumn.One, - { + assert( + createWebviewPanel.calledWith(sinonMatch.string, '#1000', vscode.ViewColumn.One, { enableScripts: true, retainContextWhenHidden: true, - localResourceRoots: [vscode.Uri.file(path.resolve(EXTENSION_PATH, 'media'))] - } - )); - assert.notStrictEqual(PullRequestOverviewPanel.currentPanel, undefined); + localResourceRoots: [vscode.Uri.joinPath(EXTENSION_URI, 'dist')], + enableFindWidget: true + }), + ); + assert.notStrictEqual(PullRequestOverviewPanel.findPanel('aaa', 'bbb', 1000), undefined); }); - it('reveals and updates an existing panel', async function () { + it('reveals an existing panel for the same PR', async function () { const createWebviewPanel = sinon.spy(vscode.window, 'createWebviewPanel'); - repo.addGraphQLPullRequest((builder) => { + repo.addGraphQLPullRequest(builder => { builder.pullRequest(response => { response.repository(r => { r.pullRequest(pr => pr.number(1000)); }); }); }); - repo.addGraphQLPullRequest((builder) => { + repo.addGraphQLPullRequest(builder => { builder.pullRequest(response => { response.repository(r => { r.pullRequest(pr => pr.number(2000)); @@ -114,53 +117,76 @@ describe('PullRequestOverview', function () { }); }); - const prItem0 = convertRESTPullRequestToRawPullRequest( - new PullRequestBuilder().number(1000).build(), - repo, - ); - const prModel0 = new PullRequestModel(telemetry, repo, remote, prItem0); - const descriptionNode0 = new DescriptionNode( - new OrphanedTreeNode(), - 'label', - 'https://avatars3.githubusercontent.com/u/17565?v=4', - prModel0, - ); + const prItem0 = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(1000).build(), repo); + 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: 'pending', statuses: [] }); - await PullRequestOverviewPanel.createOrShow(EXTENSION_PATH, pullRequestManager, prModel0, descriptionNode0); + 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; + const panel0 = PullRequestOverviewPanel.findPanel(identity0.owner, identity0.repo, identity0.number); assert.notStrictEqual(panel0, undefined); assert.strictEqual(createWebviewPanel.callCount, 1); + assert.strictEqual(panel0!.getCurrentTitle(), '#1000 New feature'); - const prItem1 = convertRESTPullRequestToRawPullRequest( - new PullRequestBuilder().number(2000).build(), - repo, - ); - const prModel1 = new PullRequestModel(telemetry, repo, remote, prItem1); - const descriptionNode1 = new DescriptionNode( - new OrphanedTreeNode(), - 'label', - 'https://avatars3.githubusercontent.com/u/17565?v=4', - prModel1, - ); + // Opening the same PR again should reuse the existing panel + await PullRequestOverviewPanel.createOrShow(telemetry, EXTENSION_URI, pullRequestManager, identity0, prModel0); + + assert.strictEqual(panel0, PullRequestOverviewPanel.findPanel(identity0.owner, identity0.repo, identity0.number)); + assert.strictEqual(createWebviewPanel.callCount, 1); + }); + + it('creates separate panels for different PRs', async function () { + const createWebviewPanel = sinon.spy(vscode.window, 'createWebviewPanel'); + + repo.addGraphQLPullRequest(builder => { + builder.pullRequest(response => { + response.repository(r => { + r.pullRequest(pr => pr.number(1000)); + }); + }); + }); + repo.addGraphQLPullRequest(builder => { + builder.pullRequest(response => { + response.repository(r => { + r.pullRequest(pr => pr.number(2000)); + }); + }); + }); + + const prItem0 = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(1000).build(), repo); + 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, '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.findPanel(identity0.owner, identity0.repo, identity0.number); + assert.notStrictEqual(panel0, undefined); + assert.strictEqual(createWebviewPanel.callCount, 1); + + const prItem1 = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(2000).build(), repo); + 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: 'pending', statuses: [] }); - await PullRequestOverviewPanel.createOrShow(EXTENSION_PATH, pullRequestManager, prModel1, descriptionNode1); - - assert.strictEqual(panel0, PullRequestOverviewPanel.currentPanel); - assert.strictEqual(createWebviewPanel.callCount, 1); - assert.strictEqual(panel0!.getCurrentTitle(), 'Pull Request #2000'); + 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); + + const panel1 = PullRequestOverviewPanel.findPanel(identity1.owner, identity1.repo, identity1.number); + assert.notStrictEqual(panel1, undefined); + assert.notStrictEqual(panel0, panel1); + assert.strictEqual(createWebviewPanel.callCount, 2); + assert.strictEqual(panel0!.getCurrentTitle(), '#1000 New feature'); + assert.strictEqual(panel1!.getCurrentTitle(), '#2000 New feature'); }); }); }); - -class OrphanedTreeNode extends TreeNode { - getTreeItem(): vscode.TreeItem { - throw new Error('Attempt to get tree item from orphaned node'); - } -} \ No newline at end of file diff --git a/src/test/github/repositoriesManager.test.ts b/src/test/github/repositoriesManager.test.ts new file mode 100644 index 0000000000..e79941605f --- /dev/null +++ b/src/test/github/repositoriesManager.test.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { SinonSandbox, createSandbox } from 'sinon'; +import { default as assert } from 'assert'; + +import { RepositoriesManager } from '../../github/repositoriesManager'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; +import { GitApiImpl } from '../../api/api1'; +import { CredentialStore } from '../../github/credentials'; + +import { MockTelemetry } from '../mocks/mockTelemetry'; +import { MockExtensionContext } from '../mocks/mockExtensionContext'; +import { MockRepository } from '../mocks/mockRepository'; +import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; +import { MockThemeWatcher } from '../mocks/mockThemeWatcher'; +import { CreatePullRequestHelper } from '../../view/createPullRequestHelper'; + +describe('RepositoriesManager', function () { + let sinon: SinonSandbox; + let context: MockExtensionContext; + let telemetry: MockTelemetry; + let credentialStore: CredentialStore; + let reposManager: RepositoriesManager; + let createPrHelper: CreatePullRequestHelper; + let mockThemeWatcher: MockThemeWatcher; + + beforeEach(function () { + sinon = createSandbox(); + MockCommandRegistry.install(sinon); + mockThemeWatcher = new MockThemeWatcher(); + context = new MockExtensionContext(); + telemetry = new MockTelemetry(); + credentialStore = new CredentialStore(telemetry, context); + reposManager = new RepositoriesManager(credentialStore, telemetry); + createPrHelper = new CreatePullRequestHelper(); + }); + + afterEach(function () { + context.dispose(); + sinon.restore(); + }); + + describe('removeRepo', function () { + it('removes only the specified repository when it is not at the last position', function () { + const repo1 = new MockRepository(); + repo1.rootUri = vscode.Uri.file('/repo1'); + repo1.addRemote('origin', 'git@github.com:aaa/bbb'); + + const repo2 = new MockRepository(); + repo2.rootUri = vscode.Uri.file('/repo2'); + repo2.addRemote('origin', 'git@github.com:ccc/ddd'); + + const repo3 = new MockRepository(); + repo3.rootUri = vscode.Uri.file('/repo3'); + repo3.addRemote('origin', 'git@github.com:eee/fff'); + + reposManager.insertFolderManager(new FolderRepositoryManager(0, context, repo1, telemetry, new GitApiImpl(reposManager), credentialStore, createPrHelper, mockThemeWatcher)); + reposManager.insertFolderManager(new FolderRepositoryManager(1, context, repo2, telemetry, new GitApiImpl(reposManager), credentialStore, createPrHelper, mockThemeWatcher)); + reposManager.insertFolderManager(new FolderRepositoryManager(2, context, repo3, telemetry, new GitApiImpl(reposManager), credentialStore, createPrHelper, mockThemeWatcher)); + + assert.strictEqual(reposManager.folderManagers.length, 3); + + // Remove the repo at the first position + reposManager.removeRepo(repo1); + + // Only repo1 should be removed; repo2 and repo3 should remain + assert.strictEqual(reposManager.folderManagers.length, 2); + assert.strictEqual(reposManager.folderManagers[0].repository.rootUri.toString(), repo2.rootUri.toString()); + assert.strictEqual(reposManager.folderManagers[1].repository.rootUri.toString(), repo3.rootUri.toString()); + }); + + it('removes only the specified repository when it is at the last position', function () { + const repo1 = new MockRepository(); + repo1.rootUri = vscode.Uri.file('/repo1'); + repo1.addRemote('origin', 'git@github.com:aaa/bbb'); + + const repo2 = new MockRepository(); + repo2.rootUri = vscode.Uri.file('/repo2'); + repo2.addRemote('origin', 'git@github.com:ccc/ddd'); + + reposManager.insertFolderManager(new FolderRepositoryManager(0, context, repo1, telemetry, new GitApiImpl(reposManager), credentialStore, createPrHelper, mockThemeWatcher)); + reposManager.insertFolderManager(new FolderRepositoryManager(1, context, repo2, telemetry, new GitApiImpl(reposManager), credentialStore, createPrHelper, mockThemeWatcher)); + + assert.strictEqual(reposManager.folderManagers.length, 2); + + // Remove the repo at the last position + reposManager.removeRepo(repo2); + + assert.strictEqual(reposManager.folderManagers.length, 1); + assert.strictEqual(reposManager.folderManagers[0].repository.rootUri.toString(), repo1.rootUri.toString()); + }); + + it('removes only the middle repository leaving others intact', function () { + const repo1 = new MockRepository(); + repo1.rootUri = vscode.Uri.file('/repo1'); + repo1.addRemote('origin', 'git@github.com:aaa/bbb'); + + const repo2 = new MockRepository(); + repo2.rootUri = vscode.Uri.file('/repo2'); + repo2.addRemote('origin', 'git@github.com:ccc/ddd'); + + const repo3 = new MockRepository(); + repo3.rootUri = vscode.Uri.file('/repo3'); + repo3.addRemote('origin', 'git@github.com:eee/fff'); + + reposManager.insertFolderManager(new FolderRepositoryManager(0, context, repo1, telemetry, new GitApiImpl(reposManager), credentialStore, createPrHelper, mockThemeWatcher)); + reposManager.insertFolderManager(new FolderRepositoryManager(1, context, repo2, telemetry, new GitApiImpl(reposManager), credentialStore, createPrHelper, mockThemeWatcher)); + reposManager.insertFolderManager(new FolderRepositoryManager(2, context, repo3, telemetry, new GitApiImpl(reposManager), credentialStore, createPrHelper, mockThemeWatcher)); + + assert.strictEqual(reposManager.folderManagers.length, 3); + + // Remove the middle repo + reposManager.removeRepo(repo2); + + assert.strictEqual(reposManager.folderManagers.length, 2); + assert.strictEqual(reposManager.folderManagers[0].repository.rootUri.toString(), repo1.rootUri.toString()); + assert.strictEqual(reposManager.folderManagers[1].repository.rootUri.toString(), repo3.rootUri.toString()); + }); + + it('does nothing when removing a repo that is not tracked', function () { + const repo1 = new MockRepository(); + repo1.rootUri = vscode.Uri.file('/repo1'); + repo1.addRemote('origin', 'git@github.com:aaa/bbb'); + + const unknownRepo = new MockRepository(); + unknownRepo.rootUri = vscode.Uri.file('/unknown'); + + reposManager.insertFolderManager(new FolderRepositoryManager(0, context, repo1, telemetry, new GitApiImpl(reposManager), credentialStore, createPrHelper, mockThemeWatcher)); + + assert.strictEqual(reposManager.folderManagers.length, 1); + + reposManager.removeRepo(unknownRepo); + + assert.strictEqual(reposManager.folderManagers.length, 1); + assert.strictEqual(reposManager.folderManagers[0].repository.rootUri.toString(), repo1.rootUri.toString()); + }); + }); + + describe('worktree change detection', function () { + it('removes folder manager when its worktree is removed from the main repo', function () { + const mainRepo = new MockRepository(); + mainRepo.rootUri = vscode.Uri.file('/main-repo'); + mainRepo.addRemote('origin', 'git@github.com:aaa/bbb'); + + const worktreeRepo = new MockRepository(); + worktreeRepo.rootUri = vscode.Uri.file('/main-repo/worktrees/feature'); + worktreeRepo.addRemote('origin', 'git@github.com:aaa/bbb'); + + reposManager.insertFolderManager(new FolderRepositoryManager(0, context, mainRepo, telemetry, new GitApiImpl(reposManager), credentialStore, createPrHelper, mockThemeWatcher)); + reposManager.insertFolderManager(new FolderRepositoryManager(1, context, worktreeRepo, telemetry, new GitApiImpl(reposManager), credentialStore, createPrHelper, mockThemeWatcher)); + + assert.strictEqual(reposManager.folderManagers.length, 2); + + // Set initial worktrees on the main repo (includes the worktree) + mainRepo.setWorktrees([ + { name: 'main-repo', path: '/main-repo', ref: 'main', main: true, detached: false }, + { name: 'feature', path: '/main-repo/worktrees/feature', ref: 'feature', main: false, detached: false }, + ]); + + assert.strictEqual(reposManager.folderManagers.length, 2); + + // Worktree is removed - main repo state changes with updated worktrees + mainRepo.setWorktrees([ + { name: 'main-repo', path: '/main-repo', ref: 'main', main: true, detached: false }, + ]); + + assert.strictEqual(reposManager.folderManagers.length, 1); + assert.strictEqual(reposManager.folderManagers[0].repository.rootUri.toString(), mainRepo.rootUri.toString()); + }); + + it('does not remove folder managers when worktrees remain unchanged', function () { + const mainRepo = new MockRepository(); + mainRepo.rootUri = vscode.Uri.file('/main-repo'); + mainRepo.addRemote('origin', 'git@github.com:aaa/bbb'); + + const worktreeRepo = new MockRepository(); + worktreeRepo.rootUri = vscode.Uri.file('/main-repo/worktrees/feature'); + worktreeRepo.addRemote('origin', 'git@github.com:aaa/bbb'); + + reposManager.insertFolderManager(new FolderRepositoryManager(0, context, mainRepo, telemetry, new GitApiImpl(reposManager), credentialStore, createPrHelper, mockThemeWatcher)); + reposManager.insertFolderManager(new FolderRepositoryManager(1, context, worktreeRepo, telemetry, new GitApiImpl(reposManager), credentialStore, createPrHelper, mockThemeWatcher)); + + // Set initial worktrees + mainRepo.setWorktrees([ + { name: 'main-repo', path: '/main-repo', ref: 'main', main: true, detached: false }, + { name: 'feature', path: '/main-repo/worktrees/feature', ref: 'feature', main: false, detached: false }, + ]); + + // Fire state change again with same worktrees + mainRepo.setWorktrees([ + { name: 'main-repo', path: '/main-repo', ref: 'main', main: true, detached: false }, + { name: 'feature', path: '/main-repo/worktrees/feature', ref: 'feature', main: false, detached: false }, + ]); + + assert.strictEqual(reposManager.folderManagers.length, 2); + }); + + it('does nothing when worktrees property is not available', function () { + const repo = new MockRepository(); + repo.rootUri = vscode.Uri.file('/repo'); + repo.addRemote('origin', 'git@github.com:aaa/bbb'); + + reposManager.insertFolderManager(new FolderRepositoryManager(0, context, repo, telemetry, new GitApiImpl(reposManager), credentialStore, createPrHelper, mockThemeWatcher)); + + assert.strictEqual(reposManager.folderManagers.length, 1); + + // Fire state change without setting worktrees (stays undefined) + (repo as any)._onDidChangeState.fire(); + + assert.strictEqual(reposManager.folderManagers.length, 1); + }); + }); +}); diff --git a/src/test/github/utils.test.ts b/src/test/github/utils.test.ts new file mode 100644 index 0000000000..4e7b774d39 --- /dev/null +++ b/src/test/github/utils.test.ts @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { AccountType } from '../../github/interface'; +import { getPRFetchQuery, insertNewCommitsSinceReview, sanitizeIssueTitle, variableSubstitution } from '../../github/utils'; +import { IssueModel } from '../../github/issueModel'; +import { GitHubRef } from '../../common/githubRef'; +import { CommitEvent, EventType, ReviewEvent, TimelineEvent } from '../../common/timelineEvent'; + +describe('utils', () => { + + describe('getPRFetchQuery', () => { + it('replaces all instances of ${user}', () => { + const user = 'rmacfarlane'; + const query = 'reviewed-by:${user} -author:${user}'; + const result = getPRFetchQuery(user, query) + assert.strictEqual(result, 'is:pull-request reviewed-by:rmacfarlane -author:rmacfarlane type:pr'); + }); + }); + + describe('sanitizeIssueTitle', () => { + [ + { input: 'Issue', expected: 'Issue' }, + { input: 'Issue A', expected: 'Issue-A' }, + { input: 'Issue A', expected: 'Issue-A' }, + { input: 'Issue A', expected: 'Issue-A' }, + { input: 'Issue @ A', expected: 'Issue-A' }, + { input: "Issue 'A'", expected: 'Issue-A' }, + { input: 'Issue "A"', expected: 'Issue-A' }, + { input: '@Issue "A"', expected: 'Issue-A' }, + { input: 'Issue "A"%', expected: 'Issue-A' }, + { input: 'Issue .A', expected: 'Issue-A' }, + { input: 'Issue ,A', expected: 'Issue-A' }, + { input: 'Issue :A', expected: 'Issue-A' }, + { input: 'Issue ;A', expected: 'Issue-A' }, + { input: 'Issue ~A', expected: 'Issue-A' }, + { input: 'Issue #A', expected: 'Issue-A' }, + ].forEach(testCase => { + it(`Transforms '${testCase.input}' into '${testCase.expected}'`, () => { + const actual = sanitizeIssueTitle(testCase.input); + assert.strictEqual(actual, testCase.expected); + }); + }); + }); + + describe('variableSubstitution', () => { + function makeIssueModel(overrides: { title?: string; number?: number; issueType?: string } = {}): IssueModel { + const number = overrides.number ?? 42; + const title = overrides.title ?? 'Some Issue'; + return { + number, + title, + item: { + issueType: overrides.issueType, + }, + } as unknown as IssueModel; + } + + it('replaces ${issueType} with the issue type name', () => { + const result = variableSubstitution('${issueType}-${issueNumber}', makeIssueModel({ issueType: 'Feature', number: 7 })); + assert.strictEqual(result, 'Feature-7'); + }); + + it('replaces ${sanitizedIssueType} with a branch-safe issue type', () => { + const result = variableSubstitution('${sanitizedIssueType}-${issueNumber}', makeIssueModel({ issueType: 'Production Bug Fix', number: 7 })); + assert.strictEqual(result, 'Production-Bug-Fix-7'); + }); + + it('replaces ${sanitizedLowercaseIssueType} with a lowercase branch-safe issue type', () => { + const result = variableSubstitution('${sanitizedLowercaseIssueType}-${issueNumber}', makeIssueModel({ issueType: 'Production Bug Fix', number: 7 })); + assert.strictEqual(result, 'production-bug-fix-7'); + }); + + it('leaves ${issueType} unsubstituted when the issue has no issue type', () => { + const result = variableSubstitution('${issueType}-${issueNumber}', makeIssueModel({ issueType: undefined, number: 7 })); + assert.strictEqual(result, '${issueType}-7'); + }); + }); + + describe('insertNewCommitsSinceReview', () => { + const CURRENT_USER = 'octocat'; + const LATEST_REVIEW_SHA = 'shaA'; + const HEAD_SHA = 'shaC'; + + function makeHead(sha: string): GitHubRef { + return new GitHubRef('refs/heads/feature', 'octocat:feature', sha, 'https://github.com/octocat/repo.git', 'octocat', 'repo', false); + } + + function makeCommit(sha: string, committedDate: Date): CommitEvent { + return { + id: sha, + event: EventType.Committed, + sha, + htmlUrl: `https://github.com/octocat/repo/commit/${sha}`, + message: `commit ${sha}`, + committedDate, + author: { + login: CURRENT_USER, + id: '1', + url: 'https://github.com/octocat', + accountType: AccountType.User, + }, + }; + } + + function makeReview(submittedAt: string, login: string = CURRENT_USER): ReviewEvent { + return { + id: 1, + event: EventType.Reviewed, + comments: [], + submittedAt, + body: '', + htmlUrl: '', + authorAssociation: 'OWNER', + user: { + login, + id: '1', + url: `https://github.com/${login}`, + accountType: AccountType.User, + }, + }; + } + + it('moves a commit pushed AFTER the user\'s review under NewCommitsSinceReview', () => { + const reviewTime = new Date('2024-01-01T12:00:00Z'); + const events: TimelineEvent[] = [ + makeCommit(LATEST_REVIEW_SHA, new Date('2024-01-01T11:00:00Z')), + makeReview(reviewTime.toISOString()), + makeCommit(HEAD_SHA, new Date('2024-01-01T13:00:00Z')), + ]; + + insertNewCommitsSinceReview(events, LATEST_REVIEW_SHA, CURRENT_USER, makeHead(HEAD_SHA)); + + // Expected order: latest-review commit, review, NewCommitsSinceReview marker, post-review commit + assert.strictEqual(events.length, 4); + assert.strictEqual(events[0].event, EventType.Committed); + assert.strictEqual((events[0] as CommitEvent).sha, LATEST_REVIEW_SHA); + assert.strictEqual(events[1].event, EventType.Reviewed); + assert.strictEqual(events[2].event, EventType.NewCommitsSinceReview); + assert.strictEqual(events[3].event, EventType.Committed); + assert.strictEqual((events[3] as CommitEvent).sha, HEAD_SHA); + }); + + it('does NOT insert a marker when the only diverging commit was pushed BEFORE the user\'s review (e.g. an attestation commit)', () => { + const reviewTime = new Date('2024-01-01T12:00:00Z'); + const attestationCommitTime = new Date('2024-01-01T11:59:00Z'); // 1 minute before the review + const events: TimelineEvent[] = [ + makeCommit(LATEST_REVIEW_SHA, new Date('2024-01-01T10:00:00Z')), + makeCommit(HEAD_SHA, attestationCommitTime), + makeReview(reviewTime.toISOString()), + ]; + + insertNewCommitsSinceReview(events, LATEST_REVIEW_SHA, CURRENT_USER, makeHead(HEAD_SHA)); + + // Expected: no marker is inserted because there are no commits after the review. + // The pre-review attestation commit stays in its chronological place. + assert.strictEqual(events.length, 3); + assert.strictEqual(events[0].event, EventType.Committed); + assert.strictEqual((events[0] as CommitEvent).sha, LATEST_REVIEW_SHA); + assert.strictEqual(events[1].event, EventType.Committed); + assert.strictEqual((events[1] as CommitEvent).sha, HEAD_SHA); + assert.strictEqual(events[2].event, EventType.Reviewed); + }); + + it('moves only post-review commits when both pre- and post-review commits exist', () => { + const reviewTime = new Date('2024-01-01T12:00:00Z'); + const preReviewSha = 'shaB'; + const events: TimelineEvent[] = [ + makeCommit(LATEST_REVIEW_SHA, new Date('2024-01-01T10:00:00Z')), + makeCommit(preReviewSha, new Date('2024-01-01T11:59:00Z')), + makeReview(reviewTime.toISOString()), + makeCommit(HEAD_SHA, new Date('2024-01-01T13:00:00Z')), + ]; + + insertNewCommitsSinceReview(events, LATEST_REVIEW_SHA, CURRENT_USER, makeHead(HEAD_SHA)); + + assert.strictEqual(events.length, 5); + assert.strictEqual((events[0] as CommitEvent).sha, LATEST_REVIEW_SHA); + assert.strictEqual((events[1] as CommitEvent).sha, preReviewSha); + assert.strictEqual(events[2].event, EventType.Reviewed); + assert.strictEqual(events[3].event, EventType.NewCommitsSinceReview); + assert.strictEqual((events[4] as CommitEvent).sha, HEAD_SHA); + }); + }); +}); \ No newline at end of file diff --git a/src/test/globalHooks.ts b/src/test/globalHooks.ts index 8d183feb0a..9ebdedf6ab 100644 --- a/src/test/globalHooks.ts +++ b/src/test/globalHooks.ts @@ -9,8 +9,8 @@ const original = { beforeEach(function () { const currentTest = this.currentTest as { - consoleOutputs?: string[], - consoleErrors?: string[], + consoleOutputs?: string[]; + consoleErrors?: string[]; }; console.log = function captureLog() { original.log.apply(console, arguments); @@ -27,4 +27,4 @@ beforeEach(function () { afterEach(function () { console.log = original.log; console.error = original.error; -}); \ No newline at end of file +}); diff --git a/src/test/index.ts b/src/test/index.ts index c5678db500..6ded19c0c2 100644 --- a/src/test/index.ts +++ b/src/test/index.ts @@ -1,51 +1,56 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; -import glob = require('glob'); -import Mocha = require('mocha'); - +import glob from 'glob'; +import Mocha from 'mocha'; import { mockWebviewEnvironment } from './mocks/mockWebviewEnvironment'; +import { EXTENSION_ID } from '../constants'; -// Linux: prevent a weird NPE when mocha on Linux requires the window size from the TTY -// Since we are not running in a tty environment, we just implement the method statically. -// This is copied verbatim from the upstream, default Mocha test runner: -// https://github.com/microsoft/vscode-extension-vscode/blob/master/lib/testrunner.ts -const tty = require('tty') as any; -if (!tty.getWindowSize) { - tty.getWindowSize = function () { return [80, 75]; }; -} - -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): Promise<number> { +async function runAllExtensionTests(testsRoot: string, clb: (error: Error | null, failures?: number) => void): Promise<any> { // Ensure the dev-mode extension is activated - const { name, publisher } = require('../../package.json') as { name: string, publisher: string }; - const extensionId = `${publisher}.${name}`; - await vscode.extensions.getExtension(extensionId)!.activate(); + await vscode.extensions.getExtension(EXTENSION_ID)!.activate(); mockWebviewEnvironment.install(global); const mocha = new Mocha({ ui: 'bdd', - useColors: true, + 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, '../../preview-src/dist/preview-src/test')); + // 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', { @@ -54,21 +59,15 @@ async function runAllExtensionTests(testsRoot: string): Promise<number> { mochaFile: process.env.TEST_JUNIT_XML_PATH, suiteTitleSeparatedBy: ' / ', outputs: true, - } + }, }); } - return new Promise((resolve) => mocha.run(resolve)); + return mocha.run(failures => clb(null, failures)); } export function run(testsRoot: string, clb: (error: Error | null, failures?: number) => void): void { require('source-map-support').install(); - runAllExtensionTests(testsRoot).then( - failures => clb(null, failures), - error => { - console.log(error.stack); - clb(error); - }, - ); -} \ No newline at end of file + runAllExtensionTests(testsRoot, clb); +} diff --git a/src/test/issues/issueTodoProvider.test.ts b/src/test/issues/issueTodoProvider.test.ts new file mode 100644 index 0000000000..a29b1f9ea4 --- /dev/null +++ b/src/test/issues/issueTodoProvider.test.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 * as vscode from 'vscode'; +import * as issueUtil from '../../issues/util'; + +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 f793983ba6..318d85d1a8 100644 --- a/src/test/issues/issuesUtils.test.ts +++ b/src/test/issues/issuesUtils.test.ts @@ -1,47 +1,109 @@ -import assert = require('assert'); -import { parseIssueExpressionOutput, ISSUE_OR_URL_EXPRESSION } from '../../issues/util'; +/*--------------------------------------------------------------------------------------------- + * 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 { getIssueOrURLExpression, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput } from '../../github/utils'; describe('Issues utilities', function () { it('regular expressions', async function () { const issueNumber = '#1234'; const issueNumberParsed = parseIssueExpressionOutput(issueNumber.match(ISSUE_OR_URL_EXPRESSION)); - assert.equal(issueNumberParsed?.issueNumber, 1234); - assert.equal(issueNumberParsed?.commentNumber, undefined); - assert.equal(issueNumberParsed?.name, undefined); - assert.equal(issueNumberParsed?.owner, undefined); + assert.strictEqual(issueNumberParsed?.issueNumber, 1234); + assert.strictEqual(issueNumberParsed?.commentNumber, undefined); + assert.strictEqual(issueNumberParsed?.name, undefined); + assert.strictEqual(issueNumberParsed?.owner, undefined); const issueNumberGH = 'GH-321'; const issueNumberGHParsed = parseIssueExpressionOutput(issueNumberGH.match(ISSUE_OR_URL_EXPRESSION)); - assert.equal(issueNumberGHParsed?.issueNumber, 321); - assert.equal(issueNumberGHParsed?.commentNumber, undefined); - assert.equal(issueNumberGHParsed?.name, undefined); - assert.equal(issueNumberGHParsed?.owner, undefined); + assert.strictEqual(issueNumberGHParsed?.issueNumber, 321); + assert.strictEqual(issueNumberGHParsed?.commentNumber, undefined); + assert.strictEqual(issueNumberGHParsed?.name, undefined); + assert.strictEqual(issueNumberGHParsed?.owner, undefined); const issueSingleDigit = '#1'; const issueSingleDigitParsed = parseIssueExpressionOutput(issueSingleDigit.match(ISSUE_OR_URL_EXPRESSION)); - assert.equal(issueSingleDigitParsed?.issueNumber, 1); - assert.equal(issueSingleDigitParsed?.commentNumber, undefined); - assert.equal(issueSingleDigitParsed?.name, undefined); - assert.equal(issueSingleDigitParsed?.owner, undefined); + assert.strictEqual(issueSingleDigitParsed?.issueNumber, 1); + assert.strictEqual(issueSingleDigitParsed?.commentNumber, undefined); + assert.strictEqual(issueSingleDigitParsed?.name, undefined); + assert.strictEqual(issueSingleDigitParsed?.owner, undefined); const issueRepo = 'alexr00/myRepo#234'; const issueRepoParsed = parseIssueExpressionOutput(issueRepo.match(ISSUE_OR_URL_EXPRESSION)); - assert.equal(issueRepoParsed?.issueNumber, 234); - assert.equal(issueRepoParsed?.commentNumber, undefined); - assert.equal(issueRepoParsed?.name, 'myRepo'); - assert.equal(issueRepoParsed?.owner, 'alexr00'); + assert.strictEqual(issueRepoParsed?.issueNumber, 234); + assert.strictEqual(issueRepoParsed?.commentNumber, undefined); + assert.strictEqual(issueRepoParsed?.name, 'myRepo'); + assert.strictEqual(issueRepoParsed?.owner, 'alexr00'); const issueUrl = 'http://github.com/alexr00/myRepo/issues/567'; const issueUrlParsed = parseIssueExpressionOutput(issueUrl.match(ISSUE_OR_URL_EXPRESSION)); - assert.equal(issueUrlParsed?.issueNumber, 567); - assert.equal(issueUrlParsed?.commentNumber, undefined); - assert.equal(issueUrlParsed?.name, 'myRepo'); - assert.equal(issueUrlParsed?.owner, 'alexr00'); + assert.strictEqual(issueUrlParsed?.issueNumber, 567); + assert.strictEqual(issueUrlParsed?.commentNumber, undefined); + assert.strictEqual(issueUrlParsed?.name, 'myRepo'); + assert.strictEqual(issueUrlParsed?.owner, 'alexr00'); const commentUrl = 'https://github.com/microsoft/vscode/issues/96#issuecomment-641150523'; const commentUrlParsed = parseIssueExpressionOutput(commentUrl.match(ISSUE_OR_URL_EXPRESSION)); - assert.equal(commentUrlParsed?.issueNumber, 96); - assert.equal(commentUrlParsed?.commentNumber, 641150523); - assert.equal(commentUrlParsed?.name, 'vscode'); - assert.equal(commentUrlParsed?.owner, 'microsoft'); + assert.strictEqual(commentUrlParsed?.issueNumber, 96); + assert.strictEqual(commentUrlParsed?.commentNumber, 641150523); + assert.strictEqual(commentUrlParsed?.name, 'vscode'); + assert.strictEqual(commentUrlParsed?.owner, 'microsoft'); const notIssue = '#a4'; const notIssueParsed = parseIssueExpressionOutput(notIssue.match(ISSUE_OR_URL_EXPRESSION)); - assert.equal(notIssueParsed, undefined); + 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'); + }); + + it('getIssueOrURLExpression matches enterprise host URLs', function () { + const enterpriseExpression = getIssueOrURLExpression(vscode.Uri.parse('https://my.ghe.host')); + + // Enterprise host URL is matched + const enterpriseIssueUrl = 'https://my.ghe.host/org/repo/issues/123'; + const enterpriseIssueParsed = parseIssueExpressionOutput(enterpriseIssueUrl.match(enterpriseExpression)); + assert.strictEqual(enterpriseIssueParsed?.issueNumber, 123); + assert.strictEqual(enterpriseIssueParsed?.commentNumber, undefined); + assert.strictEqual(enterpriseIssueParsed?.name, 'repo'); + assert.strictEqual(enterpriseIssueParsed?.owner, 'org'); + + // Enterprise PR URL is matched + const enterprisePrUrl = 'https://my.ghe.host/org/repo/pull/456'; + const enterprisePrParsed = parseIssueExpressionOutput(enterprisePrUrl.match(enterpriseExpression)); + assert.strictEqual(enterprisePrParsed?.issueNumber, 456); + assert.strictEqual(enterprisePrParsed?.name, 'repo'); + assert.strictEqual(enterprisePrParsed?.owner, 'org'); + + // Enterprise comment URL is matched + const enterpriseCommentUrl = 'https://my.ghe.host/org/repo/issues/789#issuecomment-12345'; + const enterpriseCommentParsed = parseIssueExpressionOutput(enterpriseCommentUrl.match(enterpriseExpression)); + assert.strictEqual(enterpriseCommentParsed?.issueNumber, 789); + assert.strictEqual(enterpriseCommentParsed?.commentNumber, 12345); + assert.strictEqual(enterpriseCommentParsed?.name, 'repo'); + assert.strictEqual(enterpriseCommentParsed?.owner, 'org'); + + // github.com URLs are still matched when an enterprise URI is provided + const dotComUrl = 'https://github.com/microsoft/vscode/issues/96'; + const dotComParsed = parseIssueExpressionOutput(dotComUrl.match(enterpriseExpression)); + assert.strictEqual(dotComParsed?.issueNumber, 96); + assert.strictEqual(dotComParsed?.name, 'vscode'); + assert.strictEqual(dotComParsed?.owner, 'microsoft'); + + // Without an enterprise URI, only github.com URLs are matched as full URLs + const defaultExpression = getIssueOrURLExpression(); + const enterpriseAgainstDefault = parseIssueExpressionOutput(enterpriseIssueUrl.match(defaultExpression)); + // The owner/repo/number should not match the URL form (the alternate `owner/repo#num` form is also not present here). + assert.strictEqual(enterpriseAgainstDefault, undefined); }); -}); \ No newline at end of file +}); diff --git a/src/test/issues/stateManager.test.ts b/src/test/issues/stateManager.test.ts new file mode 100644 index 0000000000..9102149dc9 --- /dev/null +++ b/src/test/issues/stateManager.test.ts @@ -0,0 +1,277 @@ +/*--------------------------------------------------------------------------------------------- + * 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); + + (stateManager as any)._queries = [{ label: 'Test', query: 'is:open assignee:@me repo:owner/repo ', groupBy: [] }]; + + // Manually trigger the setIssueData flow + await (stateManager as any).setIssueData(mockFolderManager); + + // Await the collection promise so setIssues completes + const collection = stateManager.getIssueCollection(mockUri); + const testQueryPromise = collection.get('Test'); + assert.ok(testQueryPromise, 'Expected issue collection to contain the \'Test\' query label'); + await testQueryPromise; + + // If we get here without assertion failures in getIssues, the test passed + } finally { + vscode.workspace.getConfiguration = originalGetConfiguration; + } + }); + + it('should fire onDidChangeIssueData even when getIssues throws', async function () { + const mockUri = vscode.Uri.parse('file:///test'); + const mockFolderManager = { + repository: { rootUri: mockUri, state: { HEAD: { commit: 'abc123' }, remotes: [] } }, + getIssues: async () => { + throw new Error('Network error'); + }, + getMaxIssue: async () => 0, + }; + + 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 { + const sm = new StateManager(undefined as any, { + folderManagers: [mockFolderManager], + credentialStore: { isAnyAuthenticated: () => true, getCurrentUser: async () => ({ login: 'testuser' }) }, + } as any, mockContext); + + (sm as any)._queries = [{ label: 'Test', query: 'is:open assignee:@me repo:owner/repo', groupBy: [] }]; + + let changeEventCount = 0; + sm.onDidChangeIssueData(() => changeEventCount++); + + await (sm as any).setIssueData(mockFolderManager); + + // setIssueData doesn't await setIssues - await the collection promises so the finally block fires + const collection = sm.getIssueCollection(mockUri); + const queryResult = await collection.get('Test'); + + // The event should have fired even though getIssues threw + assert.ok(changeEventCount > 0, 'onDidChangeIssueData should fire even when getIssues fails'); + assert.strictEqual(queryResult?.issues, undefined, 'Issues should be undefined when getIssues fails'); + } finally { + vscode.workspace.getConfiguration = originalGetConfiguration; + } + }); + + it('should not reject promises in issueCollection when getIssues throws', async function () { + const mockUri = vscode.Uri.parse('file:///test'); + const mockFolderManager = { + repository: { rootUri: mockUri, state: { HEAD: { commit: 'abc123' }, remotes: [] } }, + getIssues: async () => { + throw new Error('API error'); + }, + getMaxIssue: async () => 0, + }; + + 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 repo:owner/repo', groupBy: [] }]; + } + return defaultValue; + }, + } as any; + } + return originalGetConfiguration(section); + }; + + try { + const sm = new StateManager(undefined as any, { + folderManagers: [mockFolderManager], + credentialStore: { isAnyAuthenticated: () => true, getCurrentUser: async () => ({ login: 'testuser' }) }, + } as any, mockContext); + + (sm as any)._queries = [{ label: 'Test', query: 'is:open repo:owner/repo', groupBy: [] }]; + + await (sm as any).setIssueData(mockFolderManager); + + // Verify that the promises in issueCollection resolve (not reject) + const collection = sm.getIssueCollection(mockUri); + for (const [, promise] of collection) { + const result = await promise; + assert.ok(result !== undefined, 'Promise should resolve, not reject'); + assert.strictEqual(result.issues, undefined, 'Issues should be undefined on error'); + } + } finally { + vscode.workspace.getConfiguration = originalGetConfiguration; + } + }); +}); \ No newline at end of file diff --git a/src/test/mocks/inMemoryMemento.ts b/src/test/mocks/inMemoryMemento.ts index 54436ef503..db08795c42 100644 --- a/src/test/mocks/inMemoryMemento.ts +++ b/src/test/mocks/inMemoryMemento.ts @@ -3,7 +3,8 @@ import { Memento } from 'vscode'; export class InMemoryMemento implements Memento { private _storage: { [keyName: string]: any } = {}; - get<T>(key: string): T | undefined; get<T>(key: string, defaultValue: T): T; + get<T>(key: string): T | undefined; + get<T>(key: string, defaultValue: T): T; get(key: string, defaultValue?: any) { return this._storage[key] || defaultValue; } @@ -12,4 +13,10 @@ export class InMemoryMemento implements Memento { this._storage[key] = value; return Promise.resolve(); } -} \ No newline at end of file + + keys(): readonly string[] { + return Object.keys(this._storage); + } + + setKeysForSync(keys: string[]): void {} +} diff --git a/src/test/mocks/mockCommandRegistry.ts b/src/test/mocks/mockCommandRegistry.ts index 9069206c6f..033120f42b 100644 --- a/src/test/mocks/mockCommandRegistry.ts +++ b/src/test/mocks/mockCommandRegistry.ts @@ -33,7 +33,6 @@ export class MockCommandRegistry { private _commands: { [commandName: string]: (args: any[]) => any } = {}; static install(sinon: SinonSandbox) { - // tslint:disable-next-line: no-unused-expression new this(sinon); } @@ -59,4 +58,4 @@ export class MockCommandRegistry { } return callback(rest); } -} \ No newline at end of file +} diff --git a/src/test/mocks/mockExtensionContext.ts b/src/test/mocks/mockExtensionContext.ts index 06845bf90e..b15c19907e 100644 --- a/src/test/mocks/mockExtensionContext.ts +++ b/src/test/mocks/mockExtensionContext.ts @@ -1,20 +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 path from 'path'; import * as temp from 'temp'; -import { ExtensionContext, Uri } from 'vscode'; +import { ExtensionContext, Uri, SecretStorage, Event, SecretStorageChangeEvent, EventEmitter } from 'vscode'; import { InMemoryMemento } from './inMemoryMemento'; export class MockExtensionContext implements ExtensionContext { - extensionPath = path.resolve(__dirname, '..'); + extensionPath: string; workspaceState = new InMemoryMemento(); globalState = new InMemoryMemento(); - subscriptions: { dispose(): any; }[] = []; + secrets = new (class implements SecretStorage { + get(key: string): Thenable<string | undefined> { + throw new Error('Method not implemented.'); + } + 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 }[] = []; storagePath: string; globalStoragePath: string; logPath: string; - extensionUri: Uri; + extensionUri: Uri = Uri.file(path.resolve(__dirname, '..')); environmentVariableCollection: any; extensionMode: any; @@ -25,11 +46,25 @@ export class MockExtensionContext implements ExtensionContext { globalStorageUri: Uri; extensionRuntime: any; + extension: any; + isNewInstall: any; + languageModelAccessInformation = { + onDidChange: new EventEmitter<void>().event, + + canSendRequest: (_chat: any) => { + return undefined; + } + }; constructor() { + this.extensionPath = path.resolve(__dirname, '..'); + this.extensionUri = Uri.file(this.extensionPath); this.storagePath = temp.mkdirSync('storage-path'); + this.storageUri = Uri.file(this.storagePath); this.globalStoragePath = temp.mkdirSync('global-storage-path'); + this.globalStorageUri = Uri.file(this.globalStoragePath); this.logPath = temp.mkdirSync('log-path'); + this.logUri = Uri.file(this.logPath); } asAbsolutePath(relativePath: string): string { @@ -39,4 +74,4 @@ export class MockExtensionContext implements ExtensionContext { dispose() { this.subscriptions.forEach(sub => sub.dispose()); } -} \ No newline at end of file +} diff --git a/src/test/mocks/mockGitHubRepository.ts b/src/test/mocks/mockGitHubRepository.ts index 6b0f6926a5..4bff09d7e9 100644 --- a/src/test/mocks/mockGitHubRepository.ts +++ b/src/test/mocks/mockGitHubRepository.ts @@ -1,60 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { SinonSandbox } from 'sinon'; -import { QueryOptions, ApolloQueryResult, FetchResult, MutationOptions, NetworkStatus } from 'apollo-boost'; +import { QueryOptions, ApolloQueryResult, FetchResult, MutationOptions, NetworkStatus, OperationVariables } from 'apollo-boost'; import { GitHubRepository } from '../../github/githubRepository'; import { QueryProvider } from './queryProvider'; -import { Remote } from '../../common/remote'; +import { GitHubRemote, Remote } from '../../common/remote'; import { CredentialStore } from '../../github/credentials'; import { RepositoryBuilder } from '../builders/rest/repoBuilder'; import { UserBuilder } from '../builders/rest/userBuilder'; -import { ManagedGraphQLPullRequestBuilder, ManagedRESTPullRequestBuilder, ManagedPullRequest } from '../builders/managedPullRequestBuilder'; +import { + ManagedGraphQLPullRequestBuilder, + ManagedRESTPullRequestBuilder, + ManagedPullRequest, +} from '../builders/managedPullRequestBuilder'; import { MockTelemetry } from './mockTelemetry'; -const queries = require('../../github/queries.gql'); +import { Uri } from 'vscode'; +import { LoggingOctokit, RateLogger } from '../../github/loggingOctokit'; +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: Remote, - credentialStore: CredentialStore, - telemetry: MockTelemetry, - sinon: SinonSandbox - ) { - super(remote, credentialStore, telemetry); + constructor(remote: GitHubRemote, credentialStore: CredentialStore, telemetry: MockTelemetry, sinon: SinonSandbox) { + super(1, remote, Uri.file('C:\\users\\test\\repo'), credentialStore, telemetry); this.queryProvider = new QueryProvider(sinon); this._hub = { - octokit: this.queryProvider.octokit, - 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): 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 - ) { + 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'> { @@ -62,26 +72,44 @@ 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; - - this.queryProvider.expectGraphQLQuery({ - query: queries.PullRequest, - variables: { - owner: this.remote.owner, - name: this.remote.repositoryName, - number: prNumber, - } - }, { data: responses.pullRequest, loading: false, stale: false, networkStatus: NetworkStatus.ready }); - - this.queryProvider.expectGraphQLQuery({ - query: queries.TimelineEvents, - variables: { - owner: this.remote.owner, - name: this.remote.repositoryName, - number: prNumber - } - }, { data: responses.timelineEvents, loading: false, stale: false, networkStatus: NetworkStatus.ready }); + const prNumber = responses.pullRequest.repository!.pullRequest.number; + const headRef = responses.pullRequest.repository?.pullRequest.headRef; + + this.queryProvider.expectGraphQLQuery( + { + query: queries.PullRequest, + variables: { + owner: this.remote.owner, + name: this.remote.repositoryName, + number: prNumber, + }, + }, + { data: responses.pullRequest, loading: false, stale: false, networkStatus: NetworkStatus.ready }, + ); + + this.queryProvider.expectGraphQLQuery( + { + query: queries.TimelineEvents, + variables: { + owner: this.remote.owner, + name: this.remote.repositoryName, + number: prNumber, + }, + }, + { data: responses.timelineEvents, loading: false, stale: false, networkStatus: NetworkStatus.ready }, + ); + + this.queryProvider.expectGraphQLQuery( + { + query: queries.LatestReviewCommit, + variables: { + owner: this.remote.owner, + name: this.remote.repositoryName, + number: prNumber, + } + }, + { data: responses.latestReviewCommit, loading: false, stale: false, networkStatus: NetworkStatus.ready }, + ) this._addPullRequestCommon(prNumber, headRef && headRef.target.oid, responses); @@ -131,4 +159,4 @@ export class MockGitHubRepository extends GitHubRepository { responses.reviewRequestsREST, ); } -} \ No newline at end of file +} 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 181eb0dc9e..9206853fdd 100644 --- a/src/test/mocks/mockRepository.ts +++ b/src/test/mocks/mockRepository.ts @@ -1,12 +1,35 @@ -import { Uri } from 'vscode'; - -import { Repository, RepositoryState, RepositoryUIState, Commit, Change, Branch, RefType, CommitOptions } from '../../api/api'; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { EventEmitter, Uri } from 'vscode'; +import { RefType } from '../../api/api1'; + +import type { + Repository, + RepositoryState, + RepositoryUIState, + Commit, + Change, + Branch, + CommitOptions, + InputBox, + Ref, + BranchQuery, + FetchOptions, + RefQuery, + Worktree, +} from '../../api/api'; type Mutable<T> = { -readonly [P in keyof T]: T[P]; }; export class MockRepository implements Repository { + add(paths: string[]): Promise<void> { + return Promise.reject(new Error(`Unexpected add(${paths.join(', ')})`)); + } commit(message: string, opts?: CommitOptions): Promise<void> { return Promise.reject(new Error(`Unexpected commit(${message}, ${opts})`)); } @@ -16,7 +39,7 @@ export class MockRepository implements Repository { getGlobalConfig(key: string): Promise<string> { return Promise.reject(new Error(`Unexpected getGlobalConfig(${key})`)); } - detectObjectType(object: string): Promise<{ mimetype: string; encoding?: string | undefined; }> { + detectObjectType(object: string): Promise<{ mimetype: string; encoding?: string | undefined }> { return Promise.reject(new Error(`Unexpected detectObjectType(${object})`)); } buffer(ref: string, path: string): Promise<Buffer> { @@ -37,26 +60,37 @@ export class MockRepository implements Repository { getMergeBase(ref1: string, ref2: string): Promise<string> { return Promise.reject(new Error(`Unexpected getMergeBase(${ref1}, ${ref2})`)); } + async getRefs(_query: RefQuery, _cancellationToken?: any): Promise<Ref[]> { + // ignore the query + return this._state.refs; + } log(options?: any): Promise<Commit[]> { return Promise.reject(new Error(`Unexpected log(${options})`)); } - private _state: Mutable<RepositoryState> = { - HEAD: undefined, + private _onDidChangeState = new EventEmitter<void>(); + + private _state: Mutable<RepositoryState & { refs: Ref[] }> = { + HEAD: { + type: RefType.Head + }, refs: [], remotes: [], submodules: [], + worktrees: undefined, rebaseCommit: undefined, mergeChanges: [], indexChanges: [], workingTreeChanges: [], - onDidChange: () => ({ dispose() { } }), + onDidChange: this._onDidChangeState.event, }; private _config: Map<string, string> = new Map(); private _branches: Branch[] = []; - private _expectedFetches: { remoteName?: string, ref?: string, depth?: number }[] = []; + private _expectedFetches: { remoteName?: string; ref?: string; depth?: number }[] = []; private _expectedPulls: { unshallow?: boolean }[] = []; - private _expectedPushes: { remoteName?: string, branchName?: string, setUpstream?: boolean }[] = []; + private _expectedPushes: { remoteName?: string; branchName?: string; setUpstream?: boolean }[] = []; + + inputBox: InputBox = { value: '' }; rootUri = Uri.file('/root'); @@ -67,7 +101,7 @@ export class MockRepository implements Repository { onDidChange: () => ({ dispose() { } }), }; - async getConfigs(): Promise<{ key: string, value: string }[]> { + async getConfigs(): Promise<{ key: string; value: string }[]> { return Array.from(this._config, ([k, v]) => ({ key: k, value: v })); } @@ -81,7 +115,7 @@ export class MockRepository implements Repository { return oldValue; } - getObjectDetails(treeish: string, treePath: string): Promise<{ mode: string; object: string; size: number; }> { + getObjectDetails(treeish: string, treePath: string): Promise<{ mode: string; object: string; size: number }> { return Promise.reject(new Error(`Unexpected getObjectDetails(${treeish}, ${treePath})`)); } @@ -121,8 +155,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`); } @@ -156,6 +194,14 @@ export class MockRepository implements Repository { return branch; } + async getBranches(_query: BranchQuery): Promise<Ref[]> { + 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) { @@ -164,7 +210,9 @@ export class MockRepository implements Repository { const match = /^refs\/remotes\/([^\/]+)\/(.+)$/.exec(upstream); if (!match) { - throw new Error(`upstream ${upstream} provided to setBranchUpstream did match pattern refs/remotes/<name>/<remote-branch>`); + throw new Error( + `upstream ${upstream} provided to setBranchUpstream did match pattern refs/remotes/<name>/<remote-branch>`, + ); } const [, remoteName, remoteRef] = match; @@ -220,13 +268,24 @@ export class MockRepository implements Repository { this._state.remotes.splice(index, 1); } - async fetch(remoteName?: string | undefined, ref?: string | undefined, depth?: number | undefined): Promise<void> { - const index = this._expectedFetches.findIndex(f => f.remoteName === remoteName && f.ref === ref && f.depth === depth); + async fetch(arg0?: string | undefined | FetchOptions, ref?: string | undefined, depth?: number | undefined): Promise<void> { + let remoteName: string | undefined; + if (typeof arg0 === 'object') { + remoteName = arg0.remote; + ref = arg0.ref; + depth = arg0.depth; + } else { + remoteName = arg0; + } + + const index = this._expectedFetches.findIndex( + f => f.remoteName === remoteName && f.ref === ref && f.depth === depth, + ); if (index === -1) { throw new Error(`Unexpected fetch(${remoteName}, ${ref}, ${depth})`); } - if (ref) { + if (ref && !this._hasBranch(ref)) { const match = /^(?:\+?[^:]+\:)?(.*)$/.exec(ref); if (match) { const [, localRef] = match; @@ -245,8 +304,14 @@ export class MockRepository implements Repository { this._expectedPulls.splice(index, 1); } - async push(remoteName?: string | undefined, branchName?: string | undefined, setUpstream?: boolean | undefined): Promise<void> { - const index = this._expectedPushes.findIndex(f => f.remoteName === remoteName && f.branchName === branchName && f.setUpstream === setUpstream); + async push( + remoteName?: string | undefined, + branchName?: string | undefined, + setUpstream?: boolean | undefined, + ): Promise<void> { + const index = this._expectedPushes.findIndex( + f => f.remoteName === remoteName && f.branchName === branchName && f.setUpstream === setUpstream, + ); if (index === -1) { throw new Error(`Unexpected push(${remoteName}, ${branchName}, ${setUpstream})`); } @@ -268,4 +333,17 @@ export class MockRepository implements Repository { expectPush(remoteName?: string, branchName?: string, setUpstream?: boolean) { this._expectedPushes.push({ remoteName, branchName, setUpstream }); } -} \ No newline at end of file + + merge(ref: string): Promise<void> { + return Promise.reject(new Error(`Unexpected merge(${ref})`)); + } + + mergeAbort(): Promise<void> { + return Promise.reject(new Error(`Unexpected mergeAbort`)); + } + + setWorktrees(worktrees: Worktree[]) { + this._state.worktrees = worktrees; + this._onDidChangeState.fire(); + } +} diff --git a/src/test/mocks/mockTelemetry.ts b/src/test/mocks/mockTelemetry.ts index e2d576a3df..ad426ff8a3 100644 --- a/src/test/mocks/mockTelemetry.ts +++ b/src/test/mocks/mockTelemetry.ts @@ -1,9 +1,9 @@ - import { ITelemetry } from '../../common/telemetry'; export class MockTelemetry implements ITelemetry { - sendTelemetryEvent() { } - sendTelemetryException() { } - sendTelemetryErrorEvent() { } - dispose() { return Promise.resolve(); } -} \ No newline at end of file + sendTelemetryEvent() {} + sendTelemetryErrorEvent() {} + dispose() { + return Promise.resolve(); + } +} 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/mocks/mockWebviewEnvironment.ts b/src/test/mocks/mockWebviewEnvironment.ts index 4b47c775d3..e3c19af551 100644 --- a/src/test/mocks/mockWebviewEnvironment.ts +++ b/src/test/mocks/mockWebviewEnvironment.ts @@ -1,4 +1,4 @@ -import installJsDomGlobal = require('jsdom-global'); +import installJsDomGlobal from 'jsdom-global'; import { Suite } from 'mocha'; interface WebviewEnvironmentSetters { @@ -31,13 +31,13 @@ class MockWebviewEnvironment { constructor() { this._api = new WebviewVsCodeApi({ - stateSetter: (nState) => { + stateSetter: nState => { this._persistedState = nState; }, stateGetter: () => this._persistedState, - messageAdder: (newMessage) => { + messageAdder: newMessage => { this._messages.push(newMessage); - } + }, }); this._uninstall = () => { }; @@ -46,17 +46,12 @@ class MockWebviewEnvironment { install(host: any) { const previous = host.acquireVsCodeApi; host.acquireVsCodeApi = () => this._api; - const cleanup = installJsDomGlobal('', { - runScripts: 'outside-only', - }); - this._uninstall = () => { if (previous) { host.acquireVsCodeApi = previous; } else { delete host.acquireVsCodeApi; } - cleanup(); }; } @@ -89,4 +84,4 @@ class MockWebviewEnvironment { } } -export const mockWebviewEnvironment = new MockWebviewEnvironment(); \ No newline at end of file +export const mockWebviewEnvironment = new MockWebviewEnvironment(); diff --git a/src/test/mocks/queryProvider.ts b/src/test/mocks/queryProvider.ts index 61eb2a1f9f..5a3834d393 100644 --- a/src/test/mocks/queryProvider.ts +++ b/src/test/mocks/queryProvider.ts @@ -1,7 +1,15 @@ import { inspect } from 'util'; import { Octokit } from '@octokit/rest'; -import { ApolloQueryResult, QueryOptions, DocumentNode, OperationVariables, MutationOptions, FetchResult } from 'apollo-boost'; +import { + ApolloQueryResult, + QueryOptions, + DocumentNode, + OperationVariables, + MutationOptions, + FetchResult, +} from 'apollo-boost'; import { SinonSandbox, SinonStubbedInstance } from 'sinon'; +import equals from 'fast-deep-equal'; interface RecordedQueryResult<T> { variables?: OperationVariables; @@ -29,9 +37,9 @@ export class QueryProvider { } get octokit(): Octokit { - // Cast through "any" because SinonStubbedInstance<Octokit> does not propertly map the type of the + // Cast through "any" because SinonStubbedInstance<Octokit> does not properly map the type of the // overloaded "authenticate" method. - return this._octokit as any as Octokit; + return (this._octokit as any) as Octokit; } expectGraphQLQuery<T>(q: QueryOptions, result: ApolloQueryResult<T>) { @@ -65,9 +73,16 @@ export class QueryProvider { accessorPath.forEach((accessor, i) => { let nextStub = currentStub[accessor]; if (nextStub === undefined) { - nextStub = i < accessorPath.length - 1 ? {} : this._sinon.stub().callsFake((...variables) => { - throw new Error(`Unexpected octokit query: ${accessorPath.join('.')}(${variables.map(v => inspect(v)).join(', ')})`); - }); + nextStub = + i < accessorPath.length - 1 + ? {} + : this._sinon.stub().callsFake((...variables) => { + throw new Error( + `Unexpected octokit query: ${accessorPath.join('.')}(${variables + .map(v => inspect(v)) + .join(', ')})`, + ); + }); currentStub[accessor] = nextStub; } currentStub = nextStub; @@ -77,7 +92,11 @@ export class QueryProvider { emulateGraphQLQuery<T>(q: QueryOptions): ApolloQueryResult<T> { const cannedResponses = this._graphqlQueryResponses.get(q.query) || []; - const cannedResponse = cannedResponses.find(each => !!each.variables && Object.keys(each.variables).every(key => each.variables![key] === q.variables![key])); + const cannedResponse = cannedResponses.find( + each => + !!each.variables && + Object.keys(each.variables).every(key => each.variables![key] === q.variables![key]), + ); if (cannedResponse) { return cannedResponse.result; } else { @@ -92,13 +111,17 @@ export class QueryProvider { } } - emulateGraphQLMutation<T>(m: MutationOptions): FetchResult<T> { + emulateGraphQLMutation<T>(m: MutationOptions<T, OperationVariables>): FetchResult<T> { const cannedResponses = this._graphqlMutationResponses.get(m.mutation) || []; - const cannedResponse = cannedResponses.find(each => !!each.variables && Object.keys(each.variables).every(key => each.variables![key] === m.variables![key])); + const cannedResponse = cannedResponses.find( + each => + !!each.variables && + Object.keys(each.variables).every(key => equals(each.variables![key], m.variables![key])), + ); if (cannedResponse) { return cannedResponse.result; } else { - if (cannedResponses.length > 0) { } { + if (cannedResponses.length > 0) { let message = 'Variables did not match any expected queries:\n'; for (const { variables } of cannedResponses) { message += ` ${inspect(variables, { depth: 3 })}\n`; @@ -108,4 +131,4 @@ export class QueryProvider { throw new Error(`Unexpected GraphQL mutation: ${m}`); } } -} \ No newline at end of file +} 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/runTests.ts b/src/test/runTests.ts index fb683ec84b..ed01fcc776 100644 --- a/src/test/runTests.ts +++ b/src/test/runTests.ts @@ -1,10 +1,11 @@ import * as path from 'path'; -import { runTests } from 'vscode-test'; +import { runTests } from '@vscode/test-electron'; async function go() { try { - const extensionDevelopmentPath = path.resolve(__dirname, '../../'); + const extensionDevelopmentPath = path.resolve(__dirname, '../../../'); const extensionTestsPath = path.resolve(__dirname, './'); + console.log(extensionDevelopmentPath, extensionTestsPath); /** * Basic usage @@ -13,13 +14,12 @@ async function go() { version: 'insiders', extensionDevelopmentPath, extensionTestsPath, - launchArgs: [ - '--disable-extensions' - ] + launchArgs: ['--disable-extensions'], }); } catch (e) { console.log(e); + process.exit(1); } } -go(); \ No newline at end of file +setTimeout(() => go(), 10000); diff --git a/src/test/view/prsTree.test.ts b/src/test/view/prsTree.test.ts index 3eb2f5ceed..881d2a8385 100644 --- a/src/test/view/prsTree.test.ts +++ b/src/test/view/prsTree.test.ts @@ -1,25 +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 { SinonSandbox, createSandbox } from 'sinon'; -import assert = require('assert'); +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 { 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 { ApiImpl } from '../../api/api1'; +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; @@ -27,34 +41,45 @@ 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); - credentialStore = new CredentialStore(telemetry); + 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. sinon.stub(credentialStore, 'showSignInNotification').callsFake(async () => { const github: GitHub = { - octokit: new Octokit({ + octokit: new LoggingOctokit(new Octokit({ request: {}, baseUrl: 'https://github.com', userAgent: 'GitHub VSCode Pull Requests', - previews: ['shadow-cat-preview'] - }), - graphql: null + previews: ['shadow-cat-preview'], + }), new RateLogger(telemetry, true)), + graphql: {} as any, }; return github; }); - - Resource.initialize(context); }); afterEach(function () { @@ -63,109 +88,130 @@ describe('GitHub Pull Requests view', function () { sinon.restore(); }); - it('displays a message when no workspace folders are open', async function () { + it('has no children when no workspace folders are open', async function () { sinon.stub(vscode.workspace, 'workspaceFolders').value(undefined); const rootNodes = await provider.getChildren(); - assert.strictEqual(rootNodes.length, 1); - - const [onlyNode] = rootNodes; - const onlyItem = onlyNode.getTreeItem(); - assert.strictEqual(onlyItem.collapsibleState, vscode.TreeItemCollapsibleState.None); - assert.strictEqual(onlyItem.label, 'You have not yet opened a folder.'); - assert.strictEqual(onlyItem.command, undefined); + assert.strictEqual(rootNodes.length, 0); }); - it('displays a message when no GitHub remotes are available', async function () { - sinon.stub(vscode.workspace, 'workspaceFolders').value([ - { index: 0, name: __dirname, uri: vscode.Uri.file(__dirname) }, - ]); + it('has no children when no GitHub remotes are available', async function () { + sinon + .stub(vscode.workspace, 'workspaceFolders') + .value([{ index: 0, name: __dirname, uri: vscode.Uri.file(__dirname) }]); const rootNodes = await provider.getChildren(); - assert.strictEqual(rootNodes.length, 1); - - const [onlyNode] = rootNodes; - const onlyItem = onlyNode.getTreeItem(); - assert.strictEqual(onlyItem.collapsibleState, vscode.TreeItemCollapsibleState.None); - assert.strictEqual(onlyItem.label, 'No git repositories found.'); - assert.strictEqual(onlyItem.command, undefined); + assert.strictEqual(rootNodes.length, 0); }); - it('displays a message when repositories have not yet been initialized', async 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(repository, telemetry, new ApiImpl(), credentialStore)], credentialStore, telemetry); - provider.initialize(manager); + 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, 1); - - const [onlyNode] = rootNodes; - const onlyItem = onlyNode.getTreeItem(); - assert.strictEqual(onlyItem.collapsibleState, vscode.TreeItemCollapsibleState.None); - assert.strictEqual(onlyItem.label, 'Loading...'); - assert.strictEqual(onlyItem.command, undefined); + assert.strictEqual(rootNodes.length, 0); }); it('opens the viewlet and displays the default categories', async function () { const repository = new MockRepository(); repository.addRemote('origin', 'git@github.com:aaa/bbb'); - - const manager = new RepositoriesManager([new FolderRepositoryManager(repository, telemetry, new ApiImpl(), credentialStore)], credentialStore, telemetry); - sinon.stub(manager, 'createGitHubRepository').callsFake((remote, cStore) => { - return new MockGitHubRepository(remote, cStore, telemetry, sinon); - }); + 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 manager.folderManagers[0].updateRepositories(); - provider.initialize(manager); + await reposManager.folderManagers[0].updateRepositories(); + provider.initialize([], mockNotificationsManager as NotificationsManager); const rootNodes = await provider.getChildren(); - assert(rootNodes.every(n => n.getTreeItem().collapsibleState === vscode.TreeItemCollapsibleState.Collapsed)); - assert.deepEqual(rootNodes.map(n => n.getTreeItem().label), [ - 'Local Pull Request Branches', - 'Waiting For My Review', - 'Assigned To Me', - 'Created By Me', - 'All', - ]); + // 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 folderManager.updateRepositories(); + + // Verify that the tree model's data change event was triggered + assert(onDidChangeDataSpy.calledWith(folderManager), + 'Tree model should fire data change event with the folder manager when GitHub repositories are discovered'); + + // Verify tree now has content + rootNodes = await provider.getChildren(); + const treeItems = await Promise.all(rootNodes.map(node => node.getTreeItem())); + assert.deepStrictEqual( + treeItems.map(n => n.label), + ['Copilot on My Behalf', 'Local Pull Request Branches', 'Waiting For My Review', 'Created By Me', 'All Open'], + 'Tree should display categories after GitHub repositories are discovered', + ); }); describe('Local Pull Request Branches', function () { it('creates a node for each local pull request', async function () { const url = 'git@github.com:aaa/bbb'; - const remote = new Remote('origin', url, new Protocol(url)); + const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); const gitHubRepository = new MockGitHubRepository(remote, credentialStore, telemetry, sinon); gitHubRepository.buildMetadata(m => { m.clone_url('https://github.com/aaa/bbb'); }); - const pr0 = gitHubRepository.addGraphQLPullRequest((builder) => { + const pr0 = gitHubRepository.addGraphQLPullRequest(builder => { builder.pullRequest(pr => { - pr.repository(r => r.pullRequest(p => { - p.number(1111); - p.title('zero'); - p.author(a => a.login('me').avatarUrl('https://avatars.com/me.jpg')); - p.baseRef!(b => b.repository(br => br.url('https://github.com/aaa/bbb'))); - })); + pr.repository(r => + r.pullRequest(p => { + p.databaseId(1111); + p.number(1111); + p.title('zero'); + 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, 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) => { + const pr1 = gitHubRepository.addGraphQLPullRequest(builder => { builder.pullRequest(pr => { - pr.repository(r => r.pullRequest(p => { - p.number(2222); - p.title('one'); - p.author(a => a.login('you').avatarUrl('https://avatars.com/you.jpg')); - p.baseRef!(b => b.repository(br => br.url('https://github.com/aaa/bbb'))); - })); + pr.repository(r => + r.pullRequest(p => { + p.databaseId(2222); + p.number(2222); + p.title('one'); + 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, 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); @@ -177,39 +223,50 @@ describe('GitHub Pull Requests view', function () { await repository.createBranch('non-pr-branch', false); - const manager = new FolderRepositoryManager(repository, telemetry, new ApiImpl(), 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.deepEqual(r, remote); + assert.deepStrictEqual(r, remote); assert.strictEqual(cs, credentialStore); - return gitHubRepository; + 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); + 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 (#1111) by @me'); - assert.strictEqual(localItem0.description, '#1111 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.deepEqual(localItem0.iconPath!.toString(), 'https://avatars.com/me.jpg&s=64'); - - assert.strictEqual(localItem1.label, '✓ one'); - assert.strictEqual(localItem1.tooltip, 'Current Branch * one (#2222) by @you'); - assert.strictEqual(localItem1.description, '#2222 by @you'); + assert.strictEqual(localItem0.contextValue, 'pullrequest:local:nonactive:hasHeadRef'); + assert.deepStrictEqual(localItem0.iconPath!.toString(), 'https://githubusercontent.com/me.jpg'); + + 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.deepEqual(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'); }); }); -}); \ No newline at end of file +}); diff --git a/src/test/view/reviewCommentController.test.ts b/src/test/view/reviewCommentController.test.ts index 1eae72af81..12e7e87a32 100644 --- a/src/test/view/reviewCommentController.test.ts +++ b/src/test/view/reviewCommentController.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import assert = require('assert'); +import { default as assert } from 'assert'; import { SinonSandbox, createSandbox } from 'sinon'; import { CredentialStore } from '../../github/credentials'; import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; @@ -18,22 +18,53 @@ 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 { GitHubRepository } from '../../github/githubRepository'; +import { IResolvedPullRequestModel, PullRequestModel } from '../../github/pullRequestModel'; import { Protocol } from '../../common/protocol'; -import { 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 { ApiImpl } from '../../api/api1'; +import { GitApiImpl } from '../../api/api1'; +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 { RepositoriesManager } from '../../github/repositoriesManager'; +import { GitFileChangeModel } from '../../view/fileChangeModel'; +import { WebviewViewCoordinator } from '../../view/webviewViewCoordinator'; +import { GitHubServerType } from '../../common/authentication'; +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 Remote('test', 'github/test', protocol); +const remote = new GitHubRemote('test', 'github/test', protocol, GitHubServerType.GitHubDotCom); class TestReviewCommentController extends ReviewCommentController { public workspaceFileChangeCommentThreads() { return this._workspaceFileChangeCommentThreads; } + public obsoleteFileChangeCommentThreads() { + return this._obsoleteFileChangeCommentThreads; + } + public reviewSchemeFileChangeCommentThreads() { + return this._reviewSchemeFileChangeCommentThreads; + } + public seedWorkspaceThreads(path: string, threads: GHPRCommentThread[]) { + this._workspaceFileChangeCommentThreads[path] = threads; + } + public seedObsoleteThreads(path: string, threads: GHPRCommentThread[]) { + this._obsoleteFileChangeCommentThreads[path] = threads; + } + public callFindMatchingThread(thread: import('../../common/comment').IReviewThread) { + return (this as any)._findMatchingThread(thread); + } } describe('ReviewCommentController', function () { @@ -44,28 +75,49 @@ describe('ReviewCommentController', function () { let provider: PullRequestsTreeDataProvider; let manager: FolderRepositoryManager; 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(); - credentialStore = new CredentialStore(telemetry); + const context = new MockExtensionContext(); + credentialStore = new CredentialStore(telemetry, context); repository = new MockRepository(); repository.addRemote('origin', 'git@github.com:aaa/bbb'); - - provider = new PullRequestsTreeDataProvider(telemetry); - manager = new FolderRepositoryManager(repository, telemetry, new ApiImpl(), credentialStore); + 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); + 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 new MockGitHubRepository(r, cStore, telemetry, sinon); + return Promise.resolve(new MockGitHubRepository(GitHubRemote.remoteAsGitHub(r, GitHubServerType.GitHubDotCom), cStore, telemetry, sinon)); }); sinon.stub(credentialStore, 'isAuthenticated').returns(false); await manager.updateRepositories(); const pr = new PullRequestBuilder().build(); - const repo = new GitHubRepository(remote, credentialStore, telemetry); - activePullRequest = new PullRequestModel(telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo)); + githubRepo = new MockGitHubRepository(remote, credentialStore, telemetry, sinon); + activePullRequest = new PullRequestModel( + credentialStore, + telemetry, + githubRepo, + remote, + convertRESTPullRequestToRawPullRequest(pr, githubRepo), + ); manager.activePullRequest = activePullRequest; }); @@ -75,82 +127,160 @@ describe('ReviewCommentController', function () { }); function createLocalFileChange(uri: vscode.Uri, fileName: string, rootUri: vscode.Uri): GitFileChangeNode { - return new GitFileChangeNode( - provider.view, + const gitFileChangeModel = new GitFileChangeModel( + manager, activePullRequest, - GitChangeType.MODIFY, - fileName, - 'https://example.com', + { + status: GitChangeType.MODIFY, + fileName, + blobUrl: 'https://example.com', + diffHunks: + [ + { + oldLineNumber: 22, + oldLength: 5, + newLineNumber: 22, + newLength: 11, + positionInHunk: 0, + diffLines: [ + new DiffLine(3, -1, -1, 0, '@@ -22,5 +22,11 @@', true), + new DiffLine(0, 22, 22, 1, " 'title': 'Papayas',", true), + new DiffLine(0, 23, 23, 2, " 'title': 'Papayas',", true), + new DiffLine(0, 24, 24, 3, " 'title': 'Papayas',", true), + new DiffLine(1, -1, 25, 4, '+ {', true), + new DiffLine(1, -1, 26, 5, '+ {', true), + new DiffLine(1, -1, 27, 6, '+ {', true), + new DiffLine(1, -1, 28, 7, '+ {', true), + new DiffLine(1, -1, 29, 8, '+ {', true), + new DiffLine(1, -1, 30, 9, '+ {', true), + new DiffLine(0, 25, 31, 10, '+ {', true), + new DiffLine(0, 26, 32, 11, '+ {', true), + ], + }, + ] + }, uri, toReviewUri(uri, fileName, undefined, '1', false, { base: true }, rootUri), - [ - { - oldLineNumber: 22, - oldLength: 5, - newLineNumber: 22, - newLength: 11, - positionInHunk: 0, - diffLines: [ - new DiffLine(3, -1, -1, 0, '@@ -22,5 +22,11 @@', true), - new DiffLine(0, 22, 22, 1, ' \'title\': \'Papayas\',', true), - new DiffLine(0, 23, 23, 2, ' \'title\': \'Papayas\',', true), - new DiffLine(0, 24, 24, 3, ' \'title\': \'Papayas\',', true), - new DiffLine(1, -1, 25, 4, '+ {', true), - new DiffLine(1, -1, 26, 5, '+ {', true), - new DiffLine(1, -1, 27, 6, '+ {', true), - new DiffLine(1, -1, 28, 7, '+ {', true), - new DiffLine(1, -1, 29, 8, '+ {', true), - new DiffLine(1, -1, 30, 9, '+ {', true), - new DiffLine(0, 25, 31, 10, '+ {', true), - new DiffLine(0, 26, 32, 11, '+ {', true) - ], - }], - [], 'abcd' ); + + return new GitFileChangeNode( + provider, + manager, + activePullRequest as any as PullRequestModel & IResolvedPullRequestModel, + gitFileChangeModel + ); } function createGHPRCommentThread(threadId: string, uri: vscode.Uri): GHPRCommentThread { return { - threadId, + gitHubThreadId: threadId, uri, range: new vscode.Range(new vscode.Position(21, 0), new vscode.Position(21, 0)), comments: [], collapsibleState: vscode.CommentThreadCollapsibleState.Expanded, label: 'Start discussion', - dispose: () => { } + state: { resolved: vscode.CommentThreadState.Unresolved, applicability: 0 }, + canReply: false, + reveal: () => Promise.resolve(), + hide: () => Promise.resolve(), + dispose: () => { }, }; } + describe('initializes workspace thread data', async function () { + const fileName = 'data/products.json'; + const uri = vscode.Uri.parse(`${repository.rootUri.toString()}/${fileName}`); + const localFileChanges = [createLocalFileChange(uri, fileName, repository.rootUri)]; + const reviewModel = new ReviewModel(); + reviewModel.localFileChanges = localFileChanges; + const reviewCommentController = new TestReviewCommentController(reviewManager, manager, repository, reviewModel, gitApiImpl, telemetry); + + sinon.stub(activePullRequest, 'validateDraftMode').returns(Promise.resolve(false)); + sinon.stub(activePullRequest, 'getReviewThreads').returns( + Promise.resolve([ + { + id: '1', + isResolved: false, + viewerCanResolve: false, + viewerCanUnresolve: false, + path: fileName, + diffSide: DiffSide.RIGHT, + startLine: 372, + endLine: 372, + originalStartLine: 372, + originalEndLine: 372, + isOutdated: false, + comments: [ + { + id: 1, + url: '', + diffHunk: '', + body: '', + createdAt: '', + htmlUrl: '', + graphNodeId: '', + isOutdated: false + } + ], + subjectType: SubjectType.LINE + }, + ]), + ); + + 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({ + uri: repository.rootUri, + name: '', + index: 0, + }); + + await reviewCommentController.initialize(); + const workspaceFileChangeCommentThreads = reviewCommentController.workspaceFileChangeCommentThreads(); + assert.strictEqual(Object.keys(workspaceFileChangeCommentThreads).length, 1); + assert.strictEqual(Object.keys(workspaceFileChangeCommentThreads)[0], fileName); + assert.strictEqual(workspaceFileChangeCommentThreads[fileName].length, 1); + }); + describe('createOrReplyComment', function () { it('creates a new comment on an empty thread in a local file', async function () { const fileName = 'data/products.json'; const uri = vscode.Uri.parse(`${repository.rootUri.toString()}/${fileName}`); + await activePullRequest.initializeReviewThreadCache(); const localFileChanges = [createLocalFileChange(uri, fileName, repository.rootUri)]; - const reviewCommentController = new TestReviewCommentController(manager, repository, localFileChanges, [], []); + const reviewModel = new ReviewModel(); + reviewModel.localFileChanges = localFileChanges; + const reviewCommentController = new TestReviewCommentController( + reviewManager, + manager, + repository, + reviewModel, + gitApiImpl, + telemetry + ); const thread = createGHPRCommentThread('review-1.1', uri); sinon.stub(activePullRequest, 'validateDraftMode').returns(Promise.resolve(false)); + sinon.stub(activePullRequest, 'getReviewThreads').returns(Promise.resolve([])); + sinon.stub(activePullRequest, 'getPendingReviewId').returns(Promise.resolve(undefined)); - sinon.stub(manager, 'getCurrentUser').returns({ + sinon.stub(manager, 'getCurrentUser').returns(Promise.resolve({ login: 'rmacfarlane', - url: 'https://github.com/rmacfarlane' - }); - - sinon.stub(reviewCommentController, 'createNewThread' as any).returns(Promise.resolve({ - url: 'https://example.com', - id: 1, - diffHunk: '', - body: 'hello world', - createdAt: '', - htmlUrl: '', - graphNodeId: '' + url: 'https://github.com/rmacfarlane', + id: '123', + accountType: AccountType.User })); sinon.stub(vscode.workspace, 'getWorkspaceFolder').returns({ uri: repository.rootUri, name: '', - index: 0 + index: 0, }); sinon.stub(vscode.workspace, 'asRelativePath').callsFake((pathOrUri: string | vscode.Uri): string => { @@ -160,18 +290,64 @@ describe('ReviewCommentController', function () { sinon.stub(repository, 'diffWith').returns(Promise.resolve('')); - const replaceCommentSpy = sinon.spy(reviewCommentController, 'replaceTemporaryComment' as any); - await reviewCommentController.initialize(); const workspaceFileChangeCommentThreads = reviewCommentController.workspaceFileChangeCommentThreads(); - assert.strictEqual(Object.keys(workspaceFileChangeCommentThreads).length, 1); - assert.strictEqual(Object.keys(workspaceFileChangeCommentThreads)[0], fileName); - assert.strictEqual(workspaceFileChangeCommentThreads[fileName].length, 0); + assert.strictEqual(Object.keys(workspaceFileChangeCommentThreads).length, 0); - await reviewCommentController.createOrReplyComment(thread, 'hello world'); + githubRepo.queryProvider.expectGraphQLMutation( + { + mutation: schema.AddReviewThread, + variables: { + input: { + path: fileName, + body: 'hello world', + pullRequestId: activePullRequest.graphNodeId, + pullRequestReviewId: undefined, + startLine: undefined, + line: 22, + side: 'RIGHT', + subjectType: 'LINE' + } + } + }, + { + data: { + addPullRequestReviewThread: { + thread: { + id: 1, + isResolved: false, + viewCanResolve: true, + path: fileName, + line: 22, + startLine: null, + originalStartLine: null, + originalLine: 22, + diffSide: 'RIGHT', + isOutdated: false, + subjectType: 'LINE', + comments: { + nodes: [ + { + databaseId: 1, + id: 1, + body: 'hello world', + commit: {}, + diffHunk: '', + reactionGroups: [], + author: {} + } + ] + } + } + } + } + } + ) + const newReviewThreadPromise = asPromise(activePullRequest.onDidChangeReviewThreads); + await reviewCommentController.createOrReplyComment(thread, 'hello world', false); + await newReviewThreadPromise; assert.strictEqual(thread.comments.length, 1); - assert(replaceCommentSpy.calledOnce); assert.strictEqual(thread.comments[0].parent, thread); assert.strictEqual(Object.keys(workspaceFileChangeCommentThreads).length, 1); @@ -179,4 +355,61 @@ describe('ReviewCommentController', function () { assert.strictEqual(workspaceFileChangeCommentThreads[fileName].length, 1); }); }); -}); \ No newline at end of file + + describe('_findMatchingThread', function () { + it('returns the moved thread when an existing workspace thread becomes outdated', function () { + const fileName = 'data/products.json'; + const uri = vscode.Uri.parse(`${repository.rootUri.toString()}/${fileName}`); + const reviewModel = new ReviewModel(); + const reviewCommentController = new TestReviewCommentController( + reviewManager, + manager, + repository, + reviewModel, + gitApiImpl, + telemetry, + ); + + const threadA = createGHPRCommentThread('thread-A', uri); + const threadB = createGHPRCommentThread('thread-B', uri); + const existingObsolete = createGHPRCommentThread('thread-C', uri); + + reviewCommentController.seedWorkspaceThreads(fileName, [threadA, threadB]); + reviewCommentController.seedObsoleteThreads(fileName, [existingObsolete]); + + const reviewThread: import('../../common/comment').IReviewThread = { + id: 'thread-B', + isResolved: false, + viewerCanResolve: false, + viewerCanUnresolve: false, + path: fileName, + diffSide: DiffSide.RIGHT, + startLine: 1, + endLine: 1, + originalStartLine: 1, + originalEndLine: 1, + isOutdated: true, + comments: [], + subjectType: SubjectType.LINE, + }; + + const result = reviewCommentController.callFindMatchingThread(reviewThread); + + // The matching thread should be the moved thread, not `undefined` or the wrong one. + assert.strictEqual(result.threadMap, reviewCommentController.obsoleteFileChangeCommentThreads()); + assert.ok(result.index > -1, 'index should point to the moved thread'); + assert.strictEqual(result.threadMap[fileName][result.index], threadB); + + // Workspace map no longer contains the moved thread. + const workspace = reviewCommentController.workspaceFileChangeCommentThreads(); + assert.strictEqual(workspace[fileName].length, 1); + assert.strictEqual(workspace[fileName][0], threadA); + + // Obsolete map now contains the moved thread appended after the pre-existing one. + const obsolete = reviewCommentController.obsoleteFileChangeCommentThreads(); + assert.strictEqual(obsolete[fileName].length, 2); + assert.strictEqual(obsolete[fileName][0], existingObsolete); + assert.strictEqual(obsolete[fileName][1], threadB); + }); + }); +}); diff --git a/src/test/view/reviewManager.test.ts b/src/test/view/reviewManager.test.ts new file mode 100644 index 0000000000..9d47032089 --- /dev/null +++ b/src/test/view/reviewManager.test.ts @@ -0,0 +1,214 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { SinonFakeTimers, SinonSandbox, createSandbox } from 'sinon'; +import * as vscode from 'vscode'; +import { GitApiImpl } from '../../api/api1'; +import { ITelemetry } from '../../common/telemetry'; +import { CredentialStore } from '../../github/credentials'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; +import { PullRequestModel } from '../../github/pullRequestModel'; +import { RepositoriesManager } from '../../github/repositoriesManager'; +import { CreatePullRequestHelper } from '../../view/createPullRequestHelper'; +import { PullRequestChangesTreeDataProvider } from '../../view/prChangesTreeDataProvider'; +import { PullRequestsTreeDataProvider } from '../../view/prsTreeDataProvider'; +import { ReviewManager, ShowPullRequest } from '../../view/reviewManager'; +import { WebviewViewCoordinator } from '../../view/webviewViewCoordinator'; +import { MockPrsTreeModel } from '../mocks/mockPRsTreeModel'; +import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; +import { MockExtensionContext } from '../mocks/mockExtensionContext'; +import { MockRepository } from '../mocks/mockRepository'; +import { MockTelemetry } from '../mocks/mockTelemetry'; +import { MockThemeWatcher } from '../mocks/mockThemeWatcher'; +import { PrsTreeModel } from '../../view/prsTreeModel'; + +describe('ReviewManager polling', function () { + const POLL_MIN_INTERVAL_MS = 1000 * 60 * 5; + const POLL_MAX_INTERVAL_MS = 1000 * 60 * 30; + const POLL_BACKOFF_MULTIPLIER = 2; + + let sinon: SinonSandbox; + let clock: SinonFakeTimers; + let repository: MockRepository; + let telemetry: ITelemetry; + let context: MockExtensionContext; + let reposManager: RepositoriesManager; + let gitApi: GitApiImpl; + let manager: FolderRepositoryManager; + let reviewManager: ReviewManager; + let onDidChangeWindowStateCallback: ((state: vscode.WindowState) => unknown) | undefined; + let isWindowFocused: boolean; + let setTimeoutSpy: sinon.SinonSpy; + + beforeEach(function () { + sinon = createSandbox(); + clock = sinon.useFakeTimers(); + MockCommandRegistry.install(sinon); + setTimeoutSpy = sinon.spy(global, 'setTimeout'); + + isWindowFocused = true; + sinon.stub(vscode.window, 'state').get(() => ({ focused: isWindowFocused } as vscode.WindowState)); + sinon.stub(vscode.window, 'onDidChangeWindowState').callsFake(listener => { + onDidChangeWindowStateCallback = listener; + return new vscode.Disposable(() => { }); + }); + + telemetry = new MockTelemetry(); + context = new MockExtensionContext(); + repository = new MockRepository(); + repository.addRemote('origin', 'git@github.com:aaa/bbb'); + + const credentialStore = new CredentialStore(telemetry, context); + reposManager = new RepositoriesManager(credentialStore, telemetry); + gitApi = new GitApiImpl(reposManager); + + const mockPrsTreeModel = new MockPrsTreeModel() as unknown as PrsTreeModel; + const prsTreeProvider = new PullRequestsTreeDataProvider(mockPrsTreeModel, telemetry, context, reposManager); + const activePrViewCoordinator = new WebviewViewCoordinator(context); + const createPrHelper = new CreatePullRequestHelper(); + const themeWatcher = new MockThemeWatcher(); + + manager = new FolderRepositoryManager(0, context, repository, telemetry, gitApi, credentialStore, createPrHelper, themeWatcher); + reposManager.insertFolderManager(manager); + + const changesTreeProvider = new PullRequestChangesTreeDataProvider(gitApi, reposManager); + reviewManager = new ReviewManager( + 0, + context, + repository, + manager, + telemetry, + changesTreeProvider, + prsTreeProvider, + new ShowPullRequest(), + activePrViewCoordinator, + createPrHelper, + gitApi, + ); + }); + + afterEach(function () { + reviewManager.dispose(); + sinon.restore(); + clock.restore(); + }); + + async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); + } + + function latestScheduledDelay(): number { + const delays = setTimeoutSpy.getCalls() + .map(call => call.args[1]) + .filter((delay): delay is number => typeof delay === 'number'); + assert.ok(delays.length > 0, 'expected at least one scheduled timer'); + return delays[delays.length - 1]; + } + + it('backs off polling interval when no change is detected', async function () { + sinon.stub(reviewManager, 'updateState').resolves(); + + assert.strictEqual(latestScheduledDelay(), POLL_MIN_INTERVAL_MS); + clock.tick(POLL_MIN_INTERVAL_MS); + await flushMicrotasks(); + + assert.strictEqual(latestScheduledDelay(), POLL_MIN_INTERVAL_MS * POLL_BACKOFF_MULTIPLIER); + }); + + it('resets polling interval to minimum when a change is detected', async function () { + // doPoll detects a change by comparing ReviewManager's internal _prNumber / + // _lastCommitSha before and after updateState, so the stub must mutate one of + // those to emulate an observable change. + const internal = reviewManager as unknown as { _prNumber?: number }; + let pollCount = 0; + sinon.stub(reviewManager, 'updateState').callsFake(async () => { + pollCount++; + if (pollCount === 2) { + internal._prNumber = (internal._prNumber ?? 0) + 1; + } + }); + + assert.strictEqual(latestScheduledDelay(), POLL_MIN_INTERVAL_MS); + clock.tick(POLL_MIN_INTERVAL_MS); + await flushMicrotasks(); + assert.strictEqual(latestScheduledDelay(), POLL_MIN_INTERVAL_MS * POLL_BACKOFF_MULTIPLIER); + + clock.tick(POLL_MIN_INTERVAL_MS * POLL_BACKOFF_MULTIPLIER); + await flushMicrotasks(); + assert.strictEqual(latestScheduledDelay(), POLL_MIN_INTERVAL_MS); + }); + + it('applies normal backoff on focus-triggered refresh when no change is detected', async function () { + const MAX_FOCUS_JITTER_MS = 60_000; + sinon.stub(reviewManager, 'updateState').resolves(); + + clock.tick(POLL_MIN_INTERVAL_MS); + await flushMicrotasks(); + assert.strictEqual(latestScheduledDelay(), Math.min(POLL_MAX_INTERVAL_MS, POLL_MIN_INTERVAL_MS * POLL_BACKOFF_MULTIPLIER)); + + isWindowFocused = false; + const callsBeforeSkip = setTimeoutSpy.callCount; + clock.tick(POLL_MIN_INTERVAL_MS * POLL_BACKOFF_MULTIPLIER); + await flushMicrotasks(); + assert.strictEqual(setTimeoutSpy.callCount, callsBeforeSkip, 'no timer should be scheduled while unfocused after skip'); + + isWindowFocused = true; + onDidChangeWindowStateCallback!({ focused: true } as vscode.WindowState); + await flushMicrotasks(); + + // Focus schedules a jittered poll; advance past the max jitter to run it. + clock.tick(MAX_FOCUS_JITTER_MS); + await flushMicrotasks(); + + assert.strictEqual(latestScheduledDelay(), POLL_MIN_INTERVAL_MS * POLL_BACKOFF_MULTIPLIER * POLL_BACKOFF_MULTIPLIER); + }); + + it('window focus triggers a one-off poll when there is no scheduled poll', async function () { + assert.ok(onDidChangeWindowStateCallback, 'window state listener should be registered'); + + sinon.stub(reviewManager, 'updateState').resolves(); + + isWindowFocused = false; + const callsBeforeSkip = setTimeoutSpy.callCount; + clock.tick(POLL_MIN_INTERVAL_MS); + await flushMicrotasks(); + assert.strictEqual(setTimeoutSpy.callCount, callsBeforeSkip, 'timer should not be rescheduled while unfocused'); + + onDidChangeWindowStateCallback!({ focused: true } as vscode.WindowState); + await flushMicrotasks(); + + assert.ok(setTimeoutSpy.callCount > callsBeforeSkip, 'focus should cause one-off refresh and schedule next poll'); + }); + + it('polling refreshes state when active PR exists and new PR activity is detected', async function () { + // Stub the getter rather than assigning through the setter, which has side effects. + sinon.stub(manager, 'activePullRequest').get(() => ({ number: 123 } as unknown as PullRequestModel)); + const updateStateStub = sinon.stub(reviewManager, 'updateState').resolves(); + sinon.stub(reviewManager as unknown as { hasNewPullRequests: () => Promise<boolean> }, 'hasNewPullRequests').resolves(true); + + clock.tick(POLL_MIN_INTERVAL_MS); + await flushMicrotasks(); + + assert.strictEqual(updateStateStub.called, true, 'poll should refresh state when active PR may be stale'); + }); + + it('caps backoff at the maximum interval', async function () { + sinon.stub(reviewManager, 'updateState').resolves(); + + clock.tick(POLL_MIN_INTERVAL_MS); + await flushMicrotasks(); + assert.strictEqual(latestScheduledDelay(), POLL_MIN_INTERVAL_MS * 2); + + clock.tick(POLL_MIN_INTERVAL_MS * 2); + await flushMicrotasks(); + assert.strictEqual(latestScheduledDelay(), POLL_MIN_INTERVAL_MS * 4); + + clock.tick(POLL_MIN_INTERVAL_MS * 4); + await flushMicrotasks(); + assert.strictEqual(latestScheduledDelay(), POLL_MIN_INTERVAL_MS * 6); + }); +}); diff --git a/src/test/view/treeNodes/directoryTreeNode.test.ts b/src/test/view/treeNodes/directoryTreeNode.test.ts new file mode 100644 index 0000000000..47fa9e5f50 --- /dev/null +++ b/src/test/view/treeNodes/directoryTreeNode.test.ts @@ -0,0 +1,262 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { default as assert } from 'assert'; +import { DirectoryTreeNode } from '../../../view/treeNodes/directoryTreeNode'; +import { TreeNode, TreeNodeParent } from '../../../view/treeNodes/treeNode'; + +/** + * Minimal mock for a file-like child node that supports checkboxState. + * This is NOT a DirectoryTreeNode so allChildrenViewed() treats it as a leaf file. + */ +class MockFileNode extends TreeNode { + public checkboxState?: { state: vscode.TreeItemCheckboxState; tooltip?: string; accessibilityInformation?: vscode.AccessibilityInformation }; + constructor(parent: TreeNodeParent) { + super(parent); + } + getTreeItem(): vscode.TreeItem { + return this; + } +} + +function createMockParent(): TreeNodeParent { + return { + refresh: () => { }, + reveal: () => Promise.resolve(), + children: undefined, + view: {} as any + } as any; +} + +describe('DirectoryTreeNode', function () { + describe('allChildrenViewed (via updateCheckboxFromChildren)', function () { + it('sets Checked when all file children are checked', function () { + const dirNode = new DirectoryTreeNode(createMockParent(), 'src'); + + const file1 = new MockFileNode(dirNode); + file1.checkboxState = { state: vscode.TreeItemCheckboxState.Checked }; + const file2 = new MockFileNode(dirNode); + file2.checkboxState = { state: vscode.TreeItemCheckboxState.Checked }; + + (dirNode._children as any[]).push(file1, file2); + + dirNode.updateCheckboxFromChildren(); + assert.strictEqual(dirNode.checkboxState?.state, vscode.TreeItemCheckboxState.Checked); + }); + + it('sets Unchecked when some file children are unchecked', function () { + const dirNode = new DirectoryTreeNode(createMockParent(), 'src'); + + const file1 = new MockFileNode(dirNode); + file1.checkboxState = { state: vscode.TreeItemCheckboxState.Checked }; + const file2 = new MockFileNode(dirNode); + file2.checkboxState = { state: vscode.TreeItemCheckboxState.Unchecked }; + + (dirNode._children as any[]).push(file1, file2); + + dirNode.updateCheckboxFromChildren(); + assert.strictEqual(dirNode.checkboxState?.state, vscode.TreeItemCheckboxState.Unchecked); + }); + + it('sets Unchecked when a file child has no checkboxState', function () { + const dirNode = new DirectoryTreeNode(createMockParent(), 'src'); + + const file1 = new MockFileNode(dirNode); + file1.checkboxState = { state: vscode.TreeItemCheckboxState.Checked }; + const file2 = new MockFileNode(dirNode); + // file2 has no checkboxState + + (dirNode._children as any[]).push(file1, file2); + + dirNode.updateCheckboxFromChildren(); + assert.strictEqual(dirNode.checkboxState?.state, vscode.TreeItemCheckboxState.Unchecked); + }); + + it('sets Checked when nested directories have all children checked', function () { + const parentDir = new DirectoryTreeNode(createMockParent(), 'src'); + const childDir = new DirectoryTreeNode(parentDir, 'utils'); + + const file1 = new MockFileNode(childDir); + file1.checkboxState = { state: vscode.TreeItemCheckboxState.Checked }; + + (childDir._children as any[]).push(file1); + parentDir._children.push(childDir); + + childDir.updateCheckboxFromChildren(); + parentDir.updateCheckboxFromChildren(); + assert.strictEqual(parentDir.checkboxState?.state, vscode.TreeItemCheckboxState.Checked); + }); + + it('sets Unchecked when nested directories have unchecked children', function () { + const parentDir = new DirectoryTreeNode(createMockParent(), 'src'); + const childDir = new DirectoryTreeNode(parentDir, 'utils'); + + const file1 = new MockFileNode(childDir); + file1.checkboxState = { state: vscode.TreeItemCheckboxState.Unchecked }; + + (childDir._children as any[]).push(file1); + parentDir._children.push(childDir); + + childDir.updateCheckboxFromChildren(); + parentDir.updateCheckboxFromChildren(); + assert.strictEqual(parentDir.checkboxState?.state, vscode.TreeItemCheckboxState.Unchecked); + }); + + it('sets Checked when empty directory has no children', function () { + const dirNode = new DirectoryTreeNode(createMockParent(), 'empty'); + dirNode.updateCheckboxFromChildren(); + assert.strictEqual(dirNode.checkboxState?.state, vscode.TreeItemCheckboxState.Checked); + }); + }); + + describe('updateCheckboxFromChildren', function () { + it('sets checkbox to Checked when all children are viewed', function () { + const dirNode = new DirectoryTreeNode(createMockParent(), 'src'); + + const file1 = new MockFileNode(dirNode); + file1.checkboxState = { state: vscode.TreeItemCheckboxState.Checked }; + + (dirNode._children as any[]).push(file1); + + dirNode.updateCheckboxFromChildren(); + + assert.strictEqual(dirNode.checkboxState?.state, vscode.TreeItemCheckboxState.Checked); + }); + + it('sets checkbox to Unchecked when not all children are viewed', function () { + const dirNode = new DirectoryTreeNode(createMockParent(), 'src'); + + const file1 = new MockFileNode(dirNode); + file1.checkboxState = { state: vscode.TreeItemCheckboxState.Checked }; + const file2 = new MockFileNode(dirNode); + file2.checkboxState = { state: vscode.TreeItemCheckboxState.Unchecked }; + + (dirNode._children as any[]).push(file1, file2); + + dirNode.updateCheckboxFromChildren(); + + assert.strictEqual(dirNode.checkboxState?.state, vscode.TreeItemCheckboxState.Unchecked); + }); + + it('propagates state through nested directories', function () { + const parentDir = new DirectoryTreeNode(createMockParent(), 'src'); + const childDir = new DirectoryTreeNode(parentDir, 'utils'); + + const file1 = new MockFileNode(childDir); + file1.checkboxState = { state: vscode.TreeItemCheckboxState.Checked }; + + (childDir._children as any[]).push(file1); + parentDir._children.push(childDir); + + // Update bottom-up (child first, then parent) + childDir.updateCheckboxFromChildren(); + assert.strictEqual(childDir.checkboxState?.state, vscode.TreeItemCheckboxState.Checked); + + parentDir.updateCheckboxFromChildren(); + assert.strictEqual(parentDir.checkboxState?.state, vscode.TreeItemCheckboxState.Checked); + }); + + it('updates parent to Unchecked when a child is unchecked after being checked', function () { + const parentDir = new DirectoryTreeNode(createMockParent(), 'src'); + + const file1 = new MockFileNode(parentDir); + file1.checkboxState = { state: vscode.TreeItemCheckboxState.Checked }; + const file2 = new MockFileNode(parentDir); + file2.checkboxState = { state: vscode.TreeItemCheckboxState.Checked }; + + (parentDir._children as any[]).push(file1, file2); + + // All checked → parent should be Checked + parentDir.updateCheckboxFromChildren(); + assert.strictEqual(parentDir.checkboxState?.state, vscode.TreeItemCheckboxState.Checked); + + // Uncheck one file → parent should be Unchecked + file2.checkboxState = { state: vscode.TreeItemCheckboxState.Unchecked }; + parentDir.updateCheckboxFromChildren(); + assert.strictEqual(parentDir.checkboxState?.state, vscode.TreeItemCheckboxState.Unchecked); + }); + + it('updates correctly with mixed file and directory children', function () { + const rootDir = new DirectoryTreeNode(createMockParent(), 'root'); + const subDir = new DirectoryTreeNode(rootDir, 'sub'); + + const subFile = new MockFileNode(subDir); + subFile.checkboxState = { state: vscode.TreeItemCheckboxState.Checked }; + (subDir._children as any[]).push(subFile); + + const rootFile = new MockFileNode(rootDir); + rootFile.checkboxState = { state: vscode.TreeItemCheckboxState.Checked }; + + (rootDir._children as any[]).push(subDir); + (rootDir._children as any[]).push(rootFile); + + // Update bottom-up + subDir.updateCheckboxFromChildren(); + rootDir.updateCheckboxFromChildren(); + assert.strictEqual(rootDir.checkboxState?.state, vscode.TreeItemCheckboxState.Checked); + + // Uncheck the sub-directory file + subFile.checkboxState = { state: vscode.TreeItemCheckboxState.Unchecked }; + subDir.updateCheckboxFromChildren(); + rootDir.updateCheckboxFromChildren(); + assert.strictEqual(subDir.checkboxState?.state, vscode.TreeItemCheckboxState.Unchecked); + assert.strictEqual(rootDir.checkboxState?.state, vscode.TreeItemCheckboxState.Unchecked); + }); + }); + + describe('parent pointer after phantom-root pull-up', function () { + // When a tree has multiple top-level directories, a temporary DirectoryTreeNode + // with label='' (phantom root) is used to build the tree, then its children are + // pulled up to the actual container. If children are not re-parented, getParent() + // still returns the phantom DirectoryTreeNode and processCheckboxUpdates fires + // refresh() on an invisible node, so checkbox state never updates visually. + + it('getParent() returns undefined after child is re-parented to the container', function () { + const container = createMockParent(); + const phantomRoot = new DirectoryTreeNode(container, ''); + const subDir = new DirectoryTreeNode(phantomRoot, 'src'); + + // Before re-parenting: parent is the phantom root DirectoryTreeNode + assert.ok(subDir.getParent() instanceof DirectoryTreeNode, 'sanity: should point to phantom root before re-parenting'); + + // Simulate the pull-up re-parenting fix applied in filesCategoryNode / pullRequestNode / commitNode + subDir.parent = container; + + // After re-parenting: container is not a TreeNode, so getParent() returns undefined. + // This means the ancestor walk in processCheckboxUpdates stops here and refresh() + // is called on the visible subDir node, not the invisible phantom root. + assert.strictEqual(subDir.getParent(), undefined, 'after re-parenting, getParent() should not return the phantom root'); + }); + + it('ancestor walk stops at the topmost visible directory after re-parenting', function () { + const container = createMockParent(); + const phantomRoot = new DirectoryTreeNode(container, ''); + const topDir = new DirectoryTreeNode(phantomRoot, 'cloud'); + const subDir = new DirectoryTreeNode(topDir, 'helm'); + const file = new MockFileNode(subDir); + file.checkboxState = { state: vscode.TreeItemCheckboxState.Checked }; + + (subDir._children as any[]).push(file); + topDir._children.push(subDir); + + // Re-parent: simulate fix + topDir.parent = container; + + // Walk ancestors from file, mimicking processCheckboxUpdates + const ancestors: TreeNode[] = []; + let current = file.getParent(); + while (current instanceof DirectoryTreeNode) { + ancestors.push(current); + current = current.getParent(); + } + + // Should find subDir and topDir, but NOT the phantom root + assert.strictEqual(ancestors.length, 2); + assert.strictEqual(ancestors[0], subDir); + assert.strictEqual(ancestors[1], topDir); + }); + }); +}); 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/checkRunLogContentProvider.ts b/src/view/checkRunLogContentProvider.ts new file mode 100644 index 0000000000..20189a8ed0 --- /dev/null +++ b/src/view/checkRunLogContentProvider.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * 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 Logger from '../common/logger'; +import { Schemes } from '../common/uri'; +import { formatError } from '../common/utils'; +import { RepositoriesManager } from '../github/repositoriesManager'; + +interface CheckRunLogParams { + owner: string; + repo: string; + checkRunDatabaseId: number; + checkName: string; +} + +export function toCheckRunLogUri(params: CheckRunLogParams): vscode.Uri { + return vscode.Uri.from({ + scheme: Schemes.CheckRunLog, + path: `/${params.owner}/${params.repo}/${params.checkName}.log`, + query: JSON.stringify(params), + }); +} + +function fromCheckRunLogUri(uri: vscode.Uri): CheckRunLogParams | undefined { + if (uri.scheme !== Schemes.CheckRunLog) { + return undefined; + } + try { + return JSON.parse(uri.query); + } catch { + return undefined; + } +} + +export class CheckRunLogContentProvider implements vscode.TextDocumentContentProvider { + constructor(private readonly _reposManager: RepositoriesManager) { } + + async provideTextDocumentContent(uri: vscode.Uri): Promise<string> { + const params = fromCheckRunLogUri(uri); + if (!params) { + return ''; + } + + for (const folderManager of this._reposManager.folderManagers) { + const repo = folderManager.findRepo(r => + r.remote.owner === params.owner && r.remote.repositoryName === params.repo + ); + if (repo) { + try { + return await repo.getCheckRunLogs(params.checkRunDatabaseId); + } catch (e) { + Logger.error(`Failed to fetch check run logs: ${formatError(e)}`, 'CheckRunLog'); + return `Failed to fetch check run logs. See logs for details.`; + } + } + } + + Logger.error(`No repository found for ${params.owner}/${params.repo}`, 'CheckRunLog'); + return `Unable to fetch logs: repository ${params.owner}/${params.repo} not found.`; + } +} 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/commentThreadCache.ts b/src/view/commentThreadCache.ts deleted file mode 100644 index f7f3b0eeb5..0000000000 --- a/src/view/commentThreadCache.ts +++ /dev/null @@ -1,60 +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 { GHPRCommentThread } from '../github/prComment'; -import * as vscode from 'vscode'; - -export class CommentThreadCache { - private _data: { [key: string]: { original?: GHPRCommentThread[], modified?: GHPRCommentThread[] } } = {}; - - public setDocumentThreads(fileName: string, isBase: boolean, threads: GHPRCommentThread[] | undefined) { - if (!this._data[fileName]) { - this._data[fileName] = {}; - } - - if (isBase) { - this._data[fileName].original = threads; - } else { - this._data[fileName].modified = threads; - } - } - - public getDocuments(): string[] { - return Object.keys(this._data); - } - - public getThreadsForDocument(fileName: string, isBase: boolean): GHPRCommentThread[] | undefined { - const documentData = this._data[fileName]; - return isBase ? documentData && documentData.original : documentData && documentData.modified; - } - - public getAllThreadsForDocument(fileName: string): GHPRCommentThread[] | undefined { - return this._data[fileName] && (this._data[fileName].original || []).concat(this._data[fileName].modified || []); - } - - public maybeDisposeThreads(visibleEditors: vscode.TextEditor[], matchEditor: (editor: vscode.TextEditor, fileName: string, isBase: boolean) => boolean) { - for (const fileName in this._data) { - const threads = this._data[fileName]; - - const originalEditor = visibleEditors.find(editor => matchEditor(editor, fileName, true)); - - if (!originalEditor && threads.original) { - threads.original.forEach(thread => thread.dispose!()); - this._data[fileName].original = undefined; - } - - const modifiedEditor = visibleEditors.find(editor => matchEditor(editor, fileName, false)); - - if (!modifiedEditor && threads.modified) { - threads.modified.forEach(thread => thread.dispose!()); - this._data[fileName].modified = undefined; - } - - if (!this._data[fileName].original && !this._data[fileName].modified) { - delete this._data[fileName]; - } - } - } -} \ No newline at end of file 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 new file mode 100644 index 0000000000..edae2c9721 --- /dev/null +++ b/src/view/compareChangesTreeDataProvider.ts @@ -0,0 +1,392 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { GitHubFileChangeNode } from './treeNodes/fileChangeNode'; +import { BaseTreeNode, TreeNode, TreeNodeParent } from './treeNodes/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; + } +} + +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 + }; + } + + 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, + }); + + const rawFiles = data.files; + + 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, + ); + }); + } + + constructor(parent: TreeNodeParent, private readonly model: CreatePullRequestDataModel, private readonly commit: OctokitCommon.CompareCommits['commits'][0], private readonly parentRef) { + super(parent); + } +} + +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, + ); + }); + } + + constructor(parent: TreeNodeParent, private readonly commit: Commit, private readonly folderRepoManager: FolderRepositoryManager, private readonly parentRef) { + super(parent); + } +} + +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; + } + + set view(view: vscode.TreeView<TreeNode>) { + this._view = this._register(view); + } + + 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> { + return this._view.reveal(treeNode, options); + } + + refresh(): void { + this._onDidChangeTreeData.fire(); + } + + getTreeItem(element: TreeNode): vscode.TreeItem | Thenable<vscode.TreeItem> { + return element.getTreeItem(); + } + + 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 {}; + } + } + + protected abstract getGitHubChildren(element?: TreeNode): Promise<TreeNode[] | undefined>; + + protected abstract getGitChildren(element?: TreeNode): Promise<TreeNode[] | undefined>; + + get children(): TreeNode[] | undefined { + return this._children; + } + + 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; + } + + 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); + } + } + })); + } +} + +class CompareChangesFilesTreeProvider extends CompareChangesTreeProvider { + constructor( + model: CreatePullRequestDataModel, + private folderRepoManager: FolderRepositoryManager, + ) { + super(model); + } + + protected async getGitHubChildren(element?: TreeNode) { + if (element) { + return element.getChildren(); + } + + 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, + ); + }); + } + } + + 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, + ); + }); + } + + 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; + } + + 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; + } + + 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); + }); + } + } + + 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); + }); + } +} + +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..652fa52dcd --- /dev/null +++ b/src/view/createPullRequestDataModel.ts @@ -0,0 +1,309 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; +import { getIssueNumberLabelFromParsed, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput } from '../github/utils'; + +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!; + } + + public 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.getCompareHasUpstream()) { + [commitMessages, patches] = await Promise.all([ + this.gitHubCommits().then(rawCommits => rawCommits.map(commit => commit.commit.message)), + this.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 { + [commitMessages, patches] = await Promise.all([ + this.gitCommits().then(rawCommits => rawCommits.filter(commit => commit.parents.length === 1).map(commit => commit.message)), + Promise.all((await this.gitFiles()).map(async (file) => { + return { + patch: await this.folderRepositoryManager.repository.diffBetween(this._baseBranch, this._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 }; + } + + public 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._baseOwner; + const name = tryParse.name ?? this.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; + } +} \ No newline at end of file diff --git a/src/view/createPullRequestHelper.ts b/src/view/createPullRequestHelper.ts new file mode 100644 index 0000000000..63339033e8 --- /dev/null +++ b/src/view/createPullRequestHelper.ts @@ -0,0 +1,291 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CompareChanges } from './compareChangesTreeDataProvider'; +import { CreatePullRequestDataModel } from './createPullRequestDataModel'; +import { Repository } from '../api/api'; +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 { RevertPullRequestViewProvider } from '../github/revertPRViewProvider'; + +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; + + constructor() { + super(); + } + + private async setActiveContext(value: boolean) { + if (this._activeContext) { + await commands.setContext(this._activeContext, value); + } + } + + private registerListeners(repository: Repository, usingCurrentBranchAsCompare: boolean) { + addDisposable( + this._createPRViewProvider!.onDone(async createdPR => { + await CreatePullRequestViewProvider.withProgress(async () => { + return this._postCreateCallback?.(createdPR); + }); + this.dispose(); + }), + this._currentDisposables + ); + + addDisposable( + vscode.commands.registerCommand('pr.addAssigneesToNewPr', _ => { + return this._createPRViewProvider?.addAssignees(); + + }), + this._currentDisposables + ); + + addDisposable( + vscode.commands.registerCommand('pr.addReviewersToNewPr', _ => { + return this._createPRViewProvider?.addReviewers(); + }), + this._currentDisposables + ); + + addDisposable( + vscode.commands.registerCommand('pr.addLabelsToNewPr', _ => { + return this._createPRViewProvider?.addLabels(); + }), + this._currentDisposables + ); + + addDisposable( + vscode.commands.registerCommand('pr.addMilestoneToNewPr', _ => { + return this._createPRViewProvider?.addMilestone(); + + }), + this._currentDisposables + ); + + addDisposable( + vscode.commands.registerCommand('pr.addProjectsToNewPr', _ => { + return this._createPRViewProvider?.addProjects(); + + }), + this._currentDisposables + ); + + 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._currentDisposables + ); + addDisposable( + vscode.commands.registerCommand('pr.createPrMenuMergeWhenReady', () => { + this._createPRViewProvider?.createFromCommand(false, true, undefined, true); + + }), + 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) { + addDisposable( + repository.state.onDidChange(_ => { + if (this._createPRViewProvider && repository.state.HEAD && this._createPRViewProvider instanceof CreatePullRequestViewProvider) { + this._createPRViewProvider.setDefaultCompareBranch(repository.state.HEAD); + } + }), + this._currentDisposables + ); + } + } + + get isCreatingPullRequest() { + return !!this._createPRViewProvider; + } + + private async ensureDefaultsAreLocal( + folderRepoManager: FolderRepositoryManager, + defaults: PullRequestDefaults, + ): Promise<PullRequestDefaults> { + if ( + !folderRepoManager.gitHubRepositories.some( + repo => repo.remote.owner === defaults.owner && repo.remote.repositoryName === defaults.repo, + ) + ) { + // There is an upstream/parent repo, but the remote for it does not exist in the current workspace. Fall back to using origin instead. + const origin = await folderRepoManager.getOrigin(); + const metadata = await folderRepoManager.getMetadata(origin.remote.remoteName); + return { + owner: metadata.owner.login, + repo: metadata.name, + base: metadata.default_branch, + }; + } else { + return defaults; + } + } + + 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(); + this._activeContext = 'github:createPullRequest'; + this.setActiveContext(true); + + const branch = + ((compareBranch ? await folderRepoManager.repository.getBranch(compareBranch) : undefined) ?? + folderRepoManager.repository.state.HEAD?.name ? folderRepoManager.repository.state.HEAD : undefined); + + let createViewProvider: CreatePullRequestViewProvider; + if (recreate) { + this._createPRViewProvider?.dispose(); + const pullRequestDefaults = await this.ensureDefaultsAreLocal( + folderRepoManager, + await folderRepoManager.getPullRequestDefaults(branch), + ); + + 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, + ); + + this._treeView = addDisposable(new CompareChanges( + folderRepoManager, + model + ), this._currentDisposables); + + this.registerListeners(folderRepoManager.repository, !compareBranch); + + addDisposable( + vscode.window.registerWebviewViewProvider( + this._createPRViewProvider.viewType, + this._createPRViewProvider, + ), + this._currentDisposables + ); + } else { + createViewProvider = this._createPRViewProvider as CreatePullRequestViewProvider; + } + + 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 new file mode 100644 index 0000000000..06661245b1 --- /dev/null +++ b/src/view/fileChangeModel.ts @@ -0,0 +1,249 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ViewedState } from '../common/comment'; +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'; +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; + } + + protected _parentFilePath: vscode.Uri; + get parentFilePath(): vscode.Uri { + return this._parentFilePath; + } + + get status(): GitChangeType { + return this.change.status; + } + + get fileName(): string { + return this.change.fileName; + } + + get blobUrl(): string | undefined { + return this.change.blobUrl; + } + + private _viewed: ViewedState; + get viewed(): ViewedState { + return this._viewed; + } + + updateViewed(viewed: ViewedState) { + if (this._viewed === viewed) { + return; + } + + this._viewed = viewed; + } + + async diffHunks(): Promise<DiffHunk[]> { + let diffHunks: DiffHunk[] = []; + + if (this.change instanceof InMemFileChange && this.change.diffHunks) { + return this.change.diffHunks; + } else if (this.status !== GitChangeType.RENAME) { + try { + const commit = this.sha ?? this.pullRequest.head!.sha; + 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}`, 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, + public readonly sha?: string) { } +} + +export class GitFileChangeModel extends FileChangeModel { + constructor( + folderRepositoryManager: FolderRepositoryManager, + pullRequest: PullRequestModel, + change: SimpleFileChange, + filePath: vscode.Uri, + parentFilePath: vscode.Uri, + sha: string, + preload?: boolean + ) { + super(pullRequest, folderRepositoryManager, change, sha); + this._filePath = filePath; + this._parentFilePath = parentFilePath; + if (preload) { + try { + this.showBase(); + } catch (e) { + Logger.warn(`Unable to preload file content for ${filePath.fsPath} at commit ${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); + } + return this._show; + } +} + +export class InMemFileChangeModel extends FileChangeModel { + get previousFileName(): string | undefined { + return this.change.previousFileName; + } + + async isPartial(): Promise<boolean> { + let originalFileExist = false; + let fileName: string | undefined = undefined; + + if ((this.change.patch === '') && + ((this.change.status === GitChangeType.MODIFY) || (this.change.status === GitChangeType.RENAME) || (this.change.status === GitChangeType.ADD))) { + return true; + } + + 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; + } + + constructor(folderRepositoryManager: FolderRepositoryManager, + pullRequest: PullRequestModel & IResolvedPullRequestModel, + public override readonly change: InMemFileChange, + isCurrentPR: boolean, + mergeBase: string) { + super(pullRequest, folderRepositoryManager, change); + const headCommit = pullRequest.head!.sha; + const parentFileName = change.status === GitChangeType.RENAME ? change.previousFileName! : change.fileName; + const filePath = folderRepositoryManager.repository.rootUri.with({ path: vscode.Uri.file(resolvePath(folderRepositoryManager.repository.rootUri, change.fileName)).path }); + const parentPath = folderRepositoryManager.repository.rootUri.with({ path: vscode.Uri.file(resolvePath(folderRepositoryManager.repository.rootUri, parentFileName)).path }); + this._filePath = isCurrentPR ? ((change.status === GitChangeType.DELETE) + ? toReviewUri(filePath, undefined, undefined, '', false, { base: false }, folderRepositoryManager.repository.rootUri) + : filePath) : toPRUri( + filePath, + pullRequest, + change.baseCommit, + headCommit, + change.fileName, + false, + change.status, + change.previousFileName + ); + this._parentFilePath = isCurrentPR ? (toReviewUri( + parentPath, + change.status === GitChangeType.RENAME ? change.previousFileName : change.fileName, + undefined, + change.status === GitChangeType.ADD ? '' : mergeBase, + false, + { base: true }, + folderRepositoryManager.repository.rootUri, + )) : toPRUri( + parentPath, + pullRequest, + change.baseCommit, + headCommit, + change.fileName, + true, + change.status, + change.previousFileName + ); + } +} + +export class RemoteFileChangeModel extends FileChangeModel { + public fileChangeResourceUri: vscode.Uri; + public childrenDisposables: vscode.Disposable[] = []; + + get previousFileName(): string | undefined { + return this.change.previousFileName; + } + + constructor( + folderRepositoryManager: FolderRepositoryManager, + public override readonly change: SlimFileChange, + pullRequest: PullRequestModel, + ) { + super(pullRequest, folderRepositoryManager, change); + const headCommit = pullRequest.head!.sha; + const parentFileName = change.status === GitChangeType.RENAME ? change.previousFileName! : change.fileName; + this._filePath = toPRUri( + vscode.Uri.file( + resolvePath(folderRepositoryManager.repository.rootUri, change.fileName), + ), + pullRequest, + change.baseCommit, + headCommit, + change.fileName, + false, + change.status, + change.previousFileName + ); + this._parentFilePath = toPRUri( + vscode.Uri.file( + resolvePath(folderRepositoryManager.repository.rootUri, parentFileName), + ), + pullRequest, + change.baseCommit, + headCommit, + change.fileName, + true, + change.status, + change.previousFileName + ); + } +} \ No newline at end of file diff --git a/src/view/fileTypeDecorationProvider.ts b/src/view/fileTypeDecorationProvider.ts index 3faa6a526d..3d6c3283f5 100644 --- a/src/view/fileTypeDecorationProvider.ts +++ b/src/view/fileTypeDecorationProvider.ts @@ -1,66 +1,123 @@ -/*--------------------------------------------------------------------------------------------- - * 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, fromPRUri } from '../common/uri'; -import { GitChangeType } from '../common/file'; - -export class FileTypeDecorationProvider implements vscode.DecorationProvider { - private _disposables: vscode.Disposable[]; - - constructor( - ) { - this._disposables = []; - this._disposables.push(vscode.window.registerDecorationProvider(this)); - } - - _onDidChangeDecorations: vscode.EventEmitter<vscode.Uri | vscode.Uri[]> = new vscode.EventEmitter<vscode.Uri | vscode.Uri[]>(); - onDidChangeDecorations: vscode.Event<vscode.Uri | vscode.Uri[]> = this._onDidChangeDecorations.event; - provideDecoration(uri: vscode.Uri, token: vscode.CancellationToken): vscode.ProviderResult<vscode.Decoration> { - const fileChangeUriParams = fromFileChangeNodeUri(uri); - if (fileChangeUriParams && fileChangeUriParams.status !== undefined) { - return { - bubble: false, - letter: this.letter(fileChangeUriParams.status), - priority: 1 - }; - } - - const prParams = fromPRUri(uri); - - if (prParams && prParams.status !== undefined) { - return { - bubble: false, - letter: this.letter(prParams.status), - priority: 1 - }; - } - - return undefined; - } - - letter(status: GitChangeType): string { - switch (status) { - case GitChangeType.MODIFY: - return 'M'; - case GitChangeType.ADD: - return 'A'; - case GitChangeType.DELETE: - return 'D'; - case GitChangeType.RENAME: - return 'R'; - case GitChangeType.UNKNOWN: - return 'U'; - case GitChangeType.UNMERGED: - return 'C'; - } - - return ''; - } - - dispose() { - this._disposables.forEach(dispose => dispose.dispose()); - } -} +/*--------------------------------------------------------------------------------------------- + * 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 { TreeDecorationProvider } from './treeDecorationProviders'; +import { GitChangeType } from '../common/file'; +import { FileChangeNodeUriParams, fromFileChangeNodeUri, fromPRUri, PRUriParams } from '../common/uri'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { PullRequestModel } from '../github/pullRequestModel'; + +export class FileTypeDecorationProvider extends TreeDecorationProvider { + constructor() { + super(); + } + + registerPullRequestPropertyChangedListeners(folderManager: FolderRepositoryManager, model: PullRequestModel): vscode.Disposable { + return model.onDidChangeFileViewedState(changed => { + changed.changed.forEach(change => { + this._handlePullRequestPropertyChange(folderManager, model, { path: change.fileName }); + }); + }); + } + + provideFileDecoration( + uri: vscode.Uri, + _token: vscode.CancellationToken, + ): vscode.ProviderResult<vscode.FileDecoration> { + if (!uri.query) { + return; + } + + const fileChangeUriParams = fromFileChangeNodeUri(uri); + if (fileChangeUriParams && fileChangeUriParams.status !== undefined) { + return { + propagate: false, + badge: this.letter(fileChangeUriParams.status), + color: this.color(fileChangeUriParams.status), + tooltip: this.tooltip(fileChangeUriParams) + }; + } + + const prParams = fromPRUri(uri); + + if (prParams && prParams.status !== undefined) { + return { + propagate: false, + badge: this.letter(prParams.status), + color: this.color(prParams.status), + tooltip: this.tooltip(prParams) + }; + } + + return undefined; + } + + gitColors(status: GitChangeType): string | undefined { + switch (status) { + case GitChangeType.MODIFY: + return 'gitDecoration.modifiedResourceForeground'; + case GitChangeType.ADD: + return 'gitDecoration.addedResourceForeground'; + case GitChangeType.DELETE: + return 'gitDecoration.deletedResourceForeground'; + case GitChangeType.RENAME: + return 'gitDecoration.renamedResourceForeground'; + case GitChangeType.UNKNOWN: + return undefined; + case GitChangeType.UNMERGED: + return 'gitDecoration.conflictingResourceForeground'; + } + } + + remoteReposColors(status: GitChangeType): string | undefined { + switch (status) { + case GitChangeType.MODIFY: + return 'remoteHub.decorations.modifiedForegroundColor'; + case GitChangeType.ADD: + return 'remoteHub.decorations.addedForegroundColor'; + case GitChangeType.DELETE: + return 'remoteHub.decorations.deletedForegroundColor'; + case GitChangeType.RENAME: + return 'remoteHub.decorations.incomingRenamedForegroundColor'; + case GitChangeType.UNKNOWN: + return undefined; + case GitChangeType.UNMERGED: + return 'remoteHub.decorations.conflictForegroundColor'; + } + } + + color(status: GitChangeType): vscode.ThemeColor | undefined { + let color: string | undefined = vscode.extensions.getExtension('vscode.git') ? this.gitColors(status) : this.remoteReposColors(status); + return color ? new vscode.ThemeColor(color) : undefined; + } + + letter(status: GitChangeType): string { + + switch (status) { + case GitChangeType.MODIFY: + return 'M'; + case GitChangeType.ADD: + return 'A'; + case GitChangeType.DELETE: + return 'D'; + case GitChangeType.RENAME: + return 'R'; + case GitChangeType.UNKNOWN: + return 'U'; + case GitChangeType.UNMERGED: + return 'C'; + } + + return ''; + } + + tooltip(change: FileChangeNodeUriParams | PRUriParams) { + if ((change.status === GitChangeType.RENAME) && change.previousFileName) { + return `Renamed ${change.previousFileName} to ${path.basename(change.fileName)}`; + } + } +} diff --git a/src/view/gitContentProvider.ts b/src/view/gitContentProvider.ts index 4ebd8cc499..be99f25a92 100644 --- a/src/view/gitContentProvider.ts +++ b/src/view/gitContentProvider.ts @@ -4,45 +4,86 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; -import * as vscode from 'vscode'; 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 { GitAPI } from '../typings/git'; +import { GitFileChangeNode, RemoteFileChangeNode } from './treeNodes/fileChangeNode'; -export class GitContentProvider implements vscode.TextDocumentContentProvider { - private _onDidChange = new vscode.EventEmitter<vscode.Uri>(); - get onDidChange(): vscode.Event<vscode.Uri> { return this._onDidChange.event; } +export class GitContentFileSystemProvider extends RepositoryFileSystemProvider { + private static readonly ID = 'GitContentFileSystemProvider'; + private _fallback?: (uri: vscode.Uri) => Promise<string>; - private _fallback?: ((uri: vscode.Uri) => Promise<string>); + constructor(gitAPI: GitApiImpl, credentialStore: CredentialStore, private readonly getReviewManagers: () => ReviewManager[]) { + super(gitAPI, credentialStore); + } - constructor(private gitAPI: GitAPI) { } + 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; + } + } + } + } + + private getChangeModelForFile(file: vscode.Uri): GitFileChangeModel | undefined { + return this.getChangeModelForFileAndFilesArray(file, manager => manager.reviewModel.localFileChanges) as GitFileChangeModel; + } - async provideTextDocumentContent(uri: vscode.Uri, token: vscode.CancellationToken): Promise<string> { + 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)) { + await this.waitForRepos(4000); + } + + return getRepositoryForFile(this.gitAPI, file); + } + + async readFile(uri: vscode.Uri): Promise<Uint8Array> { if (!this._fallback) { - return ''; + return new TextEncoder().encode(''); } - const { path, commit, rootPath } = fromReviewUri(uri); + const { path, commit, rootPath } = fromReviewUri(uri.query); if (!path || !commit) { - return ''; + return new TextEncoder().encode(''); } - const repository = getRepositoryForFile(this.gitAPI, vscode.Uri.file(rootPath)); + const repository = await this.getRepositoryForFile(vscode.Uri.file(rootPath)); if (!repository) { vscode.window.showErrorMessage(`We couldn't find an open repository for ${commit} locally.`); - return ''; + return new TextEncoder().encode(''); } const absolutePath = pathLib.join(repository.rootUri.fsPath, path).replace(/\\/g, '/'); - let content: string; + let content: string | undefined; try { - content = await repository.show(commit, absolutePath); + 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.ID); + content = await repository.show(commit, absolutePath); + } if (!content) { throw new Error(); } } catch (_) { + 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. @@ -51,12 +92,20 @@ export class GitContentProvider implements vscode.TextDocumentContentProvider { try { await repository.getCommit(commit); } catch (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)); + } } } } - return content || ''; + return new TextEncoder().encode(content || ''); } registerTextDocumentContentFallback(provider: (uri: vscode.Uri) => Promise<string>) { diff --git a/src/view/gitHubContentProvider.ts b/src/view/gitHubContentProvider.ts new file mode 100644 index 0000000000..ac25a7c481 --- /dev/null +++ b/src/view/gitHubContentProvider.ts @@ -0,0 +1,199 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { fromGitHubURI, GitHubUriParams } from '../common/uri'; +import { compareIgnoreCase } from '../common/utils'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { GitHubRepository } from '../github/githubRepository'; + +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 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(); + } + + 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(''); + } + + 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 d0dd7290d7..ddd7e92a69 100644 --- a/src/view/inMemPRContentProvider.ts +++ b/src/view/inMemPRContentProvider.ts @@ -1,50 +1,259 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { fromPRUri } from '../common/uri'; - -export class InMemPRContentProvider implements vscode.TextDocumentContentProvider { - private _onDidChange = new vscode.EventEmitter<vscode.Uri>(); - get onDidChange(): vscode.Event<vscode.Uri> { return this._onDidChange.event; } - - fireDidChange(uri: vscode.Uri) { - this._onDidChange.fire(uri); - } - - private _prFileChangeContentProviders: { [key: number]: (uri: vscode.Uri) => Promise<string> } = {}; - - constructor() { } - - async provideTextDocumentContent(uri: vscode.Uri, token: vscode.CancellationToken): Promise<string> { - const prUriParams = fromPRUri(uri); - if (prUriParams && prUriParams.prNumber) { - const provider = this._prFileChangeContentProviders[prUriParams.prNumber]; - - if (provider) { - return await provider(uri); - } - } - - return ''; - } - - registerTextDocumentContentProvider(prNumber: number, provider: (uri: vscode.Uri) => Promise<string>): vscode.Disposable { - this._prFileChangeContentProviders[prNumber] = provider; - - return { - dispose: () => { - delete this._prFileChangeContentProviders[prNumber]; - } - }; - } -} - -const inMemPRContentProvider = new InMemPRContentProvider(); - -export function getInMemPRContentProvider(): InMemPRContentProvider { - return inMemPRContentProvider; +/*--------------------------------------------------------------------------------------------- + * 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 { 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'; +import Logger from '../common/logger'; +import { fromPRUri, PRUriParams } from '../common/uri'; +import { CredentialStore } from '../github/credentials'; +import { FolderRepositoryManager, ReposManagerState } from '../github/folderRepositoryManager'; +import { IResolvedPullRequestModel, PullRequestModel } from '../github/pullRequestModel'; +import { RepositoriesManager } from '../github/repositoriesManager'; + +export class InMemPRFileSystemProvider extends RepositoryFileSystemProvider { + private _prFileChangeContentProviders: { [key: number]: (uri: vscode.Uri) => Promise<string | Uint8Array> } = {}; + + constructor(private reposManagers: RepositoriesManager, gitAPI: GitApiImpl, credentialStore: CredentialStore) { + super(gitAPI, credentialStore); + } + + registerTextDocumentContentProvider( + prNumber: number, + provider: (uri: vscode.Uri) => Promise<string | Uint8Array>, + ): vscode.Disposable { + this._prFileChangeContentProviders[prNumber] = provider; + + return { + dispose: () => { + delete this._prFileChangeContentProviders[prNumber]; + }, + }; + } + + private resolveChanges(rawChanges: (SlimFileChange | InMemFileChange)[], pr: PullRequestModel, + folderRepositoryManager: FolderRepositoryManager, + mergeBase: string): (RemoteFileChangeModel | InMemFileChangeModel)[] { + const isCurrentPR = pr.equals(folderRepositoryManager.activePullRequest); + + return rawChanges.map(change => { + if (change instanceof SlimFileChange) { + return new RemoteFileChangeModel(folderRepositoryManager, change, pr); + } + return new InMemFileChangeModel(folderRepositoryManager, + pr as (PullRequestModel & IResolvedPullRequestModel), + change, isCurrentPR, mergeBase); + }); + } + + private waitForGitHubRepos(folderRepositoryManager: FolderRepositoryManager, milliseconds: number) { + return new Promise<void>(resolve => { + const timeout = setTimeout(() => { + disposable.dispose(); + resolve(); + }, milliseconds); + const disposable = folderRepositoryManager.onDidLoadRepositories(e => { + if (e === ReposManagerState.RepositoriesLoaded) { + clearTimeout(timeout); + disposable.dispose(); + resolve(); + } + }); + }); + } + + private async tryRegisterNewProvider(uri: vscode.Uri, prUriParams: PRUriParams) { + await this.waitForAuth(); + if ((this.gitAPI.state !== 'initialized') || (this.gitAPI.repositories.length === 0)) { + await this.waitForRepos(4000); + } + const folderRepositoryManager = this.reposManagers.getManagerForFile(uri); + if (!folderRepositoryManager) { + return; + } + let repo = folderRepositoryManager.findRepo(repo => repo.remote.remoteName === prUriParams.remoteName); + if (!repo) { + // Depending on the git provider, we might not have a GitHub repo right away, even if we already have git repos. + // This can take a long time. + await this.waitForGitHubRepos(folderRepositoryManager, 10000); + repo = folderRepositoryManager.findRepo(repo => repo.remote.remoteName === prUriParams.remoteName); + } + if (!repo) { + return; + } + const pr = await folderRepositoryManager.resolvePullRequest(repo.remote.owner, repo.remote.repositoryName, prUriParams.prNumber); + if (!pr) { + return; + } + const rawChanges = await pr.getFileChangesInfo(); + const mergeBase = pr.mergeBase; + if (!mergeBase) { + return; + } + const changes = this.resolveChanges(rawChanges, pr, folderRepositoryManager, mergeBase); + this.registerTextDocumentContentProvider(pr.number, async (uri: vscode.Uri) => { + 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(folderRepositoryManager, pr, params, fileChange); + }); + } + + private async readFileWithProvider(uri: vscode.Uri, prNumber: number): Promise<Uint8Array | undefined> { + const provider = this._prFileChangeContentProviders[prNumber]; + if (provider) { + const content = await provider(uri); + if (typeof content === 'string') { + return new TextEncoder().encode(content); + } else { + return content; + } + } + } + + async readFile(uri: vscode.Uri): Promise<Uint8Array> { + const prUriParams = fromPRUri(uri); + if (!prUriParams || (prUriParams.prNumber === undefined)) { + return new TextEncoder().encode(''); + } + const providerResult = await this.readFileWithProvider(uri, prUriParams.prNumber); + if (providerResult) { + return providerResult; + } + + await this.tryRegisterNewProvider(uri, prUriParams); + return (await this.readFileWithProvider(uri, prUriParams.prNumber)) ?? new TextEncoder().encode(''); + } +} + +let inMemPRFileSystemProvider: InMemPRFileSystemProvider | undefined; + +export function getInMemPRFileSystemProvider(initialize?: { reposManager: RepositoriesManager, gitAPI: GitApiImpl, credentialStore: CredentialStore }): InMemPRFileSystemProvider | undefined { + if (!inMemPRFileSystemProvider && initialize) { + inMemPRFileSystemProvider = new InMemPRFileSystemProvider(initialize.reposManager, initialize.gitAPI, initialize.credentialStore); + } + return inMemPRFileSystemProvider; +} + +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) + ) { + return ''; + } + + 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.githubRepository.getFile( + fileChange.previousFileName || fileChange.fileName, + params.baseCommit, + ); + } else { + return pullRequestModel.githubRepository.getFile(fileChange.fileName, params.headCommit); + } + } catch (e) { + Logger.error(`Fetching file content failed: ${e}`, 'PR'); + vscode.window + .showWarningMessage( + 'Opening this file locally failed. Would you like to view it on GitHub?', + 'Open on GitHub', + ) + .then(result => { + if ((result === 'Open on GitHub') && fileChange.blobUrl) { + vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(fileChange.blobUrl)); + } + }); + return ''; + } + } + + if (fileChange instanceof InMemFileChangeModel) { + const readContentFromDiffHunk = + fileChange.status === GitChangeType.ADD || fileChange.status === GitChangeType.DELETE; + + if (readContentFromDiffHunk) { + if (params.isBase) { + // left + const left: string[] = []; + for (let i = 0; i < diffHunks.length; i++) { + for (let j = 0; j < diffHunks[i].diffLines.length; j++) { + const diffLine = diffHunks[i].diffLines[j]; + if (diffLine.type === DiffChangeType.Add) { + // nothing + } else if (diffLine.type === DiffChangeType.Delete) { + left.push(diffLine.text); + } else if (diffLine.type === DiffChangeType.Control) { + // nothing + } else { + left.push(diffLine.text); + } + } + } + + return left.join('\n'); + } else { + const right: string[] = []; + for (let i = 0; i < diffHunks.length; i++) { + for (let j = 0; j < diffHunks[i].diffLines.length; j++) { + const diffLine = diffHunks[i].diffLines[j]; + if (diffLine.type === DiffChangeType.Add) { + right.push(diffLine.text); + } else if (diffLine.type === DiffChangeType.Delete) { + // nothing + } else if (diffLine.type === DiffChangeType.Control) { + // nothing + } else { + right.push(diffLine.text); + } + } + } + + return right.join('\n'); + } + } else { + const originalFileName = + fileChange.status === GitChangeType.RENAME ? fileChange.previousFileName : fileChange.fileName; + const originalFilePath = vscode.Uri.joinPath( + folderRepoManager.repository.rootUri, + originalFileName!, + ); + const originalContent = await folderRepoManager.repository.show( + params.baseCommit, + originalFilePath.fsPath, + ); + + if (params.isBase) { + return originalContent; + } else { + return getModifiedContentFromDiffHunk(originalContent, fileChange.patch); + } + } + } + + return ''; } \ No newline at end of file diff --git a/src/view/prChangesTreeDataProvider.ts b/src/view/prChangesTreeDataProvider.ts index 18fa72ff57..f262584b16 100644 --- a/src/view/prChangesTreeDataProvider.ts +++ b/src/view/prChangesTreeDataProvider.ts @@ -4,70 +4,143 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { GitFileChangeNode, RemoteFileChangeNode } from './treeNodes/fileChangeNode'; -import { TreeNode } from './treeNodes/treeNode'; -import { IComment } from '../common/comment'; -import { FolderRepositoryManager, SETTINGS_NAMESPACE } from '../github/folderRepositoryManager'; +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, 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 { 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> { - private _onDidChangeTreeData = new vscode.EventEmitter<void>(); +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 _localFileChanges: (GitFileChangeNode | RemoteFileChangeNode)[] = []; 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) { - 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); - - this._disposables.push(vscode.workspace.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(`${SETTINGS_NAMESPACE}.fileListLayout`)) { - this._onDidChangeTreeData.fire(); - } + showCollapseAll: true, + canSelectMany: true, + manageCheckboxStateManually: true })); + + this._register( + vscode.workspace.onDidChangeConfiguration(async e => { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${FILE_LIST_LAYOUT}`)) { + this._onDidChangeTreeData.fire(); + const layout = vscode.workspace + .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._register(this._view.onDidChangeCheckboxState(e => TreeUtils.processCheckboxUpdates(e, this._view.selection))); } - refresh() { - this._onDidChangeTreeData.fire(); + refresh(treeNode?: TreeNode) { + this._onDidChangeTreeData.fire(treeNode); } - async addPrToView(pullRequestManager: FolderRepositoryManager, pullRequest: PullRequestModel, localFileChanges: (GitFileChangeNode | RemoteFileChangeNode)[], comments: IComment[]) { - const node: RepositoryChangesNode = new RepositoryChangesNode(this._view, pullRequest, pullRequestManager, comments, localFileChanges); - this._pullRequestManagerMap.set(pullRequestManager, node); - await vscode.commands.executeCommand( - 'setContext', - 'github:inReviewMode', - true + private updateViewTitle(): void { + let pullRequestNumber: number | undefined; + if (this._pullRequestManagerMap.size === 1) { + const pullRequestIterator = this._pullRequestManagerMap.values().next(); + if (!pullRequestIterator.done) { + pullRequestNumber = pullRequestIterator.value.pullRequestModel.number; + } + } + + this._view.title = pullRequestNumber + ? vscode.l10n.t('Changes in Pull Request #{0}', pullRequestNumber) + : (this._pullRequestManagerMap.size > 1 ? vscode.l10n.t('Changes in Pull Requests') : vscode.l10n.t('Changes in Pull Request')); + } + + async addPrToView( + pullRequestManager: FolderRepositoryManager, + pullRequestModel: PullRequestModel, + reviewModel: ReviewModel, + shouldReveal: boolean, + progress: ProgressHelper + ) { + Logger.appendLine(`Adding PR #${pullRequestModel.number} to tree`, PR_TREE); + if (this._pullRequestManagerMap.has(pullRequestManager)) { + const existingNode = this._pullRequestManagerMap.get(pullRequestManager); + if (existingNode && (existingNode.pullRequestModel === pullRequestModel)) { + Logger.appendLine(`PR #${pullRequestModel.number} already exists in tree`, PR_TREE); + return; + } else { + existingNode?.dispose(); + } + } + const node: RepositoryChangesNode = new RepositoryChangesNode( + this, + pullRequestModel, + pullRequestManager, + reviewModel, + progress ); + this._pullRequestManagerMap.set(pullRequestManager, node); + this.updateViewTitle(); + + await this.setReviewModeContexts(); this._onDidChangeTreeData.fire(); + + if (shouldReveal) { + this.reveal(node); + } + } + + private async setReviewModeContexts() { + await commands.setContext(contexts.IN_REVIEW_MODE, this._pullRequestManagerMap.size > 0); + + 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); + } else if (folderManager) { + rootUrisInReviewMode.push(repo.rootUri); + } + }); + await commands.setContext(contexts.REPOS_NOT_IN_REVIEW_MODE, rootUrisNotInReviewMode); + await commands.setContext(contexts.REPOS_IN_REVIEW_MODE, rootUrisInReviewMode); } async removePrFromView(pullRequestManager: FolderRepositoryManager) { - this._pullRequestManagerMap.delete(pullRequestManager); - if (this._pullRequestManagerMap.size === 0) { - this.hide(); + const oldPR = this._pullRequestManagerMap.has(pullRequestManager) ? this._pullRequestManagerMap.get(pullRequestManager) : undefined; + if (oldPR) { + Logger.appendLine(`Removing PR #${oldPR.pullRequestModel.number} from tree`, PR_TREE); } - this._onDidChangeTreeData.fire(); - } + oldPR?.dispose(); + this._pullRequestManagerMap.delete(pullRequestManager); + this.updateViewTitle(); - async hide() { - await vscode.commands.executeCommand( - 'setContext', - 'github:inReviewMode', - false - ); + await this.setReviewModeContexts(); + this._onDidChangeTreeData.fire(); } getTreeItem(element: TreeNode): vscode.TreeItem | Thenable<vscode.TreeItem> { @@ -78,59 +151,70 @@ export class PullRequestChangesTreeDataProvider extends vscode.Disposable implem return element.getParent(); } - async reveal(element: TreeNode, options?: { select?: boolean, focus?: boolean, expand?: boolean | number }): Promise<void> { - this._view.reveal(element, options); + async reveal( + element: TreeNode, + options?: { select?: boolean; focus?: boolean; expand?: boolean | number }, + ): Promise<void> { + try { + await this._view.reveal(element, options); + } catch (e) { + Logger.error(e, PR_TREE); + } } - async revealComment(comment: IComment) { - const fileChange = this._localFileChanges.find(fc => { - if (fc.fileName !== comment.path) { - return false; - } - - if (!fc.pullRequest.isResolved()) { - return false; - } + get children(): TreeNode[] | undefined { + return this._children; + } - if (fc.pullRequest.head.sha !== comment.commitId) { - return false; - } + 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 true; - }); + return isDescendant(aFolder, bFolder) || isDescendant(bFolder, aFolder); + }; - if (fileChange) { - await this.reveal(fileChange, { focus: true, expand: 2 }); - if (!fileChange.command.arguments) { - return; + return Array.from(this._pullRequestManagerMap.entries()).sort((a, b) => { + if (a[0].repository.rootUri.toString() === b[0].repository.rootUri.toString()) { + return 0; } - if (fileChange instanceof GitFileChangeNode) { - const lineNumber = fileChange.getCommentPosition(comment); - const opts = fileChange.opts; - opts.selection = new vscode.Range(lineNumber, 0, lineNumber, 0); - fileChange.opts = opts; - await vscode.commands.executeCommand(fileChange.command.command, fileChange); - } else { - await vscode.commands.executeCommand(fileChange.command.command, ...fileChange.command.arguments!); + 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?: GitFileChangeNode): Promise<TreeNode[]> { + 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(); } } - dispose() { - this._disposables.forEach(disposable => disposable.dispose()); + getDescriptionNode(folderRepoManager: FolderRepositoryManager): RepositoryChangesNode | undefined { + return this._pullRequestManagerMap.get(folderRepoManager); + } + + async resolveTreeItem?(item: vscode.TreeItem, element: TreeNode): Promise<vscode.TreeItem> { + if (element instanceof GitFileChangeNode) { + await element.resolve(); + } + return element; } -} \ No newline at end of file +} diff --git a/src/view/prCommentController.ts b/src/view/prCommentController.ts deleted file mode 100644 index 0fddc3bb59..0000000000 --- a/src/view/prCommentController.ts +++ /dev/null @@ -1,79 +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 { fromPRUri } from '../common/uri'; -import { GHPRCommentThread, GHPRComment } from '../github/prComment'; -import { CommentReactionHandler } from '../github/utils'; - -export class PRCommentController implements vscode.CommentingRangeProvider, CommentReactionHandler, vscode.Disposable { - private _prCommentControllers: { [key: number]: vscode.CommentingRangeProvider & CommentReactionHandler } = {}; - private _prDocumentCommentThreadMap: { [key: number]: { [key: string]: GHPRCommentThread[] } } = {}; - - constructor( - public commentsController: vscode.CommentController - ) { - this.commentsController.commentingRangeProvider = this; - this.commentsController.reactionHandler = this.toggleReaction.bind(this); - } - - async provideCommentingRanges(document: vscode.TextDocument, token: vscode.CancellationToken): Promise<vscode.Range[] | undefined> { - const uri = document.uri; - const params = fromPRUri(uri); - - if (!params || !this._prCommentControllers[params.prNumber]) { - return; - } - - const provideCommentingRanges = this._prCommentControllers[params.prNumber].provideCommentingRanges.bind(this._prCommentControllers[params.prNumber]); - - return provideCommentingRanges(document, token); - } - - async toggleReaction(comment: GHPRComment, reaction: vscode.CommentReaction): Promise<void> { - const uri = comment.parent!.uri; - const params = fromPRUri(uri); - - if (!params || !this._prCommentControllers[params.prNumber] || !this._prCommentControllers[params.prNumber].toggleReaction) { - return; - } - - const toggleReaction = this._prCommentControllers[params.prNumber].toggleReaction!.bind(this._prCommentControllers[params.prNumber]); - - return toggleReaction(comment, reaction); - } - - public registerCommentController(prNumber: number, provider: vscode.CommentingRangeProvider & CommentReactionHandler) { - this._prCommentControllers[prNumber] = provider; - if (!this._prDocumentCommentThreadMap[prNumber]) { - this._prDocumentCommentThreadMap[prNumber] = {}; - } - - const commentThreadCache = this._prDocumentCommentThreadMap[prNumber]; - - return { - commentThreadCache: commentThreadCache, - dispose: () => { - delete this._prCommentControllers[prNumber]; - } - }; - } - - public clearCommentThreadCache(prNumber: number) { - if (this._prDocumentCommentThreadMap[prNumber]) { - for (const fileName in this._prDocumentCommentThreadMap[prNumber]) { - this._prDocumentCommentThreadMap[prNumber][fileName].forEach(thread => thread.dispose!()); - } - - this._prDocumentCommentThreadMap[prNumber] = {}; - } - } - - dispose() { - this._prCommentControllers = {}; - this._prDocumentCommentThreadMap = {}; - } -} \ No newline at end of file diff --git a/src/view/prStatusDecorationProvider.ts b/src/view/prStatusDecorationProvider.ts new file mode 100644 index 0000000000..7e423dff2d --- /dev/null +++ b/src/view/prStatusDecorationProvider.ts @@ -0,0 +1,170 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 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, 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; + } + const params = fromPRNodeUri(uri); + 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; + } + + const decoration = getStatusDecoration(status.status) as vscode.FileDecoration; + return decoration; + } + + 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 { + badge: new vscode.ThemeIcon('copilot') as unknown as string, + color: new vscode.ThemeColor('pullRequests.notification') + }; + } + } + + 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, + color: new vscode.ThemeColor('pullRequests.notification'), + badge: '●', + tooltip: 'unread notification' + }; + } + } + + 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/progress.ts b/src/view/progress.ts new file mode 100644 index 0000000000..04ef0370bf --- /dev/null +++ b/src/view/progress.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. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +export class ProgressHelper { + private _progress: Promise<void> = Promise.resolve(); + private _endProgress: vscode.EventEmitter<void> = new vscode.EventEmitter(); + + get progress(): Promise<void> { + return this._progress; + } + startProgress() { + this.endProgress(); + this._progress = new Promise(resolve => { + const disposable = this._endProgress.event(() => { + disposable.dispose(); + resolve(); + }); + }); + } + + endProgress() { + this._endProgress.fire(); + } +} \ No newline at end of file diff --git a/src/view/prsTreeDataProvider.ts b/src/view/prsTreeDataProvider.ts index 756bf98f3e..bfbed43df9 100644 --- a/src/view/prsTreeDataProvider.ts +++ b/src/view/prsTreeDataProvider.ts @@ -4,178 +4,610 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { TreeNode } from './treeNodes/treeNode'; -import { PRCategoryActionNode, CategoryTreeNode, PRCategoryActionType } from './treeNodes/categoryNode'; -import { getInMemPRContentProvider } from './inMemPRContentProvider'; -import { SETTINGS_NAMESPACE, REMOTES_SETTING, ReposManagerState } from '../github/folderRepositoryManager'; +import { PRStatusDecorationProvider } from './prStatusDecorationProvider'; +import { PrsTreeModel } from './prsTreeModel'; +import { ReviewModel } from './reviewModel'; +import { AuthProvider } from '../common/authentication'; +import { commands, contexts } from '../common/executeCommands'; +import { Disposable } from '../common/lifecycle'; +import Logger from '../common/logger'; +import { FILE_LIST_LAYOUT, PR_SETTINGS_NAMESPACE, QUERIES, REMOTES } from '../common/settingKeys'; import { ITelemetry } from '../common/telemetry'; -import { DecorationProvider } from './treeDecorationProvider'; -import { WorkspaceFolderNode, QUERIES_SETTING } from './treeNodes/workspaceFolderNode'; +import { createPRNodeIdentifier } from '../common/uri'; +import { EXTENSION_ID } from '../constants'; +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'; - -export class PullRequestsTreeDataProvider implements vscode.TreeDataProvider<TreeNode>, vscode.Disposable { - private _onDidChangeTreeData = new vscode.EventEmitter<TreeNode | void>(); +import { findDotComAndEnterpriseRemotes } from '../github/utils'; +import { CategoryTreeNode, PRCategoryActionNode, PRCategoryActionType } from './treeNodes/categoryNode'; +import { InMemFileChangeNode } from './treeNodes/fileChangeNode'; +import { PRNode } from './treeNodes/pullRequestNode'; +import { BaseTreeNode, TreeNode } from './treeNodes/treeNode'; +import { TreeUtils } from './treeNodes/treeUtils'; +import { WorkspaceFolderNode } from './treeNodes/workspaceFolderNode'; +import { NotificationsManager } from '../notifications/notificationsManager'; + +export class PullRequestsTreeDataProvider extends Disposable implements vscode.TreeDataProvider<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: vscode.Disposable[]; - private _view: vscode.TreeView<TreeNode>; - private _reposManager: RepositoriesManager; + get onDidChange(): vscode.Event<vscode.Uri> { + return this._onDidChange.event; + } + private _children: WorkspaceFolderNode[] | CategoryTreeNode[]; + get children() { + return this._children; + } + private readonly _view: vscode.TreeView<TreeNode>; private _initialized: boolean = false; + private _notificationsProvider?: NotificationsManager; + private _notificationClearTimeout: NodeJS.Timeout | undefined; get view(): vscode.TreeView<TreeNode> { return this._view; } - constructor( - private _telemetry: ITelemetry - ) { - this._disposables = []; - this._disposables.push(vscode.workspace.registerTextDocumentContentProvider('pr', getInMemPRContentProvider())); - this._disposables.push(vscode.window.registerDecorationProvider(DecorationProvider)); - this._disposables.push(vscode.commands.registerCommand('pr.refreshList', _ => { - this._onDidChangeTreeData.fire(); + 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._disposables.push(vscode.commands.registerCommand('pr.loadMore', (node: CategoryTreeNode) => { + this._register(vscode.commands.registerCommand('pr.loadMore', (node: CategoryTreeNode) => { node.fetchNextPage = true; - this._onDidChangeTreeData.fire(node); + this.refresh(node); })); - this._view = vscode.window.createTreeView('pr:github', { + this._view = this._register(vscode.window.createTreeView('pr:github', { treeDataProvider: this, - showCollapseAll: true + showCollapseAll: true, + manageCheckboxStateManually: true + })); + + 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._register({ + dispose: () => { + if (this._notificationClearTimeout) { + clearTimeout(this._notificationClearTimeout); + this._notificationClearTimeout = undefined; + } + } }); - this._disposables.push(this._view); - this._childrenDisposables = []; + 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._disposables.push(vscode.commands.registerCommand('pr.configurePRViewlet', async () => { - const isLoggedIn = this._reposManager.state === ReposManagerState.RepositoriesLoaded; - const configuration = await vscode.window.showQuickPick(['Configure Remotes...', 'Configure Queries...', ...isLoggedIn ? ['Sign out of GitHub...'] : []]); + this._children = []; - const { name, publisher } = require('../../package.json') as { name: string, publisher: string }; - const extensionId = `${publisher}.${name}`; + 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:${extensionId} 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:${extensionId} remotes`); - case 'Sign out of GitHub...': - return vscode.commands.executeCommand('auth.signout'); + 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(vscode.workspace.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(`${SETTINGS_NAMESPACE}.fileListLayout`)) { - this._onDidChangeTreeData.fire(); + this._register(vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${FILE_LIST_LAYOUT}`)) { + this.refreshAll(); } })); + 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 updateBadge() { + const isPRNotificationsOn = this._notificationsProvider?.isPRNotificationsOn(); + + const prNotificationsCount = isPRNotificationsOn ? this.filterNotificationsToKnown(this._notificationsProvider!.prNotifications).length : 0; + const copilotCount = this.prsTreeModel.copilotNotificationsCount; + const totalCount = prNotificationsCount + copilotCount; + + if (totalCount === 0) { + this._view.badge = undefined; + return; + } + + if (prNotificationsCount > 0 && copilotCount > 0) { + if (copilotCount === 1) { + if (prNotificationsCount === 1) { + this._view.badge = { + tooltip: vscode.l10n.t('Coding agent has 1 pull request to view, plus 1 other pull request notification'), + value: totalCount + }; + } else { + this._view.badge = { + tooltip: vscode.l10n.t(`Coding agent has 1 pull request to view, plus {0} other pull request notifications`, prNotificationsCount), + value: totalCount + }; + } + } else { + if (prNotificationsCount === 1) { + this._view.badge = { + tooltip: vscode.l10n.t('Coding agent has {0} pull requests to view, plus 1 other pull request notification', copilotCount), + value: totalCount + }; + } else { + this._view.badge = { + tooltip: vscode.l10n.t(`Coding agent has {0} pull requests to view, plus {1} other pull request notifications`, copilotCount, prNotificationsCount), + value: totalCount + }; + } + } + } else if (copilotCount > 0) { + if (copilotCount === 1) { + this._view.badge = { + tooltip: vscode.l10n.t(`Coding agent has 1 pull request to view`), + value: totalCount + }; + } else { + this._view.badge = { + tooltip: vscode.l10n.t(`Coding agent has {0} pull requests to view`, copilotCount), + value: totalCount + }; + } + } else if (prNotificationsCount > 0) { + if (prNotificationsCount === 1) { + this._view.badge = { + tooltip: vscode.l10n.t(`1 pull request notification`), + value: totalCount + }; + } else { + this._view.badge = { + tooltip: vscode.l10n.t(`{0} pull request notifications`, prNotificationsCount), + value: totalCount + }; + } + } + } + + public async expandPullRequest(pullRequest: PullRequestModel) { + if (this._children.length === 0) { + await this.getChildren(); + } + 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; + } + } + } + } + + async reveal(element: TreeNode, options?: { select?: boolean, focus?: boolean, expand?: boolean }): Promise<void> { + return this._view.reveal(element, options); + } + + /** + * 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(reposManager: RepositoriesManager) { + 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._register(this._reposManager.onDidChangeState(() => { + this.refreshAll(); })); - this._disposables.push(...this._reposManager.folderManagers.map(manager => { - return manager.onDidChangeRepositories(() => { - this._onDidChangeTreeData.fire(); - }); + 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_SETTING}`)) { - this.refresh(); + this._register(vscode.workspace.onDidChangeConfiguration(async e => { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${QUERIES}`)) { + this.refreshAll(); } })); } - async refresh(node?: TreeNode) { - return node ? this._onDidChangeTreeData.fire(node) : this._onDidChangeTreeData.fire(); + 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) { + const currentManagers = new Set(this._reposManager.folderManagers); + const before = this._children.length; + const validChildren = (this._children as WorkspaceFolderNode[]).filter(folderNode => { + if (currentManagers.has(folderNode.folderManager)) { + return true; + } + folderNode.dispose(); + return false; + }); + if (validChildren.length !== before) { + this._children = validChildren; + this._onDidChangeTreeData.fire(); + return; + } + validChildren.forEach(folderNode => this.refreshQueryResultsForFolder(folderNode)); + return; + } + this.refreshQueryResultsForFolder(); + } + + 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]); } - getTreeItem(element: TreeNode): vscode.TreeItem { + 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; + } + } + } + + 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(); } - private needsRemotes() { - if (this._reposManager.state === ReposManagerState.NeedsAuthentication) { - return Promise.resolve([]); + 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 item; + } + + private async needsRemotes() { + if (this._reposManager?.state === ReposManagerState.NeedsAuthentication) { + 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) { - return Promise.resolve([ - new PRCategoryActionNode(this._view, PRCategoryActionType.NoMatchingRemotes), - new PRCategoryActionNode(this._view, PRCategoryActionType.ConfigureRemotes) - ]); + actions = [ + new PRCategoryActionNode(this, PRCategoryActionType.NoMatchingRemotes), + new PRCategoryActionNode(this, PRCategoryActionType.ConfigureRemotes), + + ]; + } else { + actions = [new PRCategoryActionNode(this, PRCategoryActionType.NoRemotes)]; + } + + const { enterpriseRemotes } = this._reposManager ? await findDotComAndEnterpriseRemotes(this._reposManager?.folderManagers) : { enterpriseRemotes: [] }; + if ((enterpriseRemotes.length > 0) && !this._reposManager?.credentialStore.isAuthenticated(AuthProvider.githubEnterprise)) { + actions.push(new PRCategoryActionNode(this, PRCategoryActionType.LoginEnterprise)); } - return Promise.resolve([new PRCategoryActionNode(this._view, PRCategoryActionType.NoRemotes)]); + return actions; + } + + async cachedChildren(element?: WorkspaceFolderNode | CategoryTreeNode): Promise<TreeNode[]> { + if (!element) { + return this._children; + } + return element.cachedChildren(); } async getChildren(element?: TreeNode): Promise<TreeNode[]> { - if (!this._reposManager) { - if (!vscode.workspace.workspaceFolders) { - return Promise.resolve([new PRCategoryActionNode(this._view, PRCategoryActionType.NoOpenFolder)]); - } else { - return Promise.resolve([new PRCategoryActionNode(this._view, PRCategoryActionType.NoGitRepositories)]); - } + if (!this._reposManager?.folderManagers.length) { + return []; } if (this._reposManager.state === ReposManagerState.Initializing) { - return Promise.resolve([new PRCategoryActionNode(this._view, PRCategoryActionType.Initializing)]); + commands.setContext(contexts.LOADING_PRS_TREE, true); + return []; } - if (this._reposManager.folderManagers.filter(manager => manager.getGitHubRemotes().length > 0).length === 0) { + const remotes = await Promise.all(this._reposManager.folderManagers.map(manager => manager.getGitHubRemotes())); + if ((this._reposManager.folderManagers.filter((_manager, index) => remotes[index].length > 0).length === 0)) { 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) { - return WorkspaceFolderNode.getCategoryTreeNodes(this._reposManager.folderManagers[0], this._telemetry, this._view); + let result: WorkspaceFolderNode[] | CategoryTreeNode[]; + if (gitHubFolderManagers.length === 1) { + result = await WorkspaceFolderNode.getCategoryTreeNodes( + gitHubFolderManagers[0], + this._telemetry, + this, + this._notificationsProvider!, + this._context, + this.prsTreeModel, + ); } else { - result = this._reposManager.folderManagers.map(folderManager => new WorkspaceFolderNode(this._view, folderManager.repository.rootUri, folderManager, this._telemetry)); + result = gitHubFolderManagers.map( + folderManager => + new WorkspaceFolderNode( + this, + folderManager.repository.rootUri, + folderManager, + this._telemetry, + this._notificationsProvider!, + this._context, + 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) { - return Promise.resolve([new PRCategoryActionNode(this._view, PRCategoryActionType.Empty)]); + if ( + gitHubFolderManagers.filter(manager => manager.repository.state.remotes.length > 0).length === 0 + ) { + return Promise.resolve([new PRCategoryActionNode(this, PRCategoryActionType.Empty)]); } 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 new file mode 100644 index 0000000000..7a2ecc0824 --- /dev/null +++ b/src/view/prsTreeModel.ts @@ -0,0 +1,606 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { FolderRepositoryManager, ItemsResponseResult } from '../github/folderRepositoryManager'; +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'; + +export interface PRStatusChange { + pullRequest: PullRequestModel; + status: UnsatisfiedChecks; +} + +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(); + + 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); + } else { + const activeManagers = new Set(this._reposManager.folderManagers); + + for (const [manager, disposables] of this._repoEvents) { + if (!activeManagers.has(manager)) { + disposeAll(disposables); + this._repoEvents.delete(manager); + } + } + + for (const [manager, disposables] of this._activePRDisposables) { + if (!activeManagers.has(manager)) { + disposeAll(disposables); + this._activePRDisposables.delete(manager); + } + } + this._onDidChangeData.fire(); + } + })); + + 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[]) { + // 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, 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) { + if (newStatus === UnsatisfiedChecks.None) { + newStatus |= UnsatisfiedChecks.Unknown; + } + } else if (check.state !== CheckState.Success) { + for (const status of check.statuses) { + if (status.state === CheckState.Failure) { + newStatus |= UnsatisfiedChecks.CIFailed; + } else if (status.state === CheckState.Pending) { + newStatus |= UnsatisfiedChecks.CIPending; + } + } + if (newStatus === UnsatisfiedChecks.None) { + newStatus |= UnsatisfiedChecks.CIPending; + } + } + const identifier = createPRNodeIdentifier(pullRequest); + const oldState = this._queriedPullRequests.get(identifier); + if ((oldState === undefined) || (oldState.status !== newStatus)) { + const newState = { pullRequest, status: newStatus }; + changedStatuses.push(identifier); + this._queriedPullRequests.set(identifier, newState); + } + } + this._onDidChangePrStatus.fire(changedStatuses); + } + + 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); + this.hasLoaded = true; + return { hasMorePages: false, hasUnsearchedRepositories: false, items: 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, 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" : {} + */ + 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; + } + + 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 new file mode 100644 index 0000000000..40cf1b8833 --- /dev/null +++ b/src/view/pullRequestCommentController.ts @@ -0,0 +1,602 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { v4 as uuid } from 'uuid'; +import * as vscode from 'vscode'; +import { CommentHandler, registerCommentHandler, unregisterCommentHandler } from '../commentHandlerResolver'; +import { CommentControllerBase } from './commentControllBase'; +import { DiffSide, IComment, SubjectType } from '../common/comment'; +import { disposeAll } from '../common/lifecycle'; +import Logger from '../common/logger'; +import { ITelemetry } from '../common/telemetry'; +import { fromPRUri, Schemes } from '../common/uri'; +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, + updateThread, + updateThreadWithRange, +} from '../github/utils'; + +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 readonly _context: vscode.ExtensionContext; + private readonly _githubRepositories: GitHubRepository[]; + + constructor( + 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, folderRepoManager.repository); + + if (this.pullRequestModel.reviewThreadsCacheReady) { + this.initializeThreadsInOpenEditors().then(() => { + this.registerListeners(); + }); + } else { + const reviewThreadsDisposable = this.pullRequestModel.onDidChangeReviewThreads(async () => { + reviewThreadsDisposable.dispose(); + await this.initializeThreadsInOpenEditors(); + this.registerListeners(); + }); + } + this._githubRepositories = this.githubReposForPullRequest(pullRequestModel); + } + + private registerListeners(): void { + this._register(this.pullRequestModel.onDidChangeReviewThreads(e => this.onDidChangeReviewThreads(e))); + + this._register( + vscode.window.tabGroups.onDidChangeTabs(async e => { + return this.onDidChangeOpenTabs(e); + }) + ); + + this._register( + this.pullRequestModel.onDidChangePendingReviewState(newDraftMode => { + for (const key in this._commentThreadCache) { + this._commentThreadCache[key].forEach(thread => { + updateCommentReviewState(thread, newDraftMode); + }); + } + }), + ); + + this._register( + vscode.window.onDidChangeActiveTextEditor(e => { + this.refreshContextKey(e); + }), + ); + } + + private refreshContextKey(editor: vscode.TextEditor | undefined): void { + if (!editor) { + return; + } + + const editorUri = editor.document.uri; + if (editorUri.scheme !== Schemes.Pr) { + return; + } + + const params = fromPRUri(editorUri); + if (!params || params.prNumber !== this.pullRequestModel.number) { + return; + } + + this.setContextKey(this.pullRequestModel.hasPendingReview); + } + + 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); + } + } + }; + 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 }); + } + 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 async addThreadsForEditors(documents: vscode.TextDocument[]): Promise<void> { + const reviewThreads = this.pullRequestModel.reviewThreadsCache; + const threadsByPath = groupBy(reviewThreads, thread => thread.path); + 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[cacheKey] = threadsByPath[fileName] + .filter( + thread => + ((thread.diffSide === DiffSide.LEFT && isBase) || + (thread.diffSide === DiffSide.RIGHT && !isBase)) + && (thread.endLine !== null), + ) + .map(thread => { + const endLine = thread.endLine - 1; + const range = thread.subjectType === SubjectType.FILE ? undefined : threadRange(thread.startLine - 1, endLine, document.lineAt(endLine).range.end.character); + + return createVSCodeCommentThreadForReviewThread( + this._context, + document.uri, + range, + thread, + this._commentController, + currentUser, + this._githubRepositories + ); + }); + } + } + } + + private async initializeThreadsInOpenEditors(): Promise<void> { + const prEditors = await this.getPREditors(vscode.window.visibleTextEditors); + return this.addThreadsForEditors(prEditors); + } + + private allTabs(): (vscode.TabInputText | vscode.TabInputTextDiff)[] { + return this.filterTabsToPrTabs(vscode.window.tabGroups.all.map(group => group.tabs).flat()); + } + + 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 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 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(); + } + } + + 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 async onDidChangeReviewThreads(e: ReviewThreadChangeEvent): Promise<void> { + for (const thread of e.added) { + const fileName = thread.path; + const key = this.getCommentThreadCacheKey(thread.path, thread.diffSide === DiffSide.LEFT); + + // Defensive: if a comment thread for this review thread id already exists in the cache + // (e.g. because addThreadsForEditors already created it from the cache before this event was + // processed), update it in place instead of creating a duplicate VS Code comment thread. + const existing = this._commentThreadCache[key]?.find(t => t.gitHubThreadId === thread.id); + if (existing) { + updateThread(this._context, existing, thread, this._githubRepositories); + continue; + } + + const index = this._pendingCommentThreadAdds.findIndex(t => { + 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; + }); + + let newThread: GHPRCommentThread | undefined = undefined; + if (index > -1) { + newThread = this._pendingCommentThreadAdds[index]; + newThread.gitHubThreadId = thread.id; + 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 = await this.getPREditors(vscode.window.visibleTextEditors); + const matchingEditor = openPREditors.find(editor => { + const query = fromPRUri(editor.uri); + const sameSide = + (thread.diffSide === DiffSide.RIGHT && !query?.isBase) || + (thread.diffSide === DiffSide.LEFT && query?.isBase); + return query?.fileName === fileName && sameSide; + }); + + if (matchingEditor) { + const endLine = thread.endLine - 1; + const range = thread.subjectType === SubjectType.FILE ? undefined : threadRange(thread.startLine - 1, endLine, matchingEditor.lineAt(endLine).range.end.character); + + newThread = createVSCodeCommentThreadForReviewThread( + this._context, + matchingEditor.uri, + range, + thread, + this._commentController, + (await this._folderRepoManager.getCurrentUser()), + this._githubRepositories + ); + } + } + + if (!newThread) { + continue; + } + if (this._commentThreadCache[key]) { + this._commentThreadCache[key].push(newThread); + } else { + this._commentThreadCache[key] = [newThread]; + } + } + + for (const thread of e.changed) { + const key = this.getCommentThreadCacheKey(thread.path, thread.diffSide === DiffSide.LEFT); + const index = this._commentThreadCache[key] ? this._commentThreadCache[key].findIndex(t => t.gitHubThreadId === thread.id) : -1; + if (index > -1) { + const matchingThread = this._commentThreadCache[key][index]; + updateThread(this._context, matchingThread, thread, this._githubRepositories); + } + } + + 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) { + const matchingThread = this._commentThreadCache[key][index]; + 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 { + if (thread.uri.scheme !== Schemes.Pr) { + return false; + } + + const params = fromPRUri(thread.uri); + + if (!params || params.prNumber !== this.pullRequestModel.number) { + return false; + } + + return true; + } + + private getCommentSide(thread: GHPRCommentThread): DiffSide { + const query = fromPRUri(thread.uri); + return query?.isBase ? DiffSide.LEFT : DiffSide.RIGHT; + } + + public async createOrReplyComment( + thread: GHPRCommentThread, + input: string, + isSingleComment: boolean, + inDraft?: boolean, + ): Promise<void> { + const hasExistingComments = thread.comments.length; + const isDraft = isSingleComment + ? false + : inDraft !== undefined + ? inDraft + : this.pullRequestModel.hasPendingReview; + const temporaryCommentId = await this.optimisticallyAddComment(thread, input, isDraft); + + try { + if (hasExistingComments) { + await this.reply(thread, input, isSingleComment); + } else { + const fileName = this._folderRepoManager.gitRelativeRootPath(thread.uri.path); + const side = this.getCommentSide(thread); + this._pendingCommentThreadAdds.push(thread); + await Promise.all([this.pullRequestModel.createReviewThread( + input, + fileName, + 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) { + await this.pullRequestModel.submitReview(); + } + } catch (e) { + 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.githubRepository.getPullRequest(this.pullRequestModel.number, 'PullRequestCommentController.replyThread'); + } + }); + } else { + vscode.window.showErrorMessage(`Creating comment failed: ${e}`); + } + thread.comments = thread.comments.map(c => { + if (c instanceof TemporaryComment && c.id === temporaryCommentId) { + c.mode = vscode.CommentMode.Editing; + } + + return c; + }); + } + } + + private reply(thread: GHPRCommentThread, input: string, isSingleComment: boolean): Promise<IComment | undefined> { + const replyingTo = thread.comments[0]; + if (replyingTo instanceof GHPRComment) { + return this.pullRequestModel.createCommentReply(input, replyingTo.rawComment.graphNodeId, isSingleComment); + } else { + // TODO can we do better? + throw new Error('Cannot respond to temporary comment'); + } + } + + private async optimisticallyEditComment(thread: GHPRCommentThread, comment: GHPRComment): Promise<number> { + const currentUser = await this._folderRepoManager.getCurrentUser(this.pullRequestModel.githubRepository); + const temporaryComment = new TemporaryComment( + thread, + comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, + !!comment.label, + currentUser, + comment, + ); + thread.comments = thread.comments.map(c => { + if (c instanceof GHPRComment && c.commentId === comment.commentId) { + return temporaryComment; + } + + return c; + }); + + return temporaryComment.id; + } + + public async editComment(thread: GHPRCommentThread, comment: GHPRComment | TemporaryComment): Promise<void> { + if (comment instanceof GHPRComment) { + const temporaryCommentId = await this.optimisticallyEditComment(thread, comment); + try { + await this.pullRequestModel.editReviewComment( + comment.rawComment, + comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, + ); + } catch (e) { + vscode.window.showErrorMessage(`Editing comment failed ${e}`); + + thread.comments = thread.comments.map(c => { + if (c instanceof TemporaryComment && c.id === temporaryCommentId) { + 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, + ); + } + } + + public async deleteComment(thread: GHPRCommentThread, comment: GHPRComment | TemporaryComment): Promise<void> { + if (comment instanceof GHPRComment) { + await this.pullRequestModel.deleteReviewComment(comment.commentId); + } else { + thread.comments = thread.comments.filter(c => !(c instanceof TemporaryComment && c.id === comment.id)); + } + + await this.pullRequestModel.validateDraftMode(); + } + // #endregion + + // #region Review + public async startReview(thread: GHPRCommentThread, input: string): Promise<void> { + const hasExistingComments = thread.comments.length; + let temporaryCommentId: number | undefined = undefined; + + try { + temporaryCommentId = await this.optimisticallyAddComment(thread, input, true); + if (!hasExistingComments) { + 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 ? (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 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) { + c.mode = vscode.CommentMode.Editing; + } + + return c; + }); + } + } + + + public async openReview(): Promise<void> { + 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(identity.owner, identity.repo, identity.number); + + /* __GDPR__ + "pr.openDescription" : {} + */ + this._folderRepoManager.telemetry.sendTelemetryEvent('pr.openDescription'); + } + + private async optimisticallyAddComment(thread: GHPRCommentThread, input: string, inDraft: boolean): Promise<number> { + 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; + } + + private updateCommentThreadComments(thread: GHPRCommentThread, newComments: (GHPRComment | TemporaryComment)[]) { + thread.comments = newComments; + updateCommentThreadLabel(thread); + } + + private async createCommentOnResolve(thread: GHPRCommentThread, input: string): Promise<void> { + const pendingReviewId = await this.pullRequestModel.getPendingReviewId(); + await this.createOrReplyComment(thread, input, !pendingReviewId); + } + + public async resolveReviewThread(thread: GHPRCommentThread, input?: string): Promise<void> { + try { + if (input) { + await this.createCommentOnResolve(thread, input); + } + + await this.pullRequestModel.resolveReviewThread(thread.gitHubThreadId); + } catch (e) { + vscode.window.showErrorMessage(`Resolving conversation failed: ${e}`); + } + } + + public async unresolveReviewThread(thread: GHPRCommentThread, input?: string): Promise<void> { + try { + if (input) { + await this.createCommentOnResolve(thread, input); + } + + await this.pullRequestModel.unresolveReviewThread(thread.gitHubThreadId); + } catch (e) { + vscode.window.showErrorMessage(`Unresolving conversation failed: ${e}`); + } + } + + public async toggleReaction(comment: GHPRComment, reaction: vscode.CommentReaction): Promise<void> { + if (comment.parent!.uri.scheme !== Schemes.Pr) { + return; + } + + 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); + } + } + + private setContextKey(inDraftMode: boolean): void { + vscode.commands.executeCommand('setContext', 'prInDraft', inDraftMode); + } + + private removeAllCommentsThreads(): void { + Object.keys(this._commentThreadCache).forEach(key => { + disposeAll(this._commentThreadCache[key]); + delete this._commentThreadCache[key]; + }); + } + + override dispose() { + super.dispose(); + this.removeAllCommentsThreads(); + unregisterCommentHandler(this._commentHandlerId); + } +} diff --git a/src/view/pullRequestCommentControllerRegistry.ts b/src/view/pullRequestCommentControllerRegistry.ts new file mode 100644 index 0000000000..e4eeb6ad12 --- /dev/null +++ b/src/view/pullRequestCommentControllerRegistry.ts @@ -0,0 +1,147 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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'; + +interface PullRequestCommentHandlerInfo extends vscode.Disposable { + handler: PullRequestCommentController & CommentReactionHandler; + refCount: number; +} + +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]: PRCommentingRangeProviderInfo } = {}; + private readonly _activeChangeListeners: Map<FolderRepositoryManager, vscode.Disposable> = new Map(); + public readonly resourceHints = { schemes: [Schemes.Pr] }; + + constructor(public readonly commentsController: vscode.CommentController, private readonly _telemetry: ITelemetry) { + super(); + this.commentsController.commentingRangeProvider = this; + this.commentsController.reactionHandler = this.toggleReaction.bind(this); + } + + async provideCommentingRanges(document: vscode.TextDocument, token: vscode.CancellationToken): Promise<vscode.Range[] | undefined> { + const uri = document.uri; + const params = fromPRUri(uri); + + if (!params || !this._prCommentingRangeProviders[params.prNumber]) { + return; + } + + const provideCommentingRanges = this._prCommentingRangeProviders[params.prNumber].provider.provideCommentingRanges.bind( + this._prCommentingRangeProviders[params.prNumber].provider, + ); + + return provideCommentingRanges(document, token); + } + + async toggleReaction(comment: GHPRComment, reaction: vscode.CommentReaction): Promise<void> { + const uri = comment.parent!.uri; + const params = fromPRUri(uri); + + if ( + !params || + !this._prCommentHandlers[params.prNumber] || + !this._prCommentHandlers[params.prNumber].handler.toggleReaction + ) { + return; + } + + const toggleReaction = this._prCommentHandlers[params.prNumber].handler.toggleReaction!.bind( + this._prCommentHandlers[params.prNumber].handler, + ); + + 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]; + } + + 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, + dispose: () => { + if (!this._prCommentHandlers[prNumber]) { + return; + } + + this._prCommentHandlers[prNumber].refCount -= 1; + if (this._prCommentHandlers[prNumber].refCount === 0) { + this._prCommentHandlers[prNumber].handler.dispose(); + delete this._prCommentHandlers[prNumber]; + } + } + }; + + return this._prCommentHandlers[prNumber]; + } + + public registerCommentingRangeProvider(prNumber: number, provider: vscode.CommentingRangeProvider2): vscode.Disposable { + if (this._prCommentingRangeProviders[prNumber]) { + this._prCommentingRangeProviders[prNumber].refCount += 1; + return this._prCommentingRangeProviders[prNumber]; + } + + this._prCommentingRangeProviders[prNumber] = { + provider, + refCount: 1, + dispose: () => { + if (!this._prCommentingRangeProviders[prNumber]) { + return; + } + + this._prCommentingRangeProviders[prNumber].refCount -= 1; + if (this._prCommentingRangeProviders[prNumber].refCount === 0) { + delete this._prCommentingRangeProviders[prNumber]; + } + } + }; + + return this._prCommentingRangeProviders[prNumber]; + } + + override dispose() { + super.dispose(); + Object.keys(this._prCommentHandlers).forEach(key => { + this._prCommentHandlers[key].handler.dispose(); + }); + + this._prCommentingRangeProviders = {}; + this._prCommentHandlers = {}; + } +} diff --git a/src/view/quickpick.ts b/src/view/quickpick.ts index 49a4798558..637f0273be 100644 --- a/src/view/quickpick.ts +++ b/src/view/quickpick.ts @@ -1,94 +1,24 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { Remote } from '../common/remote'; - -export class RemoteQuickPickItem implements vscode.QuickPickItem { - detail?: string; - picked?: boolean; - - static fromRemote(remote: Remote) { - return new this(remote.owner, remote.repositoryName, remote.url, remote); - } - - constructor( - public owner: string, - public name: string, - public description: string, - public remote?: Remote, - public label = `${owner}:${name}`, - ) { } -} - -export type PullRequestTitleSource = 'commit' | 'branch' | 'custom' | 'ask'; - -export enum PullRequestTitleSourceEnum { - Commit = 'commit', - Branch = 'branch', - Custom = 'custom', - Ask = 'ask' -} - -export class PullRequestTitleSourceQuickPick implements vscode.QuickPickItem { - static allOptions(): PullRequestTitleSourceQuickPick[] { - const values: PullRequestTitleSource[] = [ - PullRequestTitleSourceEnum.Commit, - PullRequestTitleSourceEnum.Branch, - PullRequestTitleSourceEnum.Custom - ]; - return values.map(x => this.fromPullRequestTitleSource(x)); - } - static getDescription(pullRequestTitleSource: PullRequestTitleSource): string { - switch (pullRequestTitleSource) { - case PullRequestTitleSourceEnum.Commit: - return 'Use the latest commit message'; - case PullRequestTitleSourceEnum.Branch: - return 'Use the branch name'; - case PullRequestTitleSourceEnum.Custom: - return 'Specify a custom title'; - } - return ''; - } - static fromPullRequestTitleSource(pullRequestTitleSource: PullRequestTitleSource) { - return new this(this.getDescription(pullRequestTitleSource), pullRequestTitleSource, pullRequestTitleSource); - } - constructor(public description: string, public pullRequestTitleSource: PullRequestTitleSource, public label: string) { } -} - -export type PullRequestDescriptionSource = 'template' | 'commit' | 'custom' | 'ask'; - -export enum PullRequestDescriptionSourceEnum { - Template = 'template', - Commit = 'commit', - Custom = 'custom', - Ask = 'ask' -} - -export class PullRequestDescriptionSourceQuickPick implements vscode.QuickPickItem { - static allOptions(): PullRequestDescriptionSourceQuickPick[] { - const values: PullRequestDescriptionSource[] = [ - PullRequestDescriptionSourceEnum.Template, - PullRequestDescriptionSourceEnum.Commit, - PullRequestDescriptionSourceEnum.Custom - ]; - return values.map(x => this.fromPullRequestDescriptionSource(x)); - } - static getDescription(pullRequestDescriptionSource: PullRequestDescriptionSource): string { - switch (pullRequestDescriptionSource) { - case PullRequestDescriptionSourceEnum.Template: - return 'Use a pull request template, or use the commit description if no templates were found'; - case PullRequestDescriptionSourceEnum.Commit: - return 'Use the latest commit message'; - case PullRequestDescriptionSourceEnum.Custom: - return 'Specify a custom description'; - } - return ''; - } - static fromPullRequestDescriptionSource(pullRequestDescriptionSource: PullRequestDescriptionSource) { - return new this(this.getDescription(pullRequestDescriptionSource), pullRequestDescriptionSource, pullRequestDescriptionSource); - } - constructor(public description: string, public pullRequestDescriptionSource: PullRequestDescriptionSource, public label: string) { } -} +/*--------------------------------------------------------------------------------------------- + * 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 { Remote } from '../common/remote'; + +export class RemoteQuickPickItem implements vscode.QuickPickItem { + detail?: string; + picked?: boolean; + + static fromRemote(remote: Remote) { + return new this(remote.owner, remote.repositoryName, remote.url, remote); + } + + constructor( + public owner: string, + public name: string, + public description: string, + public remote?: Remote, + public label = `${owner}:${name}`, + ) {} +} diff --git a/src/view/readonlyFileSystemProvider.ts b/src/view/readonlyFileSystemProvider.ts new file mode 100644 index 0000000000..d98192b691 --- /dev/null +++ b/src/view/readonlyFileSystemProvider.ts @@ -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. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +export abstract class ReadonlyFileSystemProvider implements vscode.FileSystemProvider { + protected _onDidChangeFile = new vscode.EventEmitter<vscode.FileChangeEvent[]>(); + onDidChangeFile = this._onDidChangeFile.event; + + constructor() { } + + watch(_uri: vscode.Uri, _options: { recursive: boolean; excludes: string[]; }): vscode.Disposable { + /** no op */ + return { dispose: () => { } }; + } + + stat(_uri: any): vscode.FileStat { + /** mock stat as they are not necessarily needed */ + return { + type: vscode.FileType.File, + ctime: 0, + mtime: new Date().getTime(), + size: 0 + }; + } + + readDirectory(_uri: vscode.Uri): [string, vscode.FileType][] { + return []; + } + + createDirectory(_uri: vscode.Uri): void { + /** no op */ + } + + abstract readFile(_uri: vscode.Uri): Promise<Uint8Array>; + + writeFile(_uri: vscode.Uri, _content: Uint8Array, _options: { create: boolean; overwrite: boolean; }): 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 */ + } +} \ No newline at end of file diff --git a/src/view/repositoryFileSystemProvider.ts b/src/view/repositoryFileSystemProvider.ts new file mode 100644 index 0000000000..78ab8a849a --- /dev/null +++ b/src/view/repositoryFileSystemProvider.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ReadonlyFileSystemProvider } from './readonlyFileSystemProvider'; +import { GitApiImpl } from '../api/api1'; +import Logger from '../common/logger'; +import { CredentialStore } from '../github/credentials'; +import { RepositoriesManager } from '../github/repositoriesManager'; + +export abstract class RepositoryFileSystemProvider extends ReadonlyFileSystemProvider { + constructor(protected gitAPI: GitApiImpl, protected credentialStore: CredentialStore) { + super(); + } + + protected async waitForRepos(milliseconds: number): Promise<void> { + Logger.appendLine('Waiting for repositories.', 'RepositoryFileSystemProvider'); + let eventDisposable: vscode.Disposable | undefined = undefined; + const openPromise = new Promise<void>(resolve => { + eventDisposable = this.gitAPI.onDidOpenRepository(() => { + Logger.appendLine('Found at least one repository.', 'RepositoryFileSystemProvider'); + eventDisposable?.dispose(); + eventDisposable = undefined; + resolve(); + }); + }); + let timeout: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise<void>(resolve => { + timeout = setTimeout(() => { + Logger.appendLine('Timed out while waiting for repositories.', 'RepositoryFileSystemProvider'); + resolve(); + }, milliseconds); + }); + await Promise.race([openPromise, timeoutPromise]); + if (timeout) { + clearTimeout(timeout); + } + if (eventDisposable) { + (eventDisposable as vscode.Disposable).dispose(); + } + } + + protected async waitForAuth(): Promise<void> { + if (this.credentialStore.isAnyAuthenticated()) { + return; + } + 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 862c2f7afa..7855c8a2de 100644 --- a/src/view/reviewCommentController.ts +++ b/src/view/reviewCommentController.ts @@ -4,323 +4,461 @@ *--------------------------------------------------------------------------------------------*/ import * as nodePath from 'path'; -import * as uuid from 'uuid'; +import { v4 as uuid } from 'uuid'; import * as vscode from 'vscode'; -import { IComment } from '../common/comment'; -import { GHPRComment, GHPRCommentThread, TemporaryComment } from '../github/prComment'; -import { getAbsolutePosition, getLastDiffLine, mapCommentsToHead, mapOldPositionToNew, getDiffLineByPosition, getZeroBased, mapHeadLineToDiffHunkPosition } from '../common/diffPositionMapping'; -import { fromPRUri, fromReviewUri, ReviewUriParams } from '../common/uri'; -import { formatError, groupBy, uniqBy } from '../common/utils'; import { Repository } from '../api/api'; -import { FolderRepositoryManager } from '../github/folderRepositoryManager'; -import { GitFileChangeNode, gitFileChangeNodeFilter, RemoteFileChangeNode } from './treeNodes/fileChangeNode'; -import { getDocumentThreadDatas, ThreadData } from './treeNodes/pullRequestNode'; -import { parseGraphQLReaction, createVSCodeCommentThread, updateCommentThreadLabel, updateCommentReviewState, CommentReactionHandler, generateCommentReactions } from '../github/utils'; -import { ReactionGroup } from '../github/graphql'; -import { DiffHunk, DiffChangeType } from '../common/diffHunk'; +import { GitApiImpl } from '../api/api1'; import { CommentHandler, registerCommentHandler, unregisterCommentHandler } from '../commentHandlerResolver'; -import { CommentThreadCache } from './commentThreadCache'; +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 { GitChangeType } from '../common/file'; - -function workspaceLocalCommentsToCommentThreads(repository: Repository, fileChange: GitFileChangeNode, fileComments: IComment[], collapsibleState: vscode.CommentThreadCollapsibleState): ThreadData[] { - if (!fileChange) { - return []; - } - - if (!fileComments || !fileComments.length) { - return []; - } - - const ret: ThreadData[] = []; - const sections = groupBy(fileComments, comment => String(comment.position)); - - for (const i in sections) { - const comments = sections[i]; - - const firstComment = comments[0]; - const pos = new vscode.Position(getZeroBased(firstComment.absolutePosition || 0), 0); - const range = new vscode.Range(pos, pos); - - const newPath = nodePath.join(repository.rootUri.path, firstComment.path!).replace(/\\/g, '/'); - const newUri = repository.rootUri.with({ path: newPath }); - ret.push({ - threadId: firstComment.id.toString(), - uri: newUri, - range, - comments, - collapsibleState - }); - } - - return ret; -} - -function mapCommentThreadsToHead(diffHunks: DiffHunk[], localDiff: string, commentThreads: GHPRCommentThread[]) { - commentThreads.forEach(thread => { - if (thread.comments && thread.comments.length) { - const comment = thread.comments[0]; - - if (comment instanceof GHPRComment) { - const diffLine = getDiffLineByPosition(diffHunks, comment._rawComment.position || comment._rawComment.originalPosition!); - if (diffLine) { - const positionInPr = diffLine.type === DiffChangeType.Delete ? diffLine.oldLineNumber : diffLine.newLineNumber; - const newPosition = getZeroBased(mapOldPositionToNew(localDiff, positionInPr)); - const range = new vscode.Range(newPosition, 0, newPosition, 0); +import { mapNewPositionToOld, mapOldPositionToNew } from '../common/diffPositionMapping'; +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 { arrayFindIndexAsync, formatError, groupBy, uniqBy } from '../common/utils'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { GHPRComment, GHPRCommentThread, TemporaryComment } from '../github/prComment'; +import { PullRequestOverviewPanel } from '../github/pullRequestOverview'; +import { + CommentReactionHandler, + createVSCodeCommentThreadForReviewThread, + getRepositoryForFile, + isFileInRepo, + setReplyAuthor, + threadRange, + updateCommentReviewState, + updateCommentThreadLabel, + updateThread, + updateThreadWithRange, +} from '../github/utils'; +import { GitFileChangeNode, gitFileChangeNodeFilter, RemoteFileChangeNode } from './treeNodes/fileChangeNode'; - thread.range = range; - } - } - } - }); +export interface SuggestionInformation { + originalStartLine: number; + originalLineLength: number; + suggestionContent: string; } -export class ReviewCommentController implements vscode.Disposable, CommentHandler, vscode.CommentingRangeProvider, CommentReactionHandler { - - private _localToDispose: vscode.Disposable[] = []; - private _onDidChangeComments = new vscode.EventEmitter<IComment[]>(); - public onDidChangeComments = this._onDidChangeComments.event; - +export class ReviewCommentController extends CommentControllerBase implements CommentHandler, vscode.CommentingRangeProvider2, CommentReactionHandler { + private static readonly ID = 'ReviewCommentController'; + 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[] } = {}; protected _obsoleteFileChangeCommentThreads: { [key: string]: GHPRCommentThread[] } = {}; - // In most cases, the right side/modified document is of type 'file' scheme, so comments - // for that side are from _workspaceFileChangeCommentThreads. If the document has been - // deleted, the right hand side will be 'review' scheme. - protected _reviewDocumentCommentThreads: CommentThreadCache = new CommentThreadCache(); - - protected _prDocumentCommentThreads: CommentThreadCache = new CommentThreadCache(); - protected _visibleNormalTextEditors: vscode.TextEditor[] = []; + private _pendingCommentThreadAdds: GHPRCommentThread[] = []; + private readonly _context: vscode.ExtensionContext; + public readonly resourceHints = { schemes: [Schemes.Review] }; + constructor( - private _reposManager: FolderRepositoryManager, + private _reviewManager: ReviewManager, + folderRepoManager: FolderRepositoryManager, private _repository: Repository, - private _localFileChanges: GitFileChangeNode[], - private _obsoleteFileChanges: (GitFileChangeNode | RemoteFileChangeNode)[], - private _comments: IComment[]) { - this._commentController = vscode.comments.createCommentController(`review-${_reposManager.activePullRequest!.number}`, _reposManager.activePullRequest!.title); - this._commentController.commentingRangeProvider = this; + private _reviewModel: ReviewModel, + private _gitApi: GitApiImpl, + telemetry: ITelemetry + ) { + 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 async initialize(): Promise<void> { - this._visibleNormalTextEditors = vscode.window.visibleTextEditors.filter(ed => ed.document.uri.scheme !== 'comment'); - await this.initializeWorkspaceCommentThreads(); - await this.initializeDocumentCommentThreadsAndListeners(); + this._visibleNormalTextEditors = vscode.window.visibleTextEditors.filter( + ed => ed.document.uri.scheme !== 'comment', + ); + await this._folderRepoManager.activePullRequest!.validateDraftMode(); + await this.initializeCommentThreads(); + await this.registerListeners(); } - async initializeWorkspaceCommentThreads(): Promise<void[]> { - await this._reposManager.activePullRequest!.validateDraftMode(); - const localFileChangePromises = this._localFileChanges.map(async matchedFile => { - const threadData = await this.getWorkspaceFileThreadDatas(matchedFile); - this._workspaceFileChangeCommentThreads[matchedFile.fileName] = threadData.map(thread => createVSCodeCommentThread(thread, this._commentController!)); - return; - }); + /** + * Creates a comment thread for a thread that is not on the latest changes. + * @param path The path to the file the comment thread is on. + * @param thread The comment thread information from GitHub. + * @returns A GHPRCommentThread that has been created on an editor. + */ + private async createOutdatedCommentThread(path: string, thread: IReviewThread): Promise<GHPRCommentThread> { + const commit = thread.comments[0].originalCommitId!; + const uri = vscode.Uri.file(nodePath.join(`commit~${commit.substr(0, 8)}`, path)); + const reviewUri = toReviewUri( + uri, + path, + undefined, + commit, + true, + { base: thread.diffSide === DiffSide.LEFT }, + this._repository.rootUri, + ); + + 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)); + } - const outdatedFileChangePromises = gitFileChangeNodeFilter(this._obsoleteFileChanges).map(fileChange => { - const threads = this.outdatedCommentsToCommentThreads(fileChange, fileChange.comments, vscode.CommentThreadCollapsibleState.Expanded).map(thread => createVSCodeCommentThread(thread, this._commentController!)); - this._obsoleteFileChangeCommentThreads[fileChange.fileName] = threads; - return; - }); + /** + * Creates a comment thread for a thread that appears on the right-hand side, which is a + * document that has a scheme matching the workspace uri scheme, typically 'file'. + * @param uri The uri to the file the comment thread is on. + * @param path The path to the file the comment thread is on. + * @param thread The comment thread information from GitHub. + * @returns A GHPRCommentThread that has been created on an editor. + */ + private async createWorkspaceCommentThread( + uri: vscode.Uri, + path: string, + thread: IReviewThread, + ): Promise<GHPRCommentThread> { + let startLine = thread.startLine; + let endLine = thread.endLine; + const localDiff = await this._repository.diffWithHEAD(path); + if (localDiff) { + startLine = mapOldPositionToNew(localDiff, startLine); + endLine = mapOldPositionToNew(localDiff, endLine); + } - return Promise.all([...localFileChangePromises, ...outdatedFileChangePromises]); + 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)); } - private async getWorkspaceFileThreadDatas(matchedFile: GitFileChangeNode): Promise<ThreadData[]> { - if (!this._reposManager.activePullRequest || !this._reposManager.activePullRequest.isResolved()) { - return []; + /** + * Creates a comment thread for a thread that appears on the left-hand side, which is a + * document that has a 'review' scheme whose content is created by the extension. + * @param uri The uri to the file the comment thread is on. + * @param path The path to the file the comment thread is on. + * @param thread The comment thread information from GitHub. + * @returns A GHPRCommentThread that has been created on an editor. + */ + private async createReviewCommentThread(uri: vscode.Uri, path: string, thread: IReviewThread): Promise<GHPRCommentThread> { + 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._folderRepoManager.activePullRequest.mergeBase, + false, + { base: true }, + this._repository.rootUri, + ); + + 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)); + } - const headCommitSha = this._reposManager.activePullRequest.head.sha; - const contentDiff = await this._repository.diffWith(headCommitSha, matchedFile.fileName); - const fileComments = mapCommentsToHead(matchedFile.diffHunks, contentDiff, matchedFile.comments) - .filter(comment => comment.absolutePosition !== undefined); + private async doInitializeCommentThreads(reviewThreads: IReviewThread[]): Promise<void> { + // First clean up all the old comments. + for (const key in this._workspaceFileChangeCommentThreads) { + disposeAll(this._workspaceFileChangeCommentThreads[key]); + } + this._workspaceFileChangeCommentThreads = {}; + for (const key in this._reviewSchemeFileChangeCommentThreads) { + disposeAll(this._reviewSchemeFileChangeCommentThreads[key]); + } + this._reviewSchemeFileChangeCommentThreads = {}; + for (const key in this._obsoleteFileChangeCommentThreads) { + disposeAll(this._obsoleteFileChangeCommentThreads[key]); + } + this._obsoleteFileChangeCommentThreads = {}; + + const threadsByPath = groupBy(reviewThreads, thread => thread.path); + + Object.keys(threadsByPath).forEach(path => { + const threads = threadsByPath[path]; + const firstThread = threads[0]; + if (firstThread) { + const fullPath = nodePath.join(this._repository.rootUri.path, firstThread.path).replace(/\\/g, '/'); + const uri = this._repository.rootUri.with({ path: fullPath }); + + let rightSideCommentThreads: GHPRCommentThread[] = []; + let leftSideThreads: GHPRCommentThread[] = []; + let outdatedCommentThreads: GHPRCommentThread[] = []; + + const threadPromises = threads.map(async thread => { + if (thread.isOutdated) { + outdatedCommentThreads.push(await this.createOutdatedCommentThread(path, thread)); + } else { + if (thread.diffSide === DiffSide.RIGHT) { + rightSideCommentThreads.push(await this.createWorkspaceCommentThread(uri, path, thread)); + } else { + leftSideThreads.push(await this.createReviewCommentThread(uri, path, thread)); + } + } + }); - return workspaceLocalCommentsToCommentThreads(this._repository, matchedFile, fileComments, vscode.CommentThreadCollapsibleState.Collapsed); - } + Promise.all(threadPromises); - async initializeDocumentCommentThreadsAndListeners(): Promise<void> { - this._localToDispose.push(vscode.window.onDidChangeVisibleTextEditors(async visibleTextEditors => { - if (this.visibleEditorsEqual(this._visibleNormalTextEditors, visibleTextEditors)) { - return; + this._workspaceFileChangeCommentThreads[path] = rightSideCommentThreads; + this._reviewSchemeFileChangeCommentThreads[path] = leftSideThreads; + this._obsoleteFileChangeCommentThreads[path] = outdatedCommentThreads; } + }); + this.updateResourcesWithCommentingRanges(); + } - this._visibleNormalTextEditors = visibleTextEditors.filter(ed => ed.document.uri.scheme !== 'comment'); - // remove comment threads in `pr/review` documents if there are no longer visible - const prEditors = visibleTextEditors.filter(editor => { - if (editor.document.uri.scheme !== 'pr') { - return false; + /** + * 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); } + } + } + } - const params = fromPRUri(editor.document.uri); - return !!params && params.prNumber === this._reposManager.activePullRequest!.number; - }); + private async initializeCommentThreads(): Promise<void> { + const activePullRequest = this._folderRepoManager.activePullRequest; + if (!activePullRequest || !activePullRequest.isResolved()) { + return; + } + return this.doInitializeCommentThreads(activePullRequest.reviewThreadsCache); + } - this._prDocumentCommentThreads.maybeDisposeThreads(prEditors, (editor: vscode.TextEditor, fileName: string, isBase: boolean) => { - const params = fromPRUri(editor.document.uri); - return !!params && params.fileName === fileName && params.isBase === isBase; - }); + private async registerListeners(): Promise<void> { + const activePullRequest = this._folderRepoManager.activePullRequest; + if (!activePullRequest) { + return; + } - this._reviewDocumentCommentThreads.maybeDisposeThreads(visibleTextEditors, (editor: vscode.TextEditor, fileName: string, isBase: boolean) => { - const editorFileName = this.gitRelativeRootPath(editor.document.uri.path); - if (editor.document.uri.scheme !== 'review' && editor.document.uri.scheme === this._repository.rootUri.scheme && editor.document.uri.query) { - const params = fromReviewUri(editor.document.uri); - if (fileName === editorFileName && params.base === isBase) { - return true; + this._register( + activePullRequest.onDidChangePendingReviewState(newDraftMode => { + [ + this._workspaceFileChangeCommentThreads, + this._obsoleteFileChangeCommentThreads, + this._reviewSchemeFileChangeCommentThreads, + ].forEach(commentThreadMap => { + for (const fileName in commentThreadMap) { + commentThreadMap[fileName].forEach(thread => { + updateCommentReviewState(thread, newDraftMode); + updateCommentThreadLabel(thread); + }); } - } - - if (editor.document.uri.scheme !== 'review') { - return false; - } - - try { - const params = fromReviewUri(editor.document.uri); - if (fileName === editorFileName && params.base === isBase) { - return true; + }); + }), + ); + + this._register( + activePullRequest.onDidChangeReviewThreads(async e => { + const githubRepositories = this.githubReposForPullRequest(this._folderRepoManager.activePullRequest); + for (const thread of e.added) { + const { path } = thread; + + // Defensive: if a comment thread for this review thread id already exists in any of the + // thread maps (e.g. because doInitializeCommentThreads already created it from the cache + // before this event was processed), update it in place instead of creating a duplicate + // VS Code comment thread. + const existingMatch = this._findMatchingThread(thread); + if (existingMatch.index > -1) { + const matchingThread = existingMatch.threadMap[thread.path][existingMatch.index]; + updateThread(this._context, matchingThread, thread, githubRepositories); + continue; } - } catch { - return false; - } - - return false; - }); - const workspaceDocuments = visibleTextEditors.filter(editor => editor.document.uri.scheme === this._repository.rootUri.scheme); - workspaceDocuments.forEach(editor => { - const fileName = this.gitRelativeRootPath(editor.document.uri.path); - const threadsForEditor = this._workspaceFileChangeCommentThreads[fileName] || []; - // If the editor has no view column, assume it is part of a diff editor and expand the comments. Otherwise, collapse them. - const isEmbedded = !editor.viewColumn; - this._workspaceFileChangeCommentThreads[fileName] = threadsForEditor.map(thread => { - thread.collapsibleState = isEmbedded - ? vscode.CommentThreadCollapsibleState.Expanded - : vscode.CommentThreadCollapsibleState.Collapsed; - - return thread; - }); - }); + const index = await arrayFindIndexAsync(this._pendingCommentThreadAdds, async t => { + const fileName = this._folderRepoManager.gitRelativeRootPath(t.uri.path); + if (fileName !== thread.path) { + return false; + } - for (const editor of this._visibleNormalTextEditors) { - await this.updateCommentThreadsForEditor(editor); - } - })); - - this._localToDispose.push(this._reposManager.activePullRequest!.onDidChangePendingReviewState(newDraftMode => { - [this._workspaceFileChangeCommentThreads, this._obsoleteFileChangeCommentThreads].forEach(commentThreadMap => { - for (const fileName in commentThreadMap) { - commentThreadMap[fileName].forEach(thread => { - updateCommentReviewState(thread, newDraftMode); - updateCommentThreadLabel(thread); + const diff = await this.getContentDiff(t.uri, fileName); + const line = t.range ? mapNewPositionToOld(diff, t.range.end.line) : 0; + const sameLine = line + 1 === thread.endLine; + return sameLine; }); - } - }); - - this._reviewDocumentCommentThreads.getDocuments().forEach(fileName => { - this._reviewDocumentCommentThreads.getAllThreadsForDocument(fileName)!.forEach(thread => { - updateCommentReviewState(thread, newDraftMode); - updateCommentThreadLabel(thread); - }); - }); - this._prDocumentCommentThreads.getDocuments().forEach(fileName => { - this._prDocumentCommentThreads.getAllThreadsForDocument(fileName)!.forEach(thread => { - thread.comments = thread.comments.map(comment => { - comment.label = newDraftMode ? 'Pending' : undefined; - return comment; - }); - updateCommentThreadLabel(thread); - }); - }); - })); - } + let newThread: GHPRCommentThread; + if (index > -1) { + newThread = this._pendingCommentThreadAdds[index]; + newThread.gitHubThreadId = thread.id; + 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, '/'); + const uri = this._repository.rootUri.with({ path: fullPath }); + if (thread.isOutdated) { + newThread = await this.createOutdatedCommentThread(path, thread); + } else { + if (thread.diffSide === DiffSide.RIGHT) { + newThread = await this.createWorkspaceCommentThread(uri, path, thread); + } else { + newThread = await this.createReviewCommentThread(uri, path, thread); + } + } + } - async updateCommentThreadsForEditor(editor: vscode.TextEditor): Promise<void> { - if (editor.document.uri.scheme === 'pr') { - const params = fromPRUri(editor.document.uri); + const threadMap = thread.isOutdated + ? this._obsoleteFileChangeCommentThreads + : thread.diffSide === DiffSide.RIGHT + ? this._workspaceFileChangeCommentThreads + : this._reviewSchemeFileChangeCommentThreads; - if (params && params.prNumber === this._reposManager.activePullRequest!.number) { - const existingPRThreads = this._prDocumentCommentThreads.getThreadsForDocument(params.fileName, params.isBase); - if (existingPRThreads) { - return; + if (threadMap[path]) { + threadMap[path].push(newThread); + } else { + threadMap[path] = [newThread]; + } } - this._prDocumentCommentThreads.setDocumentThreads(params.fileName, params.isBase, []); - - const matchedFileChanges = this._localFileChanges.filter(localFileChange => localFileChange.fileName === params.fileName); + 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); + } + } - if (matchedFileChanges.length) { - await this._reposManager.activePullRequest!.validateDraftMode(); + 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(); + } + } - const documentComments = getDocumentThreadDatas(editor.document.uri, params.isBase, matchedFileChanges[0], matchedFileChanges[0].comments); - const newThreads: GHPRCommentThread[] = documentComments.map(thread => createVSCodeCommentThread(thread, this._commentController!)); + this.updateResourcesWithCommentingRanges(); + }), + ); + } - this._prDocumentCommentThreads.setDocumentThreads(params.fileName, params.isBase, newThreads); + 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. + const workspaceIndex = this._workspaceFileChangeCommentThreads[thread.path]?.findIndex(t => t.gitHubThreadId === thread.id) ?? -1; + if (workspaceIndex > -1) { + const matchingThread = this._workspaceFileChangeCommentThreads[thread.path]!.splice(workspaceIndex, 1)[0]; + if (!this._obsoleteFileChangeCommentThreads[thread.path]) { + this._obsoleteFileChangeCommentThreads[thread.path] = []; } + this._obsoleteFileChangeCommentThreads[thread.path]!.push(matchingThread); + // `threadMap` already references `_obsoleteFileChangeCommentThreads`; the matching + // thread is now the last element of the obsolete array for this path. + index = this._obsoleteFileChangeCommentThreads[thread.path]!.length - 1; } - - return; } + return { threadMap, index }; + } - const fileName = this.gitRelativeRootPath(editor.document.uri.path); - if (editor.document.uri.scheme === this._repository.rootUri.scheme && editor.viewColumn !== undefined) { - // local files - const matchedFiles = this._localFileChanges.filter(fileChange => fileChange.fileName === fileName); - - if (matchedFiles && !matchedFiles.length) { - return; - } + private _commentContentChangedListener: vscode.Disposable | undefined; + protected onDidChangeActiveTextEditor(editor: vscode.TextEditor | undefined) { + this._commentContentChangedListener?.dispose(); + this._commentContentChangedListener = undefined; - const commentThreads = this._workspaceFileChangeCommentThreads[fileName]; + 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 (!this._reposManager.activePullRequest!.isResolved()) { - return; + 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); } + } - const headCommitSha = this._reposManager.activePullRequest!.head.sha; - const contentDiff = await this.getContentDiff(editor.document, headCommitSha, fileName); - mapCommentThreadsToHead(matchedFiles[0].diffHunks, contentDiff, commentThreads); + if (editor?.document.uri.scheme !== Schemes.Comment) { return; } - - let query: ReviewUriParams | undefined; - - try { - query = fromReviewUri(editor.document.uri); - } catch (e) { } - - if (query) { - if (query.isOutdated) { + 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(); + } - const existingThreadsForDocument = this._reviewDocumentCommentThreads.getThreadsForDocument(fileName, query.base); - if (existingThreadsForDocument) { + public updateCommentExpandState(expand: boolean) { + const activePullRequest = this._folderRepoManager.activePullRequest; + if (!activePullRequest) { + return undefined; + } + const githubRepositories = this.githubReposForPullRequest(activePullRequest); + function updateThreads(threads: { [key: string]: GHPRCommentThread[] }, reviewThreads: Map<string, Map<string, IReviewThread>>) { + if (reviewThreads.size === 0) { return; } + for (const path of reviewThreads.keys()) { + const reviewThreadsForPath = reviewThreads.get(path)!; + const commentThreads = threads[path]; + for (const commentThread of commentThreads) { + const reviewThread = reviewThreadsForPath.get(commentThread.gitHubThreadId)!; + updateThread(this._context, commentThread, reviewThread, githubRepositories, expand); + } + } + } - this._reviewDocumentCommentThreads.setDocumentThreads(fileName, query.base, []); - - await this._reposManager.activePullRequest!.validateDraftMode(); - - const threadData = this.provideCommentsForReviewUri(editor.document, query); - const newThreads = threadData.map(thread => createVSCodeCommentThread(thread, this._commentController!)); - this._reviewDocumentCommentThreads.setDocumentThreads(fileName, query.base, newThreads); + const obsoleteReviewThreads: Map<string, Map<string, IReviewThread>> = new Map(); + const reviewSchemeReviewThreads: Map<string, Map<string, IReviewThread>> = new Map(); + const workspaceFileReviewThreads: Map<string, Map<string, IReviewThread>> = new Map(); + for (const reviewThread of activePullRequest.reviewThreadsCache) { + let mapToUse: Map<string, Map<string, IReviewThread>>; + if (reviewThread.isOutdated) { + mapToUse = obsoleteReviewThreads; + } else { + if (reviewThread.diffSide === DiffSide.RIGHT) { + mapToUse = workspaceFileReviewThreads; + } else { + mapToUse = reviewSchemeReviewThreads; + } + } + if (!mapToUse.has(reviewThread.path)) { + mapToUse.set(reviewThread.path, new Map()); + } + mapToUse.get(reviewThread.path)!.set(reviewThread.id, reviewThread); } + updateThreads(this._obsoleteFileChangeCommentThreads, obsoleteReviewThreads); + updateThreads(this._reviewSchemeFileChangeCommentThreads, reviewSchemeReviewThreads); + updateThreads(this._workspaceFileChangeCommentThreads, workspaceFileReviewThreads); } private visibleEditorsEqual(a: vscode.TextEditor[], b: vscode.TextEditor[]): boolean { @@ -347,310 +485,158 @@ export class ReviewCommentController implements vscode.Disposable, CommentHandle // #endregion - hasCommentThread(thread: vscode.CommentThread): boolean { - if (thread.uri.scheme === 'review') { + hasCommentThread(thread: vscode.CommentThread2): boolean { + if (thread.uri.scheme === Schemes.Review) { return true; } - if (thread.uri.scheme === 'pr') { - const params = fromPRUri(thread.uri); - if (this._reposManager.activePullRequest && params && this._reposManager.activePullRequest.number === params.prNumber) { - return true; - } else { - return false; - } - } - const currentWorkspace = vscode.workspace.getWorkspaceFolder(thread.uri); - if (!currentWorkspace) { + if (!isFileInRepo(this._repository, thread.uri)) { return false; } - if ((thread.uri.scheme === currentWorkspace.uri.scheme) && thread.uri.fsPath.startsWith(this._repository.rootUri.fsPath)) { + if (thread.uri.scheme === this._repository.rootUri.scheme) { return true; } return false; } - private addToCommentThreadCache(thread: GHPRCommentThread): void { - const uri = thread.uri; - const currentWorkspace = vscode.workspace.getWorkspaceFolder(uri)!; - switch (uri.scheme) { - case 'pr': - const params = fromPRUri(uri); - if (params) { - const { fileName, isBase } = params; - const existingThreads = this._prDocumentCommentThreads.getThreadsForDocument(fileName, isBase) || []; - this._prDocumentCommentThreads.setDocumentThreads(fileName, isBase, existingThreads.concat(thread)); - } - return; - - case 'review': - const reviewParams = uri.query && fromReviewUri(uri); - if (reviewParams) { - const documentFileName = this.gitRelativeRootPath(uri.path); - const existingThreads = this._reviewDocumentCommentThreads.getThreadsForDocument(documentFileName, reviewParams.base) || []; - this._reviewDocumentCommentThreads.setDocumentThreads(documentFileName, reviewParams.base, existingThreads.concat(thread)); - return; - } - - case currentWorkspace.uri.scheme: - const workspaceFileName = this.gitRelativeRootPath(uri.path); - const existingWorkspaceThreads = this._workspaceFileChangeCommentThreads[workspaceFileName]; - existingWorkspaceThreads.push(thread); - return; - - default: - return; - } - } - - async provideCommentingRanges(document: vscode.TextDocument, token: vscode.CancellationToken): Promise<vscode.Range[] | undefined> { - if (document.uri.scheme === 'pr') { - const params = fromPRUri(document.uri); - - if (!params || params.prNumber !== this._reposManager.activePullRequest!.number) { - return; - } - - const fileChange = this._localFileChanges.find(change => change.fileName === params.fileName); - - if (!fileChange || fileChange instanceof RemoteFileChangeNode) { - return; - } - - return getCommentingRanges(fileChange.diffHunks, params.isBase); - } - - let query: ReviewUriParams | undefined; - - try { - query = fromReviewUri(document.uri); - } catch (e) { } + 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; if (query) { - const matchedFile = this.findMatchedFileChangeForReviewDiffView(this._localFileChanges, document.uri); + const matchedFile = this.findMatchedFileChangeForReviewDiffView(this._reviewModel.localFileChanges, document.uri); if (matchedFile) { - return getCommentingRanges(matchedFile.diffHunks, query.base); + Logger.debug('Found matched file for commenting ranges.', ReviewCommentController.ID); + return { ranges: getCommentingRanges(await matchedFile.changeModel.diffHunks(), query.base, ReviewCommentController.ID), enableFileComments: true }; } } - const currentWorkspace = vscode.workspace.getWorkspaceFolder(document.uri); - if (!currentWorkspace) { + 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); + } return; } - if (document.uri.scheme === currentWorkspace.uri.scheme) { - if (!this._reposManager.activePullRequest!.isResolved()) { + if (document.uri.scheme === this._repository.rootUri.scheme) { + 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 matchedFile = gitFileChangeNodeFilter(this._localFileChanges).find(fileChange => fileChange.fileName === fileName); - const ranges = []; + const fileName = this._folderRepoManager.gitRelativeRootPath(document.uri.path); + const matchedFile = gitFileChangeNodeFilter(this._reviewModel.localFileChanges).find( + fileChange => fileChange.fileName === fileName, + ); + const ranges: vscode.Range[] = []; - const headCommitSha = this._reposManager.activePullRequest!.head.sha; if (matchedFile) { - if (matchedFile.status === GitChangeType.RENAME) { - return []; + 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 { ranges: [], enableFileComments: true }; } - const contentDiff = await this.getContentDiff(document, headCommitSha, matchedFile.fileName); - const diffHunks = matchedFile.diffHunks; + 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)); } } - } - - return ranges; - } - return; - } - - // #endregion - - // #region Helper - - private async getNewCommentPosition(thread: GHPRCommentThread, matchedFile: GitFileChangeNode): Promise<number> { - const uri = thread.uri; - let isBase = false; - if (uri.query) { - if (uri.scheme === 'review') { - try { - isBase = fromReviewUri(uri).base; - } catch { - // do nothing + if (ranges.length === 0) { + Logger.debug('No commenting ranges: File has diffs, but they could not be mapped to current lines.', ReviewCommentController.ID); } + } else { + Logger.debug('No commenting ranges: File does not match any of the files in the review.', ReviewCommentController.ID); } - if (uri.scheme === 'pr') { - const params = fromPRUri(uri); - isBase = !!params && params.isBase; - } - } - - if (!this._reposManager.activePullRequest) { - throw new Error('No active pull request'); - } - - if (!this._reposManager.activePullRequest.validatePullRequestModel('Creating new comment failed')) { - throw new Error('No upstream branch'); - } - - const headCommitSha = this._reposManager.activePullRequest.head.sha; - - // git diff sha -- fileName - const contentDiff = await this._repository.diffWith(headCommitSha, matchedFile.fileName); - const position = mapHeadLineToDiffHunkPosition(matchedFile.diffHunks, contentDiff, thread.range.start.line + 1, isBase); - // If this is base and the diff line isn't a deletion, then this should actually be created on the right hand side - - if (position < 0) { - throw new Error('Comment position cannot be negative'); - } - - return position; - } - - private async createNewThread(thread: GHPRCommentThread, matchedFile: GitFileChangeNode, text: string): Promise<IComment | undefined> { - const position = await this.getNewCommentPosition(thread, matchedFile); - return await this._reposManager.activePullRequest!.createReviewComment(text, matchedFile.fileName, position); - } - - private async getContentDiff(document: vscode.TextDocument, headCommitSha: string, fileName: string): Promise<string> { - let contentDiff: string; - if (document.isDirty) { - const documentText = document.getText(); - const details = await this._repository.getObjectDetails(headCommitSha, fileName); - const idAtLastCommit = details.object; - const idOfCurrentText = await this._repository.hashObject(documentText); - - // git diff <blobid> <blobid> - contentDiff = await this._repository.diffBlobs(idAtLastCommit, idOfCurrentText); + Logger.debug(`Providing ${ranges.length} commenting ranges for ${nodePath.basename(document.uri.fsPath)}.`, ReviewCommentController.ID); + return { ranges, enableFileComments: ranges.length > 0 }; } else { - // git diff sha -- fileName - contentDiff = await this._repository.diffWith(headCommitSha, fileName); - } - - return contentDiff; - } - - private outdatedCommentsToCommentThreads(fileChange: GitFileChangeNode, fileComments: IComment[], collapsibleState: vscode.CommentThreadCollapsibleState): ThreadData[] { - if (!fileComments || !fileComments.length) { - return []; - } - - const ret: ThreadData[] = []; - const sections = groupBy(fileComments, comment => String(comment.position)); - - for (const i in sections) { - const comments = sections[i]; - - const firstComment = comments[0]; - const diffLine = getDiffLineByPosition(firstComment.diffHunks || [], firstComment.originalPosition!); - - if (diffLine) { - firstComment.absolutePosition = diffLine.newLineNumber; - } - - const pos = new vscode.Position(getZeroBased(firstComment.absolutePosition || 0), 0); - const range = new vscode.Range(pos, pos); - - ret.push({ - threadId: firstComment.id.toString(), - uri: fileChange.filePath, - range, - comments, - collapsibleState: collapsibleState - }); + Logger.debug('No commenting ranges: File scheme differs from repository scheme.', ReviewCommentController.ID); } - return ret; + return; } - private provideCommentsForReviewUri(document: vscode.TextDocument, query: ReviewUriParams): ThreadData[] { - const matchedFile = this.findMatchedFileChangeForReviewDiffView(this._localFileChanges, document.uri); - - if (matchedFile) { - const matchingComments = matchedFile.comments; - const isBase = query.base; - matchingComments.forEach(comment => { comment.absolutePosition = getAbsolutePosition(comment, matchedFile!.diffHunks, isBase); }); - - return workspaceLocalCommentsToCommentThreads(this._repository, matchedFile, matchingComments.filter(comment => comment.absolutePosition !== undefined && comment.absolutePosition > 0), vscode.CommentThreadCollapsibleState.Expanded).map(thread => { - thread.uri = document.uri; - return thread; - }); - } - - const matchedObsoleteFile = this.findMatchedFileChangeForReviewDiffView(this._obsoleteFileChanges, document.uri); - let comments: IComment[] = []; - if (!matchedObsoleteFile) { - // The file may be a change from a specific commit, check the comments themselves to see if they match it, as obsolete file changes - // may not contain it - try { - comments = this._comments.filter(comment => comment.path === query!.path && `${comment.originalCommitId}^` === query.commit); - } catch (_) { - // Do nothing - } + // #endregion - if (!comments.length) { - return []; - } - } else { - comments = matchedObsoleteFile.comments; + 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._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.'); } - const sections = groupBy(comments, comment => String(comment.originalPosition)); // comment.position is null in this case. - const ret: ThreadData[] = []; - for (const i in sections) { - const commentGroup = sections[i]; - const firstComment = commentGroup[0]; - const diffLine = getLastDiffLine(firstComment.diffHunk); - if (!diffLine) { - continue; + try { + 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._folderRepoManager.activePullRequest.head.sha, + fileName, + ); + const idAtLastCommit = details.object; + const idOfCurrentText = await this._repository.hashObject(documentText); + + // git diff <blobid> <blobid> + return await this._repository.diffBlobs(idAtLastCommit, idOfCurrentText); + } else { + return await this._repository.diffWith(this._folderRepoManager.activePullRequest.head.sha, fileName); } - - const lineNumber = query.base - ? diffLine.oldLineNumber - : diffLine.oldLineNumber > 0 - ? -1 - : diffLine.newLineNumber; - - if (lineNumber < 0) { - continue; + } catch (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); } - - const range = new vscode.Range(new vscode.Position(lineNumber, 0), new vscode.Position(lineNumber, 0)); - - ret.push({ - threadId: String(firstComment.id), - uri: document.uri, - range, - comments, - collapsibleState: vscode.CommentThreadCollapsibleState.Expanded - }); + throw e; } - - return ret; } - private findMatchedFileChangeForReviewDiffView(fileChanges: (GitFileChangeNode | RemoteFileChangeNode)[], uri: vscode.Uri): GitFileChangeNode | undefined { - const query = fromReviewUri(uri); - const matchedFiles = fileChanges.filter(fileChange => { - if (fileChange instanceof RemoteFileChangeNode) { + private findMatchedFileChangeForReviewDiffView( + fileChanges: (GitFileChangeNode | RemoteFileChangeNode)[], + uri: vscode.Uri, + ): GitFileChangeNode | undefined { + const query = fromReviewUri(uri.query); + const matchedFiles = fileChanges.filter(fileChangeNode => { + const fileChange = fileChangeNode.changeModel; + if (fileChange instanceof RemoteFileChangeModel) { return false; } if (fileChange.fileName !== query.path) { - return false; + if (!((fileChange.change instanceof InMemFileChange) && fileChange.change.previousFileName === query.path)) { + return false; + } } if (fileChange.filePath.scheme !== 'review') { @@ -661,21 +647,17 @@ export class ReviewCommentController implements vscode.Disposable, CommentHandle } } - try { - const q = JSON.parse(fileChange.filePath.query); + const q = fileChange.filePath.query ? JSON.parse(fileChange.filePath.query) : undefined; - if (q.commit === query.commit) { - return true; - } - } catch (e) { } + if (q && (q.commit === query.commit)) { + return true; + } - try { - const q = JSON.parse(fileChange.parentFilePath.query); + const parentQ = fileChange.parentFilePath.query ? JSON.parse(fileChange.parentFilePath.query) : undefined; - if (q.commit === query.commit) { - return true; - } - } catch (e) { } + if (parentQ && (parentQ.commit === query.commit)) { + return true; + } return false; }); @@ -683,67 +665,65 @@ export class ReviewCommentController implements vscode.Disposable, CommentHandle if (matchedFiles && matchedFiles.length) { return matchedFiles[0] as GitFileChangeNode; } + return undefined; } - private findMatchedFileByUri(uri: vscode.Uri): GitFileChangeNode | undefined { - let fileName: string; - let isOutdated = false; - if (uri.scheme === 'review') { - const query = fromReviewUri(uri); - isOutdated = query.isOutdated; - fileName = query.path; - } - - if (uri.scheme === 'pr') { - fileName = fromPRUri(uri)!.fileName; - } - - const fileChangesToSearch = isOutdated ? this._obsoleteFileChanges : this._localFileChanges; - const matchedFiles = gitFileChangeNodeFilter(fileChangesToSearch).filter(fileChange => { - if (uri.scheme === 'review' || uri.scheme === 'pr') { - return fileChange.fileName === fileName; - } else { - return fileChange.filePath.path === uri.path; - } - }); + // #endregion - if (matchedFiles && matchedFiles.length) { - return matchedFiles[0]; + // #region Review + private getCommentSide(thread: GHPRCommentThread): DiffSide { + if (thread.uri.scheme === Schemes.Review) { + const query = fromReviewUri(thread.uri.query); + return query.base ? DiffSide.LEFT : DiffSide.RIGHT; } - } - 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, '/'); + return DiffSide.RIGHT; } - // #endregion - - // #region Review public async startReview(thread: GHPRCommentThread, input: string): Promise<void> { - const temporaryCommentId = this.optimisticallyAddComment(thread, input, true); - + const hasExistingComments = thread.comments.length; + let temporaryCommentId: number | undefined = undefined; try { - const matchedFile = this.findMatchedFileByUri(thread.uri); - if (!matchedFile) { - throw new Error('No matching file found'); - } - - const position = await this.getNewCommentPosition(thread, matchedFile); - const comment = await this._reposManager.activePullRequest!.startReview( - { - body: input, - path: matchedFile.fileName, - position + temporaryCommentId = await this.optimisticallyAddComment(thread, input, true); + if (!hasExistingComments) { + 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 | 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++; } - ); - thread.threadId = comment.id.toString(); - this.addToCommentThreadCache(thread); - this.replaceTemporaryComment(thread, comment, temporaryCommentId); - await this.updateWithNewComment(comment, matchedFile); + 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._folderRepoManager.activePullRequest!.createCommentReply( + input, + comment.rawComment.graphNodeId, + false, + ); + } else { + throw new Error('Cannot reply to temporary comment'); + } + } } 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) { @@ -755,77 +735,17 @@ export class ReviewCommentController implements vscode.Disposable, CommentHandle } } - public async finishReview(thread: GHPRCommentThread, input: string): Promise<void> { - try { - await this.createOrReplyComment(thread, input); - await this._reposManager.activePullRequest!.submitReview(); - } catch (e) { - vscode.window.showErrorMessage(`Failed to submit the review: ${e}`); + public async openReview(): Promise<void> { + await this._reviewManager.openDescription(); + const pr = this._folderRepoManager.activePullRequest; + if (pr) { + PullRequestOverviewPanel.scrollToReview(pr.remote.owner, pr.remote.repositoryName, pr.number); } } - private getUpdatedThreads(originalCommentThreads: GHPRCommentThread[], deletedReviewComments: IComment[]): GHPRCommentThread[] { - const threads: GHPRCommentThread[] = []; - originalCommentThreads.forEach(thread => { - thread.comments = thread.comments.filter((comment: GHPRComment) => !deletedReviewComments.some(deletedComment => deletedComment.id.toString() === comment.commentId)); - updateCommentThreadLabel(thread); - if (!thread.comments.length) { - thread.dispose!(); - } else { - threads.push(thread); - } - }); - - return threads; - } - - async deleteReview(): Promise<void> { - const { deletedReviewComments } = await this._reposManager.activePullRequest!.deleteReview(); - - [this._workspaceFileChangeCommentThreads, this._obsoleteFileChangeCommentThreads].forEach(commentThreadMap => { - for (const fileName in commentThreadMap) { - const updatedThreads = this.getUpdatedThreads(commentThreadMap[fileName], deletedReviewComments); - - if (updatedThreads.length) { - commentThreadMap[fileName] = updatedThreads; - } else { - delete commentThreadMap[fileName]; - } - } - }); - - this._reviewDocumentCommentThreads.getDocuments().forEach(fileName => { - const originalDocumentThreads = this._reviewDocumentCommentThreads.getThreadsForDocument(fileName, true); - if (originalDocumentThreads) { - const updatedThreads = this.getUpdatedThreads(originalDocumentThreads, deletedReviewComments); - this._reviewDocumentCommentThreads.setDocumentThreads(fileName, true, updatedThreads.length ? updatedThreads : undefined); - } - - const modifiedDocumentThreads = this._reviewDocumentCommentThreads.getThreadsForDocument(fileName, false); - if (modifiedDocumentThreads) { - const updatedThreads = this.getUpdatedThreads(modifiedDocumentThreads, deletedReviewComments); - this._reviewDocumentCommentThreads.setDocumentThreads(fileName, false, updatedThreads.length ? updatedThreads : undefined); - } - }); - - this._prDocumentCommentThreads.getDocuments().forEach(fileName => { - const originalDocumentThreads = this._prDocumentCommentThreads.getThreadsForDocument(fileName, true); - if (originalDocumentThreads) { - const updatedThreads = this.getUpdatedThreads(originalDocumentThreads, deletedReviewComments); - this._prDocumentCommentThreads.setDocumentThreads(fileName, true, updatedThreads.length ? updatedThreads : undefined); - } - - const modifiedDocumentThreads = this._prDocumentCommentThreads.getThreadsForDocument(fileName, false); - if (modifiedDocumentThreads) { - const updatedThreads = this.getUpdatedThreads(modifiedDocumentThreads, deletedReviewComments); - this._prDocumentCommentThreads.setDocumentThreads(fileName, false, updatedThreads.length ? updatedThreads : undefined); - } - }); - } - // #endregion - private optimisticallyAddComment(thread: GHPRCommentThread, input: string, inDraft: boolean): number { - const currentUser = this._reposManager.getCurrentUser(this._reposManager.activePullRequest!); + private async optimisticallyAddComment(thread: GHPRCommentThread, input: string, inDraft: boolean): Promise<number> { + const currentUser = await this._folderRepoManager.getCurrentUser(); const comment = new TemporaryComment(thread, input, inDraft, currentUser); this.updateCommentThreadComments(thread, [...thread.comments, comment]); return comment.id; @@ -836,9 +756,15 @@ export class ReviewCommentController implements vscode.Disposable, CommentHandle updateCommentThreadLabel(thread); } - private optimisticallyEditComment(thread: GHPRCommentThread, comment: GHPRComment): number { - const currentUser = this._reposManager.getCurrentUser(this._reposManager.activePullRequest!); - const temporaryComment = new TemporaryComment(thread, comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, !!comment.label, currentUser, comment); + private async optimisticallyEditComment(thread: GHPRCommentThread, comment: GHPRComment): Promise<number> { + const currentUser = await this._folderRepoManager.getCurrentUser(); + const temporaryComment = new TemporaryComment( + thread, + comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, + !!comment.label, + currentUser, + comment, + ); thread.comments = thread.comments.map(c => { if (c instanceof GHPRComment && c.commentId === comment.commentId) { return temporaryComment; @@ -850,54 +776,84 @@ export class ReviewCommentController implements vscode.Disposable, CommentHandle return temporaryComment.id; } - private replaceTemporaryComment(thread: GHPRCommentThread, realComment: IComment, temporaryCommentId: number): void { - thread.comments = thread.comments.map(c => { - if (c instanceof TemporaryComment && c.id === temporaryCommentId) { - return new GHPRComment(realComment, thread); - } - - return c; - }); - } - - private async updateWithNewComment(comment: IComment, matchedFile: GitFileChangeNode): Promise<void> { - matchedFile.update(matchedFile.comments.concat(comment)); - this._comments.push(comment); - - await this.update(this._localFileChanges, this._obsoleteFileChanges); - this._onDidChangeComments.fire(this._comments); - } - // #region Comment - async createOrReplyComment(thread: GHPRCommentThread, input: string, inDraft?: boolean): Promise<void> { + async createOrReplyComment( + thread: GHPRCommentThread, + input: string, + isSingleComment: boolean, + inDraft?: boolean, + ): Promise<void> { + if (!this._folderRepoManager.activePullRequest) { + throw new Error('Cannot create comment without an active pull request.'); + } + const hasExistingComments = thread.comments.length; - const isDraft = inDraft !== undefined ? inDraft : this._reposManager.activePullRequest!.hasPendingReview; - const temporaryCommentId = this.optimisticallyAddComment(thread, input, isDraft); + const isDraft = isSingleComment + ? false + : inDraft !== undefined + ? inDraft + : this._folderRepoManager.activePullRequest.hasPendingReview; + const temporaryCommentId = await this.optimisticallyAddComment(thread, input, isDraft); try { - const matchedFile = this.findMatchedFileByUri(thread.uri); - if (!matchedFile) { - throw new Error(`Cannot find document ${thread.uri.toString()}`); - } - - let rawComment: IComment | undefined; if (!hasExistingComments) { - rawComment = await this.createNewThread(thread, matchedFile, input); - thread.threadId = rawComment!.id.toString(); - this.addToCommentThreadCache(thread); + 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 | 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 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) { - rawComment = await this._reposManager.activePullRequest!.createReviewCommentReply(input, comment._rawComment); + await this._folderRepoManager.activePullRequest.createCommentReply( + input, + comment.rawComment.graphNodeId, + isSingleComment, + ); } else { throw new Error('Cannot reply to temporary comment'); } } - this.replaceTemporaryComment(thread, rawComment!, temporaryCommentId); - this.updateWithNewComment(rawComment!, matchedFile); + if (isSingleComment) { + await this._folderRepoManager.activePullRequest.submitReview(); + } } catch (e) { - vscode.window.showErrorMessage(`Creating comment failed: ${e}`); + 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._reviewManager.updateComments(); + } + }); + } else { + vscode.window.showErrorMessage(`Creating comment failed: ${e}`); + } thread.comments = thread.comments.map(c => { if (c instanceof TemporaryComment && c.id === temporaryCommentId) { @@ -909,78 +865,94 @@ export class ReviewCommentController implements vscode.Disposable, CommentHandle } } - async editComment(thread: GHPRCommentThread, comment: GHPRComment | TemporaryComment): Promise<void> { - if (comment instanceof GHPRComment) { - const temporaryCommentId = this.optimisticallyEditComment(thread, comment); - try { - if (!this._reposManager.activePullRequest) { - throw new Error('Unable to find active pull request'); - } + async createSuggestionsFromChanges(file: vscode.Uri, suggestionInformation: SuggestionInformation): Promise<void> { + const activePr = this._folderRepoManager.activePullRequest; + if (!activePr) { + return; + } - const matchedFile = this.findMatchedFileByUri(thread.uri); - if (!matchedFile) { - throw new Error('Unable to find matching file'); - } + 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, + ); + } - const editedComment = await this._reposManager.activePullRequest.editReviewComment(comment._rawComment, comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body); + private async createCommentOnResolve(thread: GHPRCommentThread, input: string): Promise<void> { + if (!this._folderRepoManager.activePullRequest) { + throw new Error('Cannot create comment on resolve without an active pull request.'); + } + const pendingReviewId = await this._folderRepoManager.activePullRequest.getPendingReviewId(); + await this.createOrReplyComment(thread, input, !pendingReviewId); + } - // Update the cached comments of the file - const matchingCommentIndex = matchedFile.comments.findIndex(c => String(c.id) === comment.commentId); - if (matchingCommentIndex > -1) { - matchedFile.comments.splice(matchingCommentIndex, 1, editedComment); - } + async resolveReviewThread(thread: GHPRCommentThread, input?: string): Promise<void> { + try { + if (input) { + await this.createCommentOnResolve(thread, input); + } + + await this._folderRepoManager.activePullRequest!.resolveReviewThread(thread.gitHubThreadId); + } catch (e) { + vscode.window.showErrorMessage(`Resolving conversation failed: ${e}`); + } + } + + async unresolveReviewThread(thread: GHPRCommentThread, input?: string): Promise<void> { + try { + if (input) { + await this.createCommentOnResolve(thread, input); + } + + await this._folderRepoManager.activePullRequest!.unresolveReviewThread(thread.gitHubThreadId); + } catch (e) { + vscode.window.showErrorMessage(`Unresolving conversation failed: ${e}`); + } + } - // Also update this._comments - const indexInAllComments = this._comments.findIndex(c => String(c.id) === comment.commentId); - if (indexInAllComments > -1) { - this._comments.splice(indexInAllComments, 1, editedComment); + async editComment(thread: GHPRCommentThread, comment: GHPRComment): Promise<void> { + if (comment instanceof GHPRComment) { + const temporaryCommentId = await this.optimisticallyEditComment(thread, comment); + try { + if (!this._folderRepoManager.activePullRequest) { + throw new Error('Unable to find active pull request'); } - this.replaceTemporaryComment(thread, editedComment!, temporaryCommentId); - updateCommentThreadLabel(thread); + await this._folderRepoManager.activePullRequest.editReviewComment( + comment.rawComment, + comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, + ); } catch (e) { vscode.window.showErrorMessage(formatError(e)); 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); } } 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'); } - const matchedFile = this.findMatchedFileByUri(thread.uri); - if (!matchedFile) { - throw new Error('Unable to find matching file'); - } - if (comment instanceof GHPRComment) { - await this._reposManager.activePullRequest.deleteReviewComment(comment.commentId); - const matchingCommentIndex = matchedFile.comments.findIndex(c => c.id.toString() === comment.commentId); - if (matchingCommentIndex > -1) { - matchedFile.comments.splice(matchingCommentIndex, 1); - matchedFile.update(matchedFile.comments); - } - - const indexInAllComments = this._comments.findIndex(c => c.id.toString() === comment.commentId); - if (indexInAllComments > -1) { - this._comments.splice(indexInAllComments, 1); - } - - thread.comments = thread.comments.filter(c => c instanceof GHPRComment && c.commentId !== comment.commentId); + await this._folderRepoManager.activePullRequest.deleteReviewComment(comment.commentId); } else { - thread.comments = thread.comments.filter(c => c instanceof TemporaryComment && c.id === comment.id); + thread.comments = thread.comments.filter(c => !(c instanceof TemporaryComment && c.id === comment.id)); } if (thread.comments.length === 0) { @@ -989,13 +961,12 @@ export class ReviewCommentController implements vscode.Disposable, CommentHandle 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(this._localFileChanges, this._obsoleteFileChanges); - + this.update(); } catch (e) { throw new Error(formatError(e)); } @@ -1004,133 +975,64 @@ export class ReviewCommentController implements vscode.Disposable, CommentHandle // #endregion // #region Incremental update comments - public async update(localFileChanges: GitFileChangeNode[], obsoleteFileChanges: (GitFileChangeNode | RemoteFileChangeNode)[]): Promise<void> { - await this._reposManager.activePullRequest!.validateDraftMode(); - // _workspaceFileChangeCommentThreads - for (const fileName in this._workspaceFileChangeCommentThreads) { - this.updateFileChangeCommentThreads(localFileChanges, fileName, false); - } - - this._localFileChanges = localFileChanges; - - // _obsoleteFileChangeCommentThreads - for (const fileName in this._obsoleteFileChangeCommentThreads) { - this.updateFileChangeCommentThreads(gitFileChangeNodeFilter(obsoleteFileChanges), fileName, true); - } - - this._obsoleteFileChanges = obsoleteFileChanges; - - // for pr and review document comments, as we dispose them when the editor is being closed, we only need to update for visible editors. - for (const editor of vscode.window.visibleTextEditors) { - await this.updateCommentThreadsForEditor(editor); - } - } - - private async updateFileChangeCommentThreads(fileChanges: GitFileChangeNode[], fileName: string, forOutdated: boolean): Promise<void> { - const matchedFile = fileChanges.find(fileChange => fileChange.fileName === fileName); - - if (!matchedFile) { - this._workspaceFileChangeCommentThreads[fileName].forEach(thread => thread.dispose()); - delete this._workspaceFileChangeCommentThreads[fileName]; - } else { - const existingCommentThreads = forOutdated - ? this._obsoleteFileChangeCommentThreads[fileName] - : this._workspaceFileChangeCommentThreads[fileName]; - - const newThreads = forOutdated - ? this.outdatedCommentsToCommentThreads(matchedFile, matchedFile.comments, vscode.CommentThreadCollapsibleState.Expanded) - : await this.getWorkspaceFileThreadDatas(matchedFile); - - const resultThreads: GHPRCommentThread[] = []; - - newThreads.forEach(thread => { - const matchedThread = existingCommentThreads.find(existingThread => existingThread.threadId === thread.threadId); - - if (matchedThread) { - // update - resultThreads.push(matchedThread); - matchedThread.range = thread.range; - matchedThread.comments = thread.comments.map(comment => { - return new GHPRComment(comment, matchedThread); - }); - updateCommentThreadLabel(matchedThread); - } else { - // create new thread - resultThreads.push(createVSCodeCommentThread(thread, this._commentController!)); - } - }); - - existingCommentThreads.forEach(existingThread => { - const matchedThread = newThreads.filter(thread => thread.threadId === existingThread.threadId); - - if (!matchedThread) { - existingThread.dispose(); - } - }); - - this._workspaceFileChangeCommentThreads[fileName] = resultThreads; - } + public async update(): Promise<void> { + 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'); } - const matchedFile = this.findMatchedFileByUri(comment.parent!.uri); - if (!matchedFile) { - throw new Error('Unable to find matching file'); - } - - let reactionGroups: ReactionGroup[] = []; - if (comment.reactions && !comment.reactions.find(ret => ret.label === reaction.label && !!ret.authorHasReacted)) { - const result = await this._reposManager.activePullRequest.addCommentReaction(comment._rawComment.graphNodeId, reaction); - reactionGroups = result.addReaction.subject.reactionGroups; + if ( + comment.reactions && + !comment.reactions.find(ret => ret.label === reaction.label && !!ret.authorHasReacted) + ) { + await this._folderRepoManager.activePullRequest.addCommentReaction( + comment.rawComment.graphNodeId, + reaction, + ); } else { - const result = await this._reposManager.activePullRequest.deleteCommentReaction(comment._rawComment.graphNodeId, reaction); - reactionGroups = result.removeReaction.subject.reactionGroups; - } - - // Update the cached comments of the file - const matchingCommentIndex = matchedFile.comments.findIndex(c => c.id.toString() === comment.commentId); - if (matchingCommentIndex > -1) { - const editedComment = matchedFile.comments[matchingCommentIndex]; - editedComment.reactions = parseGraphQLReaction(reactionGroups); - const vscodeCommentReactions = generateCommentReactions(editedComment.reactions); - const fileName = matchedFile.fileName; - const modifiedThreads = [ - ...(this._prDocumentCommentThreads.getAllThreadsForDocument(fileName) || []), - ...(this._reviewDocumentCommentThreads.getAllThreadsForDocument(fileName) || []), - ...(this._workspaceFileChangeCommentThreads[fileName] || []), - ...(this._obsoleteFileChangeCommentThreads[fileName] || []) - ].filter(td => !!td.comments.find((cmt: GHPRComment) => cmt.commentId === comment.commentId)); - - modifiedThreads.forEach(thread => { - thread.comments = thread.comments.map((cmt: GHPRComment) => { - if (cmt.commentId === comment.commentId) { - cmt.reactions = vscodeCommentReactions; - } - - return cmt; - }); - }); + 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 - public dispose() { - if (this._commentController) { - this._commentController.dispose(); + + async applySuggestion(comment: GHPRComment) { + const range = comment.parent.range; + const suggestion = comment.suggestion; + if ((suggestion === undefined) || !range) { + throw new Error('Comment doesn\'t contain a suggestion'); } - unregisterCommentHandler(this._commentHandlerId); + 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, editor.document.lineAt(range.end.line).range.end), suggestion); + }); + } - this._localToDispose.forEach(d => d.dispose()); + public override dispose() { + super.dispose(); + unregisterCommentHandler(this._commentHandlerId); } } diff --git a/src/view/reviewManager.ts b/src/view/reviewManager.ts index c8df3f18ec..72b6245075 100644 --- a/src/view/reviewManager.ts +++ b/src/view/reviewManager.ts @@ -5,45 +5,107 @@ import * as nodePath from 'path'; import * as vscode from 'vscode'; -import { parseDiff, parsePatch, DiffHunk } from '../common/diffHunk'; -import { toReviewUri, fromReviewUri } from '../common/uri'; -import { groupBy, formatError } from '../common/utils'; -import { IComment } from '../common/comment'; -import { GitChangeType, InMemFileChange, SlimFileChange } from '../common/file'; -import { Repository, GitErrorCodes, Branch } from '../api/api'; +import type { Branch, Change, Repository } from '../api/api'; +import { GitApiImpl, GitErrorCodes, Status } from '../api/api1'; +import { openDescription } from '../commands'; +import { CreatePullRequestHelper } from './createPullRequestHelper'; +import { GitFileChangeModel, InMemFileChangeModel, RemoteFileChangeModel } from './fileChangeModel'; +import { getInMemPRFileSystemProvider, provideDocumentContentForChangeModel } from './inMemPRContentProvider'; import { PullRequestChangesTreeDataProvider } from './prChangesTreeDataProvider'; -import { DiffChangeType } from '../common/diffHunk'; -import { GitFileChangeNode, RemoteFileChangeNode, gitFileChangeNodeFilter } from './treeNodes/fileChangeNode'; +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 { Remote, parseRepositoryRemotes } from '../common/remote'; -import { RemoteQuickPickItem, PullRequestTitleSourceQuickPick, PullRequestTitleSource, PullRequestTitleSourceEnum, PullRequestDescriptionSourceQuickPick, PullRequestDescriptionSource, PullRequestDescriptionSourceEnum } from './quickpick'; -import { FolderRepositoryManager, titleAndBodyFrom } from '../github/folderRepositoryManager'; -import { PullRequestModel, IResolvedPullRequestModel } from '../github/pullRequestModel'; -import { ReviewCommentController } from './reviewCommentController'; +import { parseRepositoryRemotes, Remote } from '../common/remote'; +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 } 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 { 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 static readonly MIN_POLL_INTERVAL_MS = 1000 * 60 * 5; + private static readonly MAX_POLL_INTERVAL_MS = 1000 * 60 * 30; + private static readonly POLL_BACKOFF_MULTIPLIER = 2; + private readonly _localToDispose: vscode.Disposable[] = []; - private _comments: IComment[] = []; - private _localFileChanges: (GitFileChangeNode)[] = []; - private _obsoleteFileChanges: (GitFileChangeNode | RemoteFileChangeNode)[] = []; + private readonly _reviewModel: ReviewModel = new ReviewModel(); private _lastCommitSha?: string; - private _updateMessageShown: boolean = false; private _validateStatusInProgress?: Promise<void>; - private _reviewCommentController: ReviewCommentController; + private _reviewCommentController: ReviewCommentController | undefined; + private _quickDiffProvider: vscode.Disposable | undefined; + private _inMemGitHubContentProvider: vscode.Disposable | undefined; private _statusBarItem: vscode.StatusBarItem; private _prNumber?: number; + private _isShowingLastReviewChanges: boolean = false; private _previousRepositoryState: { HEAD: Branch | undefined; remotes: Remote[]; }; private _switchingToReviewMode: boolean; + private _stateValidated: boolean = false; + private _changesSinceLastReviewProgress: ProgressHelper = new ProgressHelper(); + /** + * Cached max PR numbers per repo, keyed by `owner/repo`. + * Used to skip expensive `checkGitHubForPrBranch` calls when no new PRs + * have been created since the last check for the current branch. + */ + private _cachedMaxPRNumbers: Map<string, number> | undefined; + private _cachedBranchName: string | undefined; + /** + * Tracks branches for which we've already performed the one-shot GitHub + * re-check after detecting existing local PR metadata. This allows stale + * `branch.<name>.github-pr-owner-number` entries (e.g. pointing at a + * closed PR) to self-heal once, without bypassing the + * branch-change/new-PR cache on every subsequent `validateState` call. + */ + private readonly _staleMetadataCheckedBranches = new Set<string>(); + private _pollHandle: NodeJS.Timeout | undefined; + private _pollIntervalMs = ReviewManager.MIN_POLL_INTERVAL_MS; + /** + * Flag set when the "Checkout" action is used and cleared on the next git + * state update, once review mode has been entered. Used to disambiguate + * 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; @@ -52,32 +114,62 @@ export class ReviewManager { public set switchingToReviewMode(newState: boolean) { this._switchingToReviewMode = newState; if (!newState) { - this.updateState(); + this.updateState(true); } } + private _isFirstLoad = true; + constructor( - private _repository: Repository, + private _id: number, + private _context: vscode.ExtensionContext, + private readonly _repository: Repository, private _folderRepoManager: FolderRepositoryManager, private _telemetry: ITelemetry, - public changesInPrDataProvider: PullRequestChangesTreeDataProvider + public changesInPrDataProvider: PullRequestChangesTreeDataProvider, + private _pullRequestsTree: PullRequestsTreeDataProvider, + private _showPullRequest: ShowPullRequest, + 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) + remotes: parseRepositoryRemotes(this._repository), }; + this._register(toDisposable(() => disposeAll(this._localToDispose))); this.registerListeners(); - this.updateState(); - this.pollForStatusChange(); + if (_gitApi.state === 'initialized') { + this.updateState(true); + } + this.pollForStateChange(); + this._register(vscode.window.onDidChangeWindowState(state => { + if (state.focused && !this._pollHandle) { + // Polling was skipped because the window was not focused. Schedule a + // poll with a randomized delay (jitter) so that in multi-repo setups + // all ReviewManagers don't poll at the same time. Reuse _pollHandle so + // subsequent focus events don't trigger overlapping polls; doPoll + // applies the normal backoff rules. + const jitter = 15_000 + Math.floor(Math.random() * 45_000); // 15-60s + this._pollHandle = setTimeout(() => this.doPoll(), jitter); + } + })); + this._register(toDisposable(() => { + if (this._pollHandle) { + clearTimeout(this._pollHandle); + this._pollHandle = undefined; + } + })); } private registerListeners(): void { - this._disposables.push(this._repository.state.onDidChange(e => { + this._register(this._repository.state.onDidChange(_ => { const oldHead = this._previousRepositoryState.HEAD; const newHead = this._repository.state.HEAD; @@ -86,217 +178,1001 @@ export class ReviewManager { return; } - let sameUpstream; + let sameUpstream: boolean | undefined; 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 && + oldHead.upstream.name === newHead.upstream.name && + oldHead.upstream.remote === newHead.upstream.remote : !newHead.upstream; } - 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 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))); + 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 + remotes: remotes, }; - this.updateState(); + // 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._register(this._folderRepoManager.onDidChangeActivePullRequest(_ => { + this.updateFocusedViewMode(); + this.registerQuickDiff(); })); + + this._register(this._repository.ui.onDidChange(() => { + this._isRepositorySelected = this._repository.ui.selected; + this.updateStatusBarVisibility(); + })); + + this._register(GitHubCreatePullRequestLinkProvider.registerProvider(this, this._folderRepoManager)); + } + + private pollForStateChange() { + if (this._pollHandle) { + clearTimeout(this._pollHandle); + } + this._pollHandle = setTimeout(() => this.doPoll(), this._pollIntervalMs); } + private async doPoll() { + if (this.isDisposed) { + return; + } + this._pollHandle = undefined; + if (!vscode.window.state.focused) { + // Skip polling while the window is not focused. Polling will resume + // immediately when the window regains focus (see onDidChangeWindowState). + Logger.appendLine('Skipping poll: window is not focused', this.id); + return; + } + Logger.appendLine('Polling for state change...', this.id); + let hasDetectedChange = false; + try { + if (!this._validateStatusInProgress) { + const shouldRefreshForActivePr = !!this._folderRepoManager.activePullRequest && await this.hasNewPullRequests(); + if (shouldRefreshForActivePr) { + Logger.appendLine('Polling detected potential new PR activity while a PR is active. Refreshing state.', this.id); + } + + if (!this._folderRepoManager.activePullRequest || shouldRefreshForActivePr) { + const previousPrNumber = this._prNumber; + const previousLastCommitSha = this._lastCommitSha; + await this.updateState(true, false); + hasDetectedChange = + previousPrNumber !== this._prNumber || + previousLastCommitSha !== this._lastCommitSha; + } + } + + if (hasDetectedChange) { + this._pollIntervalMs = ReviewManager.MIN_POLL_INTERVAL_MS; + Logger.appendLine(`Polling detected change. Next poll in ${Math.round(this._pollIntervalMs / 1000)}s.`, this.id); + } else { + this._pollIntervalMs = Math.min( + ReviewManager.MAX_POLL_INTERVAL_MS, + this._pollIntervalMs * ReviewManager.POLL_BACKOFF_MULTIPLIER, + ); + Logger.appendLine(`Polling found no change. Backing off to ${Math.round(this._pollIntervalMs / 1000)}s.`, this.id); + } + } catch (e) { + Logger.warn(`Polling for state change failed: ${formatError(e)}`, this.id); + } finally { + if (!this.isDisposed) { + this.pollForStateChange(); + } + } + } + + 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(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-pr', label, this.repository.rootUri)); + } + } + + get statusBarItem() { if (!this._statusBarItem) { - this._statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); + this._statusBarItem = vscode.window.createStatusBarItem('github.pullrequest.status', vscode.StatusBarAlignment.Left); + this._statusBarItem.name = vscode.l10n.t('GitHub Active Pull Request'); } return this._statusBarItem; } - get repository(): Repository { - return this._repository; + /** + * 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(); + } + } } - get localFileChanges(): GitFileChangeNode[] { - return this._localFileChanges; + /** + * Shows the status bar item only if this repository is currently selected. + */ + private showStatusBarIfSelected() { + if (this._isRepositorySelected) { + this.statusBarItem.show(); + } } - setRepository(repository: Repository, silent: boolean) { - this._repository = repository; - this.updateState(silent); + get repository(): Repository { + return this._repository; } - private pollForStatusChange() { - setTimeout(async () => { - if (!this._validateStatusInProgress) { - await this.updateComments(); - } - this.pollForStatusChange(); - }, 1000 * 60 * 5); + get reviewModel() { + return this._reviewModel; + } + + private get id(): string { + return `${ReviewManager.ID}+${this._id}`; } - public async updateState(silent: boolean = false) { + public async updateState(silent: boolean = false, updateLayout: boolean = true) { if (this.switchingToReviewMode) { return; } if (!this._validateStatusInProgress) { - Logger.appendLine('Review> Validate state in progress'); - this._validateStatusInProgress = this.validateState(silent); + Logger.appendLine('Validate state in progress', this.id); + this._validateStatusInProgress = this.validateStatusAndSetContext(silent, updateLayout); return this._validateStatusInProgress; } else { - Logger.appendLine('Review> Queuing additional validate state'); + Logger.appendLine('Queuing additional validate state', this.id); this._validateStatusInProgress = this._validateStatusInProgress.then(async _ => { - return await this.validateState(silent); + return await this.validateStatusAndSetContext(silent, updateLayout); }); return this._validateStatusInProgress; } } - private async validateState(silent: boolean) { - Logger.appendLine('Review> Validating state...'); - await this._folderRepoManager.updateRepositories(silent); - if (silent) { + 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(() => { + const done = () => { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + resolve(); + }; + if (!this._stateValidated) { + this._stateValidated = true; + commands.setContext('github:stateValidated', true).then(done); + } else { + done(); + } + }); + }); + + return Promise.race([validatePromise, timeoutPromise]); + } + + private async offerIgnoreBranch(currentBranchName): Promise<boolean> { + const ignoreBranchStateKey = 'githubPullRequest.showOfferIgnoreBranch'; + const showOffer = this._context.workspaceState.get(ignoreBranchStateKey, true); + if (!showOffer) { + return false; + } + // Only show once per day. + const lastOfferTimeKey = 'githubPullRequest.offerIgnoreBranchTime'; + const lastOfferTime = this._context.workspaceState.get<number>(lastOfferTimeKey, 0); + const currentTime = new Date().getTime(); + if ((currentTime - lastOfferTime) < (1000 * 60 * 60 * 24)) { // 1 day + return false; + } + const { base } = await this._folderRepoManager.getPullRequestDefaults(currentBranchName); + if (base !== currentBranchName) { + return false; + } + await this._context.workspaceState.update(lastOfferTimeKey, currentTime); + const ignore = vscode.l10n.t('Ignore Pull Request'); + const dontShow = vscode.l10n.t('Don\'t Show Again'); + const offerResult = await vscode.window.showInformationMessage( + vscode.l10n.t(`There\'s a pull request associated with the default branch '{0}'. Do you want to ignore this Pull Request?`, currentBranchName), + ignore, + dontShow); + if (offerResult === ignore) { + 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); + return true; + } else if (offerResult === dontShow) { + await this._context.workspaceState.update(ignoreBranchStateKey, false); + return false; + } + return false; + } + + 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 }; + } + } + } + + /** + * Returns the GitHub repos relevant to the current branch: the head repo + * (matching the branch's remote) and its parent repos (for forks, the upstream). + */ + private async getRelevantGitHubRepos(): Promise<GitHubRepository[]> { + const remoteName = this._repository.state.HEAD?.remote ?? this._repository.state.HEAD?.upstream?.remote; + if (!remoteName) { + return []; + } + const headRepo = this._folderRepoManager.gitHubRepositories.find( + repo => repo.remote.remoteName === remoteName, + ); + if (!headRepo) { + return []; + } + try { + const metadata = await headRepo.getMetadata(); + if (!metadata?.owner) { + return [headRepo]; + } + const parentRepos = this._folderRepoManager.gitHubRepositories.filter(repo => { + if (metadata.fork) { + return repo.remote.owner === metadata.parent?.owner?.login && repo.remote.repositoryName === metadata.parent?.name; + } else { + return repo.remote.owner === metadata.owner?.login && repo.remote.repositoryName === metadata.name; + } + }); + // Include both head and parent repos, deduplicated + const result = new Set<GitHubRepository>(parentRepos); + result.add(headRepo); + return [...result]; + } catch (e) { + Logger.warn(`Failed to get metadata for relevant repos: ${formatError(e)}`, this.id); + return [headRepo]; + } + } + + private async getMaxPullRequestNumbers(): Promise<Map<string, number>> { + const result = new Map<string, number>(); + const repos = await this.getRelevantGitHubRepos(); + await Promise.all(repos.map(async repo => { + const max = await repo.getMaxPullRequest(); + if (max !== undefined) { + result.set(`${repo.remote.owner}/${repo.remote.repositoryName}`, max); + } + })); + return result; + } + + private async hasNewPullRequests(): Promise<boolean> { + if (!this._cachedMaxPRNumbers) { + this._cachedMaxPRNumbers = await this.getMaxPullRequestNumbers(); + return true; + } + const current = await this.getMaxPullRequestNumbers(); + // If we couldn't determine max PR for any repo, assume there may be new PRs, + // but keep the last known good cache so transient failures do not discard it. + if (current.size === 0) { + return true; + } + let result = false; + for (const [key, value] of current) { + const cached = this._cachedMaxPRNumbers.get(key); + if (cached === undefined || value > cached) { + result = true; + break; + } + } + this._cachedMaxPRNumbers = current; + return result; + } + + 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; + } + } + + private async resolvePullRequest(metadata: PullRequestMetadata, useCache: boolean): Promise<(PullRequestModel & IResolvedPullRequestModel) | undefined> { + try { + this._prNumber = metadata.prNumber; + + const { owner, repositoryName } = metadata; + Logger.appendLine('Resolving pull request', this.id); + let pr = await this._folderRepoManager.resolvePullRequest(owner, repositoryName, metadata.prNumber, useCache); + + if (!pr || !pr.isResolved() || !(await pr.githubRepository.hasBranch(pr.base.ref))) { + 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); + } + } + + 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...', this.id); + const oldLastCommitSha = this._lastCommitSha; + this._lastCommitSha = undefined; + if (!(await this._folderRepoManager.updateRepositories(false))) { return; } if (!this._repository.state.HEAD) { - this.clear(true); + await this.clear(true); return; } const branch = this._repository.state.HEAD; - const matchingPullRequestMetadata = await this._folderRepoManager.getMatchingPullRequestMetadataForBranch(); + 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; + } + let matchingPullRequestMetadata = await this._folderRepoManager.getMatchingPullRequestMetadataForBranch(); if (!matchingPullRequestMetadata) { - Logger.appendLine(`Review> no matching pull request metadata found for current branch ${this._repository.state.HEAD.name}`); - this.clear(true); + Logger.appendLine(`No matching pull request metadata found locally for current branch ${branch.name}`, this.id); + } + + // One-shot self-heal: when local metadata exists for this branch, re-check GitHub once + // (per branch) in case the local metadata points to a stale closed PR. If GitHub returns + // a result, it overwrites the local metadata via associateBranchWithPullRequest. Subsequent + // checks for the same branch fall back to the branch-change/new-PR cache. + const needsStaleMetadataCheck = !!matchingPullRequestMetadata && !!branch.name && !this._staleMetadataCheckedBranches.has(branch.name); + if (this._cachedBranchName !== branch.name || await this.hasNewPullRequests() || needsStaleMetadataCheck) { + const metadataFromGithub = await this.checkGitHubForPrBranch(branch); + if (metadataFromGithub) { + matchingPullRequestMetadata = metadataFromGithub; + } + if (needsStaleMetadataCheck && branch.name) { + this._staleMetadataCheckedBranches.add(branch.name); + } + } else { + Logger.appendLine(`Skipping GitHub check for branch ${branch.name}: no new PRs since last check`, this.id); + } + this._cachedBranchName = branch.name; + + if (!matchingPullRequestMetadata) { + Logger.appendLine( + `No matching pull request metadata found on GitHub for current branch ${branch.name}`, this.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); - const hasPushedChanges = branch.commit !== this._lastCommitSha && branch.ahead === 0 && branch.behind === 0; - if (this._prNumber === matchingPullRequestMetadata.prNumber && !hasPushedChanges) { - vscode.commands.executeCommand('pr.refreshList'); + // 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}`, this.id + ); + const previousPrNumber = this._prNumber; + // 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); + if (metadataFromGithub && metadataFromGithub?.prNumber !== pr.number) { + const prFromGitHub = await this.resolvePullRequest(metadataFromGithub, false); + if (prFromGitHub) { + pr = prFromGitHub; + } + } + } - const remote = branch.upstream ? branch.upstream.remote : null; - if (!remote) { - Logger.appendLine(`Review> current branch ${this._repository.state.HEAD.name} hasn't setup remote yet`); - this.clear(true); + const hasPushedChanges = branch.commit !== oldLastCommitSha && branch.ahead === 0 && branch.behind === 0; + if (!this.justSwitchedToReviewMode && (previousPrNumber === pr.number) && !hasPushedChanges && (this._isShowingLastReviewChanges === pr.showChangesSinceReview)) { + // No meaningful state change; keep the previous commit SHA so polling + // doesn't treat this as a change on the next cycle. + this._lastCommitSha = oldLastCommitSha; return; } + this._isShowingLastReviewChanges = pr.showChangesSinceReview; + if (previousPrNumber !== pr.number) { + this.clear(false); + } - // we switch to another PR, let's clean up first. - Logger.appendLine(`Review> current branch ${this._repository.state.HEAD.name} is associated with pull request #${matchingPullRequestMetadata.prNumber}`); - this.clear(false); - this._prNumber = matchingPullRequestMetadata.prNumber; - this._lastCommitSha = undefined; + const useReviewConfiguration = getReviewMode(); - const { owner, repositoryName } = matchingPullRequestMetadata; - Logger.appendLine('Review> Resolving pull request'); - const pr = await this._folderRepoManager.resolvePullRequest(owner, repositoryName, matchingPullRequestMetadata.prNumber); - if (!pr || !pr.isResolved()) { - this._prNumber = undefined; - Logger.appendLine('Review> This PR is no longer valid'); + // If this is the PR the user explicitly switched to, always use review mode regardless of state + const isSwitchedToPullRequest = this._switchedToPullRequest?.number === pr.number; + + if (pr.isClosed && !useReviewConfiguration.closed && !isSwitchedToPullRequest) { + Logger.appendLine('This PR is closed', this.id); + await this.clear(true); return; } + if (pr.isMerged && !useReviewConfiguration.merged && !isSwitchedToPullRequest) { + Logger.appendLine('This PR is merged', this.id); + await this.clear(true); + return; + } + + 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; this._lastCommitSha = pr.head.sha; - Logger.appendLine('Review> Fetching pull request data'); - await this.getPullRequestData(pr); - await this.changesInPrDataProvider.addPrToView(this._folderRepoManager, pr, this._localFileChanges, this._comments); + if (this._isFirstLoad) { + this._isFirstLoad = false; + this._folderRepoManager.checkBranchUpToDate(pr, true); + } + + Logger.appendLine('Fetching pull request data', this.id); + if (!silent) { + onceEvent(this._reviewModel.onDidChangeLocalFileChanges)(() => { + if (pr) { + this._upgradePullRequestEditors(pr); + } + }); + } + // Don't await. Events will be fired as part of the initialization. + this.initializePullRequestData(pr); + await this.changesInPrDataProvider.addPrToView( + this._folderRepoManager, + pr, + this._reviewModel, + this.justSwitchedToReviewMode, + this._changesSinceLastReviewProgress + ); - Logger.appendLine(`Review> register comments provider`); + Logger.appendLine(`Register comments provider`, this.id); await this.registerCommentController(); - this.statusBarItem.text = '$(git-branch) Pull Request #' + this._prNumber; - this.statusBarItem.command = { command: 'pr.openDescription', title: 'View Pull Request Description', arguments: [pr] }; - Logger.appendLine(`Review> display pull request status bar indicator and refresh pull request tree view.`); - this.statusBarItem.show(); - vscode.commands.executeCommand('pr.refreshList'); - this._validateStatusInProgress = undefined; + this._activePrViewCoordinator.setPullRequest(pr, this._folderRepoManager, this, previousActive); + this._localToDispose.push( + pr.onDidChangeChangesSinceReview(async _ => { + this._changesSinceLastReviewProgress.startProgress(); + 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 = { + command: 'pr.openDescription', + title: vscode.l10n.t('View Pull Request Description'), + arguments: [pr], + }; + Logger.appendLine(`Display pull request status bar indicator.`, this.id); + this.showStatusBarIfSelected(); + + 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<boolean>(FOCUS_REVIEW_MODE); + + 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}.`, this.id); + if (shouldShow) { + this._doFocusShow(pr, updateLayout); + } + showPRChangedDisposable.dispose(); + }); + this._localToDispose.push(showPRChangedDisposable); + } + } + + private async reopenNewReviewDiffs() { + let hasOpenDiff = false; + await Promise.all(vscode.window.tabGroups.all.map(tabGroup => { + return tabGroup.tabs.map(tab => { + if (tab.input instanceof vscode.TabInputTextDiff) { + if ((tab.input.original.scheme === Schemes.Review)) { + + for (const localChange of this._reviewModel.localFileChanges) { + const fileName = fromReviewUri(tab.input.original.query); + + if (localChange.fileName === fileName.path) { + hasOpenDiff = true; + vscode.window.tabGroups.close(tab).then(_ => localChange.openDiff(this._folderRepoManager, { preview: tab.isPreview })); + break; + } + } + + } + } + return Promise.resolve(undefined); + }); + }).flat()); + + if (!hasOpenDiff && this._reviewModel.localFileChanges.length) { + this._reviewModel.localFileChanges[0].openDiff(this._folderRepoManager, { preview: true }); + } + } + + private openDiff() { + if (this._reviewModel.localFileChanges.length) { + let fileChangeToShow: GitFileChangeNode[] = []; + for (const fileChange of this._reviewModel.localFileChanges) { + if (fileChange.status === GitChangeType.MODIFY) { + if (KnownMediaExtensions.includes(nodePath.extname(fileChange.fileName))) { + fileChangeToShow.push(fileChange); + } else { + fileChangeToShow.unshift(fileChange); + break; + } + } + } + const change = fileChangeToShow.length ? fileChangeToShow[0] : this._reviewModel.localFileChanges[0]; + change.openDiff(this._folderRepoManager); + } + } + + 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>(OPEN_VIEW); + if ((shouldShowCommentsView !== 'never') && (pr.hasComments)) { + commands.executeCommand('workbench.action.focusCommentsPanel'); + } + this._activePrViewCoordinator.show(pr); + if (updateLayout) { + 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') { + 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(); + await Promise.all(vscode.window.tabGroups.all.map(tabGroup => { + return tabGroup.tabs.map(tab => { + if (tab.input instanceof vscode.TabInputTextDiff) { + if ((tab.input.original.scheme === Schemes.Pr) && (tab.input.modified.scheme === Schemes.Pr)) { + const originalParams = fromPRUri(tab.input.original); + const modifiedParams = fromPRUri(tab.input.modified); + if ((originalParams?.prNumber === pullRequest.number) && (modifiedParams?.prNumber === pullRequest.number)) { + reopenFilenames.add([originalParams, modifiedParams]); + return vscode.window.tabGroups.close(tab); + } + } + } + return Promise.resolve(undefined); + }); + }).flat()); + const reopenPromises: Promise<void>[] = []; + if (reopenFilenames.size) { + for (const localChange of this.reviewModel.localFileChanges) { + for (const prFileChange of reopenFilenames) { + if (Array.isArray(prFileChange)) { + const modifiedPrChange = prFileChange[1]; + if (localChange.fileName === modifiedPrChange.fileName) { + reopenPromises.push(localChange.openDiff(this._folderRepoManager, { preview: false })); + reopenFilenames.delete(prFileChange); + break; + } + } + } + } + } + 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) { return; } + if (!branch) { + return; + } const matchingPullRequestMetadata = await this._folderRepoManager.getMatchingPullRequestMetadataForBranch(); - if (!matchingPullRequestMetadata) { return; } + if (!matchingPullRequestMetadata) { + return; + } const remote = branch.upstream ? branch.upstream.remote : null; - if (!remote) { return; } + if (!remote) { + return; + } if (this._prNumber === undefined || !this._folderRepoManager.activePullRequest) { return; } - const pr = await this._folderRepoManager.resolvePullRequest(matchingPullRequestMetadata.owner, matchingPullRequestMetadata.repositoryName, this._prNumber); + const pr = await this._folderRepoManager.resolvePullRequest( + matchingPullRequestMetadata.owner, + matchingPullRequestMetadata.repositoryName, + this._prNumber, + ); if (!pr || !pr.isResolved()) { - Logger.appendLine('Review> This PR is no longer valid'); + Logger.warn('This PR is no longer valid', this.id); return; } - if ((pr.head.sha !== this._lastCommitSha || (branch.behind !== undefined && branch.behind > 0)) && !this._updateMessageShown) { - this._updateMessageShown = true; - const result = await vscode.window.showInformationMessage('There are updates available for this branch.', {}, 'Pull'); - - if (result === 'Pull') { - await vscode.commands.executeCommand('git.pull'); - this._updateMessageShown = false; - } - } + await this._folderRepoManager.checkBranchUpToDate(pr, false); - await this.getPullRequestData(pr); - await this._reviewCommentController.update(this._localFileChanges, this._obsoleteFileChanges); + await this.initializePullRequestData(pr); + await this._reviewCommentController?.update(); return Promise.resolve(void 0); } - private async getLocalChangeNodes(pr: PullRequestModel & IResolvedPullRequestModel, contentChanges: (InMemFileChange | SlimFileChange)[], activeComments: IComment[]): Promise<GitFileChangeNode[]> { + private async getLocalChangeNodes( + pr: PullRequestModel & IResolvedPullRequestModel, + contentChanges: (InMemFileChange | SlimFileChange)[], + ): Promise<GitFileChangeNode[]> { const nodes: GitFileChangeNode[] = []; const mergeBase = pr.mergeBase || pr.base.sha; const headSha = pr.head.sha; for (let i = 0; i < contentChanges.length; i++) { const change = contentChanges[i]; - let diffHunks: DiffHunk[] = []; - - if (change instanceof InMemFileChange) { - diffHunks = change.diffHunks; - } else if (change.status !== GitChangeType.RENAME) { - try { - const patch = await this._repository.diffBetween(pr.base.sha, pr.head.sha, change.fileName); - diffHunks = parsePatch(patch); - } catch (e) { - Logger.appendLine(`Failed to parse patch for outdated comments: ${e}`); - } - } - const filePath = nodePath.join(this._repository.rootUri.path, change.fileName).replace(/\\/g, '/'); const uri = this._repository.rootUri.with({ path: filePath }); - const modifiedFileUri = change.status === GitChangeType.DELETE - ? toReviewUri(uri, undefined, undefined, '', false, { base: false }, this._repository.rootUri) - : uri; + const modifiedFileUri = + change.status === GitChangeType.DELETE + ? toReviewUri(uri, undefined, undefined, '', false, { base: false }, this._repository.rootUri) + : uri; const originalFileUri = toReviewUri( uri, @@ -305,20 +1181,15 @@ export class ReviewManager { change.status === GitChangeType.ADD ? '' : mergeBase, false, { base: true }, - this._repository.rootUri + this._repository.rootUri, ); + const changeModel = new GitFileChangeModel(this._folderRepoManager, pr, change, modifiedFileUri, originalFileUri, headSha, contentChanges.length < 20); const changedItem = new GitFileChangeNode( - this.changesInPrDataProvider.view, + this.changesInPrDataProvider, + this._folderRepoManager, pr, - change.status, - change.fileName, - change.blobUrl, - modifiedFileUri, - originalFileUri, - diffHunks, - activeComments.filter(comment => comment.path === change.fileName), - headSha + changeModel ); nodes.push(changedItem); } @@ -326,512 +1197,364 @@ export class ReviewManager { return nodes; } - private async getPullRequestData(pr: PullRequestModel & IResolvedPullRequestModel): Promise<void> { + private async initializePullRequestData(pr: PullRequestModel & IResolvedPullRequestModel): Promise<void> { try { - this._comments = await pr.getReviewComments(); - const activeComments = this._comments.filter(comment => comment.position); - const outdatedComments = this._comments.filter(comment => !comment.position); - - const data = await pr.getFileChangesInfo(); - const mergeBase = pr.mergeBase || pr.base.sha; - - const contentChanges = await parseDiff(data, this._repository, mergeBase!); - this._localFileChanges = await this.getLocalChangeNodes(pr, contentChanges, activeComments); + const contentChanges = await pr.getFileChangesInfo(); + this._reviewModel.localFileChanges = await this.getLocalChangeNodes(pr, contentChanges); + await Promise.all([pr.initializeReviewThreadCacheAndReviewComments(), pr.initializePullRequestFileViewState()]); + this._folderRepoManager.setFileViewedContext(); + const outdatedComments = pr.comments.filter(comment => comment.isOutdated || !comment.position); const commitsGroup = groupBy(outdatedComments, comment => comment.originalCommitId!); - this._obsoleteFileChanges = []; + const obsoleteFileChanges: (GitFileChangeNode | RemoteFileChangeNode)[] = []; for (const commit in commitsGroup) { const commentsForCommit = commitsGroup[commit]; const commentsForFile = groupBy(commentsForCommit, comment => comment.path!); for (const fileName in commentsForFile) { - - let diffHunks: DiffHunk[] = []; - try { - const patch = await this._repository.diffBetween(pr.base.sha, commit, fileName); - diffHunks = parsePatch(patch); - } catch (e) { - Logger.appendLine(`Failed to parse patch for outdated comments: ${e}`); - } - const oldComments = commentsForFile[fileName]; const uri = vscode.Uri.file(nodePath.join(`commit~${commit.substr(0, 8)}`, fileName)); + const changeModel = new GitFileChangeModel( + this._folderRepoManager, + pr, + { + status: GitChangeType.MODIFY, + fileName, + blobUrl: undefined, + + }, toReviewUri( + uri, + fileName, + undefined, + oldComments[0].originalCommitId!, + true, + { base: false }, + this._repository.rootUri, + ), + toReviewUri( + uri, + fileName, + undefined, + oldComments[0].originalCommitId!, + true, + { base: true }, + this._repository.rootUri, + ), + commit); const obsoleteFileChange = new GitFileChangeNode( - this.changesInPrDataProvider.view, + this.changesInPrDataProvider, + this._folderRepoManager, pr, - GitChangeType.MODIFY, - fileName, - undefined, - toReviewUri(uri, fileName, undefined, oldComments[0].originalCommitId!, true, { base: false }, this._repository.rootUri), - toReviewUri(uri, fileName, undefined, oldComments[0].originalCommitId!, true, { base: true }, this._repository.rootUri), - diffHunks, - oldComments, - commit + changeModel, + false, + oldComments ); - this._obsoleteFileChanges.push(obsoleteFileChange); + obsoleteFileChanges.push(obsoleteFileChange); } } + this._reviewModel.obsoleteFileChanges = obsoleteFileChanges; return Promise.resolve(void 0); } catch (e) { - Logger.appendLine(`Review> ${e}`); + 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); + } } private async registerCommentController() { - this._reviewCommentController = new ReviewCommentController(this._folderRepoManager, - this._repository, - this._localFileChanges, - this._obsoleteFileChanges, - this._comments); + if (this._folderRepoManager.activePullRequest?.reviewThreadsCacheReady && this._reviewModel.hasLocalFileChanges) { + await this.doRegisterCommentController(); + } else { + const changedLocalFilesChangesDisposable: vscode.Disposable | undefined = + this._reviewModel.onDidChangeLocalFileChanges(async () => { + if (this._folderRepoManager.activePullRequest?.reviewThreadsCache && this._reviewModel.hasLocalFileChanges) { + if (changedLocalFilesChangesDisposable) { + changedLocalFilesChangesDisposable.dispose(); + } + await this.doRegisterCommentController(); + } + }); + } + } - await this._reviewCommentController.initialize(); + private async doRegisterCommentController() { + if (!this._reviewCommentController) { + this._reviewCommentController = new ReviewCommentController( + this, + this._folderRepoManager, + this._repository, + this._reviewModel, + this._gitApi, + this._telemetry + ); - this._localToDispose.push(this._reviewCommentController); - this._localToDispose.push(this._reviewCommentController.onDidChangeComments(comments => { - this._comments = comments; - })); + await this._reviewCommentController.initialize(); + } } public async switch(pr: PullRequestModel): Promise<void> { - Logger.appendLine(`Review> switch to Pull Request #${pr.number} - start`); - this.statusBarItem.text = '$(sync~spin) Switching to Review Mode'; + 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 { - const didLocalCheckout = await this._folderRepoManager.checkoutExistingPullRequestBranch(pr); + await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification }, async (progress) => { + const didLocalCheckout = await this._folderRepoManager.checkoutExistingPullRequestBranch(pr, progress); - if (!didLocalCheckout) { - await this._folderRepoManager.fetchAndCheckout(pr); + if (!didLocalCheckout) { + 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.appendLine(`Review> checkout failed #${JSON.stringify(e)}`); + Logger.error(`Checkout failed #${JSON.stringify(e)}`, this.id); this.switchingToReviewMode = false; - if (e.gitErrorCode) { + if (e.message === 'User aborted') { + // The user cancelled the action + } else if (e.gitErrorCode && ( + e.gitErrorCode === GitErrorCodes.LocalChangesOverwritten || + e.gitErrorCode === GitErrorCodes.DirtyWorkTree + )) { // for known git errors, we should provide actions for users to continue. - if (e.gitErrorCode === GitErrorCodes.LocalChangesOverwritten || e.gitErrorCode === GitErrorCodes.DirtyWorkTree) { - vscode.window.showErrorMessage('Your local changes would be overwritten by checkout, please commit your changes or stash them before you switch branches'); - return; - } + 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' + )); + } else if ((e.stderr as string)?.startsWith('fatal: couldn\'t find remote ref') && e.gitCommand === 'fetch') { + // 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(`Error switching to pull request: ${formatError(e)}`); } - - vscode.window.showErrorMessage(formatError(e)); // todo, we should try to recover, for example, git checkout succeeds but set config fails. + if (this._folderRepoManager.activePullRequest?.number === pr.number) { + this.setStatusForPr(this._folderRepoManager.activePullRequest); + } else { + this.statusBarItem.hide(); + } return; } try { - this.statusBarItem.text = `$(sync~spin) Fetching additional data: pr/${pr.number}`; + 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.fullfillPullRequestMissingInfo(pr); + await this._folderRepoManager.fulfillPullRequestMissingInfo(pr); + this._upgradePullRequestEditors(pr); /* __GDPR__ "pr.checkout" : {} */ this._telemetry.sendTelemetryEvent('pr.checkout'); - Logger.appendLine(`Review> switch to Pull Request #${pr.number} - done`, ReviewManager.ID); + Logger.appendLine(`Switch to Pull Request #${pr.number} - done`, this.id); } finally { - this.switchingToReviewMode = false; - this.statusBarItem.text = `Pull Request #${pr.number}`; - this.statusBarItem.command = undefined; - this.statusBarItem.show(); + this.setStatusForPr(pr); await this._repository.status(); } } - public async publishBranch(branch: Branch): Promise<Branch | undefined> { - const potentialTargetRemotes = await this._folderRepoManager.getAllGitHubRemotes(); - const selectedRemote = (await this.getRemote(potentialTargetRemotes, `Pick a remote to publish the branch '${branch.name}' to:`))!.remote; - - if (!selectedRemote || branch.name === undefined) { - return; - } - - return new Promise<Branch | undefined>(async (resolve) => { - const inputBox = vscode.window.createInputBox(); - inputBox.value = branch.name!; - inputBox.ignoreFocusOut = true; - inputBox.prompt = potentialTargetRemotes.length === 1 ? `The branch '${branch.name}' is not published yet, pick a name for the upstream branch` : 'Pick a name for the upstream branch'; - const validate = async function (value: string) { - try { - inputBox.busy = true; - const remoteBranch = await this._reposManager.getBranch(selectedRemote, value); - if (remoteBranch) { - inputBox.validationMessage = `Branch ${value} already exists in ${selectedRemote.owner}/${selectedRemote.repositoryName}`; - } else { - inputBox.validationMessage = undefined; - } - } 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 synatx - // git push -u origin local_branch:remote_branch - await this._repository.push(selectedRemote.remoteName, `${branch.name}:${inputBox.value}`, true); - } catch (err) { - if (err.gitErrorCode === GitErrorCodes.PushRejected) { - vscode.window.showWarningMessage(`Can't push refs to remote, try running 'git pull' first to integrate with your change`, { - modal: true - }); - - resolve(); - } - - if (err.gitErrorCode === GitErrorCodes.RemoteConnectionError) { - vscode.window.showWarningMessage(`Could not read from remote repository '${selectedRemote.remoteName}'. Please make sure you have the correct access rights and the repository exists.`, { - modal: true - }); - - resolve(); - } - - // 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(); - } - - resolve(latestBranch); - }); - - inputBox.show(); - }); - } - - private async getRemote(potentialTargetRemotes: Remote[], placeHolder: string, defaultUpstream?: RemoteQuickPickItem): Promise<RemoteQuickPickItem | undefined> { - if (!potentialTargetRemotes.length) { - vscode.window.showWarningMessage(`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; + private setStatusForPr(pr: PullRequestModel) { + this.switchingToReviewMode = false; + this.justSwitchedToReviewMode = true; + this.statusBarItem.text = vscode.l10n.t('Pull Request #{0}', pr.number); + this.statusBarItem.command = undefined; + this.showStatusBarIfSelected(); } - private async getPullRequestTitleAndDescriptionDefaults(progress: vscode.Progress<{ message?: string, increment?: number }>, pullRequestDescriptionMethod: PullRequestDescriptionSource): Promise<{ title: string, description: string } | undefined> { - let template: vscode.Uri | undefined; - - // Only fetch pull request templates if requested - if (pullRequestDescriptionMethod === PullRequestDescriptionSourceEnum.Template) { - const pullRequestTemplates = await this._folderRepoManager.getPullRequestTemplates(); - - if (pullRequestTemplates.length === 1) { - template = pullRequestTemplates[0]; - progress.report({ increment: 5, message: 'Found pull request template. Creating pull request...' }); + public async createPullRequest(compareBranch?: string): Promise<void> { + const postCreate = async (createdPR: PullRequestModel | undefined) => { + if (!createdPR) { + return; } - if (pullRequestTemplates.length > 1) { - const targetTemplate = await vscode.window.showQuickPick(pullRequestTemplates.map(uri => { - return { - label: vscode.workspace.asRelativePath(uri.path), - uri: uri - }; - }), { - ignoreFocusOut: true, - placeHolder: 'Select the pull request template to use' - }); - - // Treat user pressing escape as cancel - if (!targetTemplate) { - return; + 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) + ]); + } } - - template = targetTemplate.uri; - progress.report({ increment: 5, message: 'Creating pull request...' }); - } - } - - const { title, body } = titleAndBodyFrom(await this._folderRepoManager.getHeadCommitMessage()); - let description = body; - if (template) { - try { - const templateContent = await vscode.workspace.fs.readFile(template); - description = templateContent.toString(); - } catch (e) { - Logger.appendLine(`Reading pull request template failed: ${e}`); } - } - - return { - title, - description + await this.updateState(false, false); }; - } - - private async getPullRequestTitleSetting(): Promise<PullRequestTitleSource | undefined> { - const method = vscode.workspace.getConfiguration('githubPullRequests').get<PullRequestTitleSource>('pullRequestTitle', PullRequestTitleSourceEnum.Ask); - - if (method === PullRequestTitleSourceEnum.Ask) { - const titleSource = await vscode.window.showQuickPick<PullRequestTitleSourceQuickPick>(PullRequestTitleSourceQuickPick.allOptions(), { - ignoreFocusOut: true, - placeHolder: 'Pull Request Title Source' - }); - - if (!titleSource) { - return; - } - - return titleSource.pullRequestTitleSource; - } - return method; + return this._createPullRequestHelper.create(this._telemetry, this._context.extensionUri, this._folderRepoManager, compareBranch, postCreate); } - private async getPullRequestDescriptionSetting(): Promise<PullRequestDescriptionSource | undefined> { - const method = vscode.workspace.getConfiguration('githubPullRequests').get<PullRequestDescriptionSource>('pullRequestDescription', PullRequestDescriptionSourceEnum.Ask); - - if (method === PullRequestDescriptionSourceEnum.Ask) { - const descriptionSource = await vscode.window.showQuickPick<PullRequestDescriptionSourceQuickPick>(PullRequestDescriptionSourceQuickPick.allOptions(), { - ignoreFocusOut: true, - placeHolder: 'Pull Request Description Source' - }); - - if (!descriptionSource) { - return; - } - - return descriptionSource.pullRequestDescriptionSource; - } - - return method; - } - - public async createPullRequest(draft = false): Promise<void> { - const pullRequestDefaults = await this._folderRepoManager.getPullRequestDefaults(); - const githubRemotes = this._folderRepoManager.getGitHubRemotes(); - const targetRemote = await this.getRemote(githubRemotes, 'Select the remote to send the pull request to', - new RemoteQuickPickItem(pullRequestDefaults.owner, pullRequestDefaults.repo, 'Parent Repository') - ); - - if (!targetRemote) { + public async openDescription(): Promise<void> { + const pullRequest = this._folderRepoManager.activePullRequest; + if (!pullRequest) { return; } - const base: string = targetRemote.remote - ? (await this._folderRepoManager.getMetadata(targetRemote.remote.remoteName)).default_branch - : pullRequestDefaults.base; - const target = await vscode.window.showInputBox({ - value: base, - ignoreFocusOut: true, - prompt: `Choose target branch for ${targetRemote.owner}/${targetRemote.name}`, - }); + const descriptionNode = this.changesInPrDataProvider.getDescriptionNode(this._folderRepoManager); + await openDescription( + this._telemetry, + pullRequest, + descriptionNode, + this._folderRepoManager, + true + ); + } - if (!target) { - return; - } + get isCreatingPullRequest() { + return this._createPullRequestHelper?.isCreatingPullRequest ?? false; + } - if (this._repository.state.HEAD === undefined) { - return; + private async updateFocusedViewMode(): Promise<void> { + 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); + } else { + vscode.commands.executeCommand('setContext', FOCUS_REVIEW_MODE, false); + this._context.workspaceState.update(FOCUS_REVIEW_MODE, false); } + } - await vscode.window.withProgress({ - location: vscode.ProgressLocation.Notification, - title: 'Creating Pull Request', - cancellable: false - }, async (progress) => { - progress.report({ increment: 10 }); - let HEAD: Branch | undefined = this._repository.state.HEAD!; - const branchName = HEAD.name; - - if (!HEAD.upstream) { - progress.report({ increment: 10, message: `Start publishing branch ${branchName}` }); - HEAD = await this.publishBranch(HEAD); - if (!HEAD) { - return; - } - progress.report({ increment: 20, message: `Branch ${branchName} published` }); - } else { - progress.report({ increment: 30, message: `Start creating pull request.` }); - - } - - const headRemote = (await this._folderRepoManager.getAllGitHubRemotes()).find(remote => remote.remoteName === HEAD!.upstream!.remote); - if (!headRemote) { - return; - } - - const pullRequestTitleMethod = await this.getPullRequestTitleSetting(); - - // User cancelled the title selection process, cancel the create process - if (!pullRequestTitleMethod) { - return; - } - - const pullRequestDescriptionMethod = await this.getPullRequestDescriptionSetting(); - - // User cancelled the description selection process, cancel the create process - if (!pullRequestDescriptionMethod) { - return; - } - - const titleAndDescriptionDefaults = await this.getPullRequestTitleAndDescriptionDefaults(progress, pullRequestDescriptionMethod); - - // User cancelled a quick input, cancel the create process - if (!titleAndDescriptionDefaults) { - return; - } - - let { title, description } = titleAndDescriptionDefaults; - - switch (pullRequestTitleMethod) { - case PullRequestTitleSourceEnum.Branch: - if (branchName) { - title = branchName; - } - break; - case PullRequestTitleSourceEnum.Custom: - const nameResult = await vscode.window.showInputBox({ - value: title, - ignoreFocusOut: true, - prompt: `Enter PR title`, - validateInput: (value) => value ? null : 'Title can not be empty' - }); - - if (!nameResult) { - return; - } - - title = nameResult; - } - - switch (pullRequestDescriptionMethod) { - case PullRequestDescriptionSourceEnum.Custom: - const descriptionResult = await vscode.window.showInputBox({ - value: description, - ignoreFocusOut: true, - prompt: `Enter PR description` - }); - - description = descriptionResult || ''; + private async clear(quitReviewMode: boolean) { + if (quitReviewMode) { + const activePullRequest = this._folderRepoManager.activePullRequest; + if (activePullRequest) { + this._activePrViewCoordinator.removePullRequest(activePullRequest); } - const createParams = { - title, - body: description, - base: target, - // For cross-repository pull requests, the owner must be listed. Always list to be safe. See https://developer.github.com/v3/pulls/#create-a-pull-request. - head: `${headRemote.owner}:${branchName}`, - owner: targetRemote!.owner, - repo: targetRemote!.name, - draft: draft - }; - - const pullRequestModel = await this._folderRepoManager.createPullRequest(createParams); - - if (pullRequestModel) { - progress.report({ increment: 30, message: `Pull Request #${pullRequestModel.number} Created` }); - await this.updateState(); - await vscode.commands.executeCommand('pr.openDescription', pullRequestModel); - progress.report({ increment: 30 }); - } else { - // error: Unhandled Rejection at: Promise [object Promise]. Reason: {"message":"Validation Failed","errors":[{"resource":"PullRequest","code":"custom","message":"A pull request already exists for rebornix:tree-sitter."}],"documentation_url":"https://developer.github.com/v3/pulls/#create-a-pull-request"}. - progress.report({ increment: 90, message: `Failed to create pull request for ${branchName}` }); + if (this.changesInPrDataProvider) { + await this.changesInPrDataProvider.removePrFromView(this._folderRepoManager); } - }); - } - - private clear(quitReviewMode: boolean) { - this._updateMessageShown = false; - - this._localToDispose.forEach(disposeable => disposeable.dispose()); - if (quitReviewMode) { this._prNumber = undefined; this._folderRepoManager.activePullRequest = undefined; + this._switchedToPullRequest = undefined; if (this._statusBarItem) { this._statusBarItem.hide(); } - if (this.changesInPrDataProvider) { - this.changesInPrDataProvider.removePrFromView(this._folderRepoManager); - } + 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. - this._comments = []; - - vscode.commands.executeCommand('pr.refreshList'); + activePullRequest?.clear(); + this._folderRepoManager.setFileViewedContext(); } + + this._reviewCommentController?.dispose(); + this._reviewCommentController = undefined; + this._inMemGitHubContentProvider?.dispose(); + this._inMemGitHubContentProvider = undefined; } async provideTextDocumentContent(uri: vscode.Uri): Promise<string | undefined> { - const { path, commit } = fromReviewUri(uri); - let changedItems = gitFileChangeNodeFilter(this._localFileChanges) + const { path, commit, base } = fromReviewUri(uri.query); + let changedItems = gitFileChangeNodeFilter(this._reviewModel.localFileChanges) .filter(change => change.fileName === path) - .filter(fileChange => fileChange.sha === commit || (fileChange.parentSha ? fileChange.parentSha : `${fileChange.sha}^`) === commit); + .filter( + fileChange => + fileChange.sha === commit || + `${fileChange.sha}^` === commit, + ); if (changedItems.length) { const changedItem = changedItems[0]; const diffChangeTypeFilter = commit === changedItem.sha ? DiffChangeType.Delete : DiffChangeType.Add; - const ret = changedItem.diffHunks.map(diffHunk => diffHunk.diffLines.filter(diffLine => diffLine.type !== diffChangeTypeFilter).map(diffLine => diffLine.text)); + const ret = (await changedItem.changeModel.diffHunks()).map(diffHunk => + diffHunk.diffLines + .filter(diffLine => diffLine.type !== diffChangeTypeFilter) + .map(diffLine => diffLine.text), + ); return ret.reduce((prev, curr) => prev.concat(...curr), []).join('\n'); } - changedItems = gitFileChangeNodeFilter(this._obsoleteFileChanges) + changedItems = gitFileChangeNodeFilter(this._reviewModel.obsoleteFileChanges) .filter(change => change.fileName === path) - .filter(fileChange => fileChange.sha === commit || (fileChange.parentSha ? fileChange.parentSha : `${fileChange.sha}^`) === commit); + .filter( + fileChange => + fileChange.sha === commit || + `${fileChange.sha}^` === commit, + ); if (changedItems.length) { // it's from obsolete file changes, which means the content is in complete. const changedItem = changedItems[0]; const diffChangeTypeFilter = commit === changedItem.sha ? DiffChangeType.Delete : DiffChangeType.Add; - const ret = []; + const ret: string[] = []; const commentGroups = groupBy(changedItem.comments, comment => String(comment.originalPosition)); for (const comment_position in commentGroups) { @@ -839,30 +1562,64 @@ export class ReviewManager { continue; } - const lines = commentGroups[comment_position][0].diffHunks! - .map(diffHunk => - diffHunk.diffLines.filter(diffLine => diffLine.type !== diffChangeTypeFilter) - .map(diffLine => diffLine.text) - ).reduce((prev, curr) => prev.concat(...curr), []); + const lines = commentGroups[comment_position][0] + .diffHunks!.map(diffHunk => + diffHunk.diffLines + .filter(diffLine => diffLine.type !== diffChangeTypeFilter) + .map(diffLine => diffLine.text), + ) + .reduce((prev, curr) => prev.concat(...curr), []); ret.push(...lines); } 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 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(reviewManagers: ReviewManager[], repository: GitHubRepository): ReviewManager | undefined { - return reviewManagers.find(reviewManager => reviewManager._folderRepoManager.gitHubRepositories.includes(repository)); + static getReviewManagerForRepository( + reviewManagers: ReviewManager[], + githubRepository: GitHubRepository, + repository?: Repository + ): ReviewManager | undefined { + return reviewManagers.find(reviewManager => + reviewManager._folderRepoManager.gitHubRepositories.some(repo => { + // If we don't have a Repository, then just get the first GH repo that fits + // Otherwise, try to pick the review manager with the same repository. + return repo.equals(githubRepository) && (!repository || (reviewManager._folderRepoManager.repository === repository)); + }) + ); } - static getReviewManagerForFolderManager(reviewManagers: ReviewManager[], folderManager: FolderRepositoryManager): ReviewManager | undefined { + static getReviewManagerForFolderManager( + reviewManagers: ReviewManager[], + folderManager: FolderRepositoryManager, + ): ReviewManager | undefined { return reviewManagers.find(reviewManager => reviewManager._folderRepoManager === folderManager); } } + +export class ShowPullRequest { + private _shouldShow: boolean = false; + private _onChangedShowValue: vscode.EventEmitter<boolean> = new vscode.EventEmitter(); + public readonly onChangedShowValue: vscode.Event<boolean> = this._onChangedShowValue.event; + constructor() { } + get shouldShow(): boolean { + return this._shouldShow; + } + set shouldShow(shouldShow: boolean) { + const oldShowValue = this._shouldShow; + this._shouldShow = shouldShow; + if (oldShowValue !== this._shouldShow) { + this._onChangedShowValue.fire(this._shouldShow); + } + } +} diff --git a/src/view/reviewModel.ts b/src/view/reviewModel.ts new file mode 100644 index 0000000000..52478da7a5 --- /dev/null +++ b/src/view/reviewModel.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 * as vscode from 'vscode'; +import { GitFileChangeNode, RemoteFileChangeNode } from './treeNodes/fileChangeNode'; + +export class ReviewModel { + private _localFileChanges: GitFileChangeNode[] | undefined; + private _onDidChangeLocalFileChanges: vscode.EventEmitter<void> = new vscode.EventEmitter(); + public onDidChangeLocalFileChanges: vscode.Event<void> = this._onDidChangeLocalFileChanges.event; + + private _obsoleteFileChanges: (GitFileChangeNode | RemoteFileChangeNode)[] = []; + + constructor() { } + + get hasLocalFileChanges() { + return !!this._localFileChanges; + } + + get localFileChanges(): GitFileChangeNode[] { + return this._localFileChanges ?? []; + } + + set localFileChanges(localFileChanges: GitFileChangeNode[]) { + this._localFileChanges = localFileChanges; + this._onDidChangeLocalFileChanges.fire(); + } + + get obsoleteFileChanges(): (GitFileChangeNode | RemoteFileChangeNode)[] { + return this._obsoleteFileChanges; + } + + set obsoleteFileChanges(obsoleteFileChanges: (GitFileChangeNode | RemoteFileChangeNode)[]) { + this._obsoleteFileChanges = obsoleteFileChanges; + } + + clear() { + this.obsoleteFileChanges = []; + this._localFileChanges = undefined; + } +} \ No newline at end of file diff --git a/src/view/reviewsManager.ts b/src/view/reviewsManager.ts index e5313ee0c6..c01319d92d 100644 --- a/src/view/reviewsManager.ts +++ b/src/view/reviewsManager.ts @@ -4,68 +4,232 @@ *--------------------------------------------------------------------------------------------*/ 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, 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 { ReviewManager } from './reviewManager'; -import { GitContentProvider } from './gitContentProvider'; -import { GitAPI } from '../typings/git'; +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, - gitApi: GitAPI + private _credentialStore: CredentialStore, + private _gitApi: GitApiImpl, + private _copilotManager: CopilotRemoteAgentManager, + private _notificationsManager: NotificationsManager, ) { - this._disposables = []; - const gitContentProvider = new GitContentProvider(gitApi); + super(); + const gitContentProvider = new GitContentFileSystemProvider(_gitApi, _credentialStore, () => this._reviewManagers); gitContentProvider.registerTextDocumentContentFallback(this.provideTextDocumentContent.bind(this)); - this._disposables.push(vscode.workspace.registerTextDocumentContentProvider('review', gitContentProvider)); + 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 => { + this._register(vscode.workspace.onDidChangeConfiguration(async e => { if (e.affectsConfiguration('githubPullRequests.showInSCM')) { if (this._prFileChangesProvider) { this._prFileChangesProvider.dispose(); - this._prFileChangesProvider = new PullRequestChangesTreeDataProvider(this._context); + this._prFileChangesProvider = new PullRequestChangesTreeDataProvider(this._gitApi, this._reposManager); for (const reviewManager of this._reviewManagers) { - reviewManager.updateState(); + reviewManager.updateState(true); } } this._prsTreeDataProvider.dispose(); - this._prsTreeDataProvider = new PullRequestsTreeDataProvider(this._telemetry); - await this._prsTreeDataProvider.initialize(this._reposManager); - this._disposables.push(this._prsTreeDataProvider); + 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); } })); + + this._register(this._reposManager.onDidChangeFolderRepositories(changed => { + if (!changed.added) { + this.removeOrphanedReviewManagers(); + } + })); + } + + private removeOrphanedReviewManagers(): void { + const folderManagerUris = new Set(this._reposManager.folderManagers.map(m => m.repository.rootUri.toString())); + for (let i = this._reviewManagers.length - 1; i >= 0; i--) { + const manager = this._reviewManagers[i]; + if (!folderManagerUris.has(manager.repository.rootUri.toString())) { + this._reviewManagers.splice(i, 1); + manager.dispose(); + } + } } 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); } } return ''; } - dispose() { - this._disposables.forEach(d => { - d.dispose(); - }); + 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); + } + + public removeReviewManager(repo: Repository) { + const reviewManagerIndex = this._reviewManagers.findIndex( + manager => manager.repository.rootUri.toString() === repo.rootUri.toString(), + ); + if (reviewManagerIndex >= 0) { + const manager = this._reviewManagers[reviewManagerIndex]; + this._reviewManagers.splice(reviewManagerIndex, 1); + manager.dispose(); + } } + async switchToPr(folderManager: FolderRepositoryManager, pullRequestModel: PullRequestModel, repository: Repository | undefined, isFromDescription: boolean) { + // If we don't have a repository from the node, use the one from the folder manager + const repositoryToCheck = repository || folderManager.repository; + + // Check for uncommitted changes before proceeding with checkout + const shouldProceed = await handleUncommittedChanges(repositoryToCheck); + if (!shouldProceed) { + return; // User cancelled or there was an error handling changes + } + + /* __GDPR__ + "pr.checkout" : { + "fromDescriptionPage" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this._telemetry.sendTelemetryEvent('pr.checkout', { fromDescription: isFromDescription.toString() }); + + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.SourceControl, + title: vscode.l10n.t('Switching to Pull Request #{0}', pullRequestModel.number), + }, + async () => { + await ReviewManager.getReviewManagerForRepository( + this._reviewManagers, + pullRequestModel.githubRepository, + repository + )?.switch(pullRequestModel); + }); + }; } + + +// Modal dialog options for handling uncommitted changes during PR checkout +const STASH_CHANGES = vscode.l10n.t('Stash changes'); +const DISCARD_CHANGES = vscode.l10n.t('Discard changes'); +const DONT_SHOW_AGAIN = vscode.l10n.t('Try to checkout anyway and don\'t show again'); + +// Constants for persistent state storage +const UNCOMMITTED_CHANGES_SCOPE = vscode.l10n.t('uncommitted changes warning'); +const UNCOMMITTED_CHANGES_STORAGE_KEY = 'showWarning'; + +/** + * Shows a modal dialog when there are uncommitted changes during PR checkout + * @param repository The git repository with uncommitted changes + * @returns Promise<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 4b899b4398..0000000000 --- a/src/view/treeDecorationProvider.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. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { fromFileChangeNodeUri } from '../common/uri'; - -class TreeDecorationProvider implements vscode.DecorationProvider { - 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._onDidChangeDecorations.fire(resourceUri); - } - } - - _onDidChangeDecorations: vscode.EventEmitter<vscode.Uri | vscode.Uri[]> = new vscode.EventEmitter<vscode.Uri | vscode.Uri[]>(); - onDidChangeDecorations: vscode.Event<vscode.Uri | vscode.Uri[]> = this._onDidChangeDecorations.event; - provideDecoration(uri: vscode.Uri, token: vscode.CancellationToken): vscode.ProviderResult<vscode.Decoration> { - const query = fromFileChangeNodeUri(uri); - if (query) { - const key = `${query.prNumber}:${query.fileName}`; - if (this.fileHasComments.get(key)) { - return { - bubble: false, - title: 'Commented', - letter: '◆', - priority: 2 - }; - } - } - - return undefined; - } -} - -export const DecorationProvider = new TreeDecorationProvider(); \ No newline at end of file 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 e8146c2054..eaa76d343f 100644 --- a/src/view/treeNodes/categoryNode.ts +++ b/src/view/treeNodes/categoryNode.ts @@ -1,222 +1,368 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { PRType } from '../../github/interface'; -import { PRNode } from './pullRequestNode'; -import { TreeNode } from './treeNode'; -import { formatError } from '../../common/utils'; -import { AuthenticationError } from '../../common/authentication'; -import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; -import { PullRequestModel } from '../../github/pullRequestModel'; -import { ITelemetry } from '../../common/telemetry'; - -export enum PRCategoryActionType { - Empty, - More, - TryOtherRemotes, - Login, - NoRemotes, - NoGitRepositories, - NoOpenFolder, - NoMatchingRemotes, - ConfigureRemotes, - Initializing -} - -export class PRCategoryActionNode extends TreeNode implements vscode.TreeItem { - public readonly label: string; - public collapsibleState: vscode.TreeItemCollapsibleState; - public iconPath?: { light: string | vscode.Uri; dark: string | vscode.Uri }; - public type: PRCategoryActionType; - public command?: vscode.Command; - - constructor(parent: TreeNode | vscode.TreeView<TreeNode>, type: PRCategoryActionType, node?: CategoryTreeNode) { - super(); - this.parent = parent; - this.type = type; - this.collapsibleState = vscode.TreeItemCollapsibleState.None; - switch (type) { - case PRCategoryActionType.Empty: - this.label = '0 pull requests in this category'; - break; - case PRCategoryActionType.More: - this.label = 'Load more'; - this.command = { - title: 'Load more', - command: 'pr.loadMore', - arguments: [ - node - ] - }; - break; - case PRCategoryActionType.TryOtherRemotes: - this.label = 'Continue fetching from other remotes'; - this.command = { - title: 'Load more', - command: 'pr.loadMore', - arguments: [ - node - ] - }; - break; - case PRCategoryActionType.Login: - this.label = 'Sign in'; - this.command = { - title: 'Sign in', - command: 'pr.signinAndRefreshList', - arguments: [] - }; - break; - case PRCategoryActionType.NoRemotes: - this.label = 'No GitHub repositories found.'; - break; - case PRCategoryActionType.NoGitRepositories: - this.label = 'No git repositories found.'; - break; - case PRCategoryActionType.NoOpenFolder: - this.label = 'You have not yet opened a folder.'; - break; - case PRCategoryActionType.NoMatchingRemotes: - this.label = 'No remotes match the current setting.'; - break; - case PRCategoryActionType.ConfigureRemotes: - this.label = 'Configure remotes...'; - this.command = { - title: 'Configure remotes', - command: 'pr.configureRemotes', - arguments: [] - }; - break; - case PRCategoryActionType.Initializing: - this.label = 'Loading...'; - default: - break; - } - } - - getTreeItem(): vscode.TreeItem { - return this; - } -} - -interface PageInformation { - pullRequestPage: number; - hasMorePages: boolean; -} - -export class CategoryTreeNode extends TreeNode implements vscode.TreeItem { - public readonly label: string; - public collapsibleState: vscode.TreeItemCollapsibleState; - public prs: PullRequestModel[]; - public fetchNextPage: boolean = false; - public repositoryPageInformation: Map<string, PageInformation> = new Map<string, PageInformation>(); - - constructor( - public parent: TreeNode | vscode.TreeView<TreeNode>, - private _folderRepoManager: FolderRepositoryManager, - private _telemetry: ITelemetry, - private _type: PRType, - _categoryLabel?: string, - private _categoryQuery?: string - ) { - super(); - - this.prs = []; - this.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; - switch (_type) { - case PRType.All: - this.label = 'All'; - break; - case PRType.Query: - this.label = _categoryLabel!; - break; - case PRType.LocalPullRequest: - this.label = 'Local Pull Request Branches'; - break; - default: - break; - } - } - - async getChildren(): Promise<TreeNode[]> { - let hasMorePages = false; - let hasUnsearchedRepositories = false; - let needLogin = false; - if (this._type === PRType.LocalPullRequest) { - try { - this.prs = await this._folderRepoManager.getLocalPullRequests(); - /* __GDPR__ - "pr.expand.local" : {} - */ - this._telemetry.sendTelemetryEvent('pr.expand.local'); - } catch (e) { - vscode.window.showErrorMessage(`Fetching local pull requests failed: ${formatError(e)}`); - needLogin = e instanceof AuthenticationError; - } - } else { - if (!this.fetchNextPage) { - try { - const response = await this._folderRepoManager.getPullRequests(this._type, { fetchNextPage: false }, this._categoryQuery); - this.prs = response.items; - hasMorePages = response.hasMorePages; - hasUnsearchedRepositories = response.hasUnsearchedRepositories; - - switch (this._type) { - case PRType.All: - /* __GDPR__ - "pr.expand.all" : {} - */ - this._telemetry.sendTelemetryEvent('pr.expand.all'); - case PRType.Query: - /* __GDPR__ - "pr.expand.query" : {} - */ - this._telemetry.sendTelemetryEvent('pr.expand.query'); - break; - } - - } catch (e) { - vscode.window.showErrorMessage(`Fetching pull requests failed: ${formatError(e)}`); - needLogin = e instanceof AuthenticationError; - } - } else { - try { - const response = await this._folderRepoManager.getPullRequests(this._type, { fetchNextPage: true }, this._categoryQuery); - this.prs = this.prs.concat(response.items); - hasMorePages = response.hasMorePages; - hasUnsearchedRepositories = response.hasUnsearchedRepositories; - } catch (e) { - vscode.window.showErrorMessage(`Fetching pull requests failed: ${formatError(e)}`); - needLogin = e instanceof AuthenticationError; - } - - 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)); - if (hasMorePages) { - nodes.push(new PRCategoryActionNode(this, PRCategoryActionType.More, this)); - } else if (hasUnsearchedRepositories) { - nodes.push(new PRCategoryActionNode(this, PRCategoryActionType.TryOtherRemotes, this)); - } - - this.childrenDisposables = nodes; - return nodes; - } else { - const category = needLogin ? PRCategoryActionType.Login : PRCategoryActionType.Empty; - const result = [new PRCategoryActionNode(this, category)]; - - this.childrenDisposables = 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 { isAuthError } from '../../github/loggingOctokit'; +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 if (isAuthError(e)) { + // Auth errors (revoked/expired token) are handled centrally by + // CredentialStore.handleAuthError (wired up through RateLogger), + // which prompts the user to re-authenticate. Showing an additional + // "Fetching pull requests failed: Bad credentials" notification + // here is redundant and on some platforms (e.g. macOS) the + // notification can linger on screen after the user has already + // completed the re-authentication flow, making it look like the + // sign-in failed even when it succeeded. Just mark the tree as + // needing login so the "Sign in" entry is shown. + needLogin = true; + } 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 eee45bfe20..ee95b34a12 100644 --- a/src/view/treeNodes/commitNode.ts +++ b/src/view/treeNodes/commitNode.ts @@ -4,83 +4,123 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import * as path from 'path'; -import { TreeNode } from './treeNode'; -import { GitFileChangeNode } from './fileChangeNode'; -import { toReviewUri } from '../../common/uri'; import { getGitChangeType } from '../../common/diffHunk'; -import { IComment } from '../../common/comment'; -import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; -import { PullRequestModel } from '../../github/pullRequestModel'; +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 } from '../../github/folderRepositoryManager'; +import { AccountType, IAccount } from '../../github/interface'; +import { IResolvedPullRequestModel, PullRequestModel } from '../../github/pullRequestModel'; +import { GitFileChangeModel } from '../fileChangeModel'; +import { DirectoryTreeNode } from './directoryTreeNode'; +import { GitFileChangeNode } from './fileChangeNode'; +import { LabelOnlyNode, TreeNode, TreeNodeParent } from './treeNode'; export class CommitNode extends TreeNode implements vscode.TreeItem { - public label: string; 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: TreeNode | vscode.TreeView<TreeNode>, + parent: TreeNodeParent, private readonly pullRequestManager: FolderRepositoryManager, private readonly pullRequest: PullRequestModel, private readonly commit: OctokitCommon.PullsListCommitsResponseItem, - private readonly comments: IComment[] + 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[]> { - const fileChanges = await this.pullRequest.getCommitChangedFiles(this.commit); + override async getChildren(): Promise<TreeNode[]> { + super.getChildren(); + const fileChanges = (await this.pullRequest.getCommitChangedFiles(this.commit)) ?? []; + + if (fileChanges.length === 0) { + return [new LabelOnlyNode(this, 'No changed files')]; + } const fileChangeNodes = fileChanges.map(change => { - const matchingComments = this.comments.filter(comment => comment.path === change.filename && comment.originalCommitId === this.commit.sha); - const fileName = change.filename; - const uri = vscode.Uri.parse(path.join(`commit~${this.commit.sha.substr(0, 8)}`, fileName)); + const fileName = change.filename!; + const uri = reviewPath(fileName, this.commit.sha); + const changeModel = new GitFileChangeModel( + this.pullRequestManager, + this.pullRequest, + { + status: getGitChangeType(change.status!), + fileName, + blobUrl: undefined + }, + toReviewUri( + uri, + fileName, + undefined, + this.commit.sha, + true, + { base: false }, + this.pullRequestManager.repository.rootUri, + ), + toReviewUri( + uri, + fileName, + undefined, + this.commit.sha, + true, + { base: true }, + this.pullRequestManager.repository.rootUri, + ), + this.commit.sha); const fileChangeNode = new GitFileChangeNode( this, - this.pullRequest, - getGitChangeType(change.status), - fileName, - undefined, - toReviewUri(uri, fileName, undefined, this.commit.sha, true, { base: false }, this.pullRequestManager.repository.rootUri), - toReviewUri(uri, fileName, undefined, this.commit.sha, true, { base: true }, this.pullRequestManager.repository.rootUri), - [], - matchingComments, - this.commit.sha + this.pullRequestManager, + this.pullRequest as (PullRequestModel & IResolvedPullRequestModel), + changeModel, + this.isCurrent ); - fileChangeNode.command = { - title: 'View Changes', - command: 'pr.viewChanges', - arguments: [ - fileChangeNode - ] - }; + fileChangeNode.useViewChangesCommand(); return fileChangeNode; }); - return Promise.resolve(fileChangeNodes); + let 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, ''); + fileChangeNodes.forEach(f => dirNode.addFile(f)); + dirNode.finalize(); + if (dirNode.label === '') { + // nothing on the root changed, pull children to parent + dirNode._children.forEach(child => { child.parent = this; }); + result.push(...dirNode._children); + } else { + result.push(dirNode); + } + } else { + // flat view + result = fileChangeNodes; + } + this._children = result; + return result; } - } diff --git a/src/view/treeNodes/commitsCategoryNode.ts b/src/view/treeNodes/commitsCategoryNode.ts index 08d5728bf8..7a0fb197ca 100644 --- a/src/view/treeNodes/commitsCategoryNode.ts +++ b/src/view/treeNodes/commitsCategoryNode.ts @@ -4,39 +4,62 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { TreeNode } from './treeNode'; import { CommitNode } from './commitNode'; -import { IComment } from '../../common/comment'; +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'; export class CommitsNode extends TreeNode implements vscode.TreeItem { - public label: string = 'Commits'; public collapsibleState: vscode.TreeItemCollapsibleState; + public resourceUri: vscode.Uri; private _folderRepoManager: FolderRepositoryManager; private _pr: PullRequestModel; - private _comments: IComment[]; + public tooltip?: string; - constructor(parent: TreeNode | vscode.TreeView<TreeNode>, reposManager: FolderRepositoryManager, pr: PullRequestModel, comments: IComment[]) { - super(); - this.parent = parent; + constructor( + parent: TreeNodeParent, + reposManager: FolderRepositoryManager, + pr: PullRequestModel, + ) { + super(parent); + this.label = vscode.l10n.t('Commits'); this._pr = pr; this._folderRepoManager = reposManager; - this._comments = comments; 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.onDidChange(e => { + if (e.comments) { + Logger.appendLine(`Comments have changed, refreshing Commits node`, PR_TREE); + this.refresh(this); + } + })); } getTreeItem(): 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(); - const commitNodes = commits.map(commit => new CommitNode(this, this._folderRepoManager, this._pr, commit, this._comments)); - return Promise.resolve(commitNodes); + 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; } catch (e) { - return Promise.resolve([]); + return []; } } -} \ No newline at end of file +} diff --git a/src/view/treeNodes/descriptionNode.ts b/src/view/treeNodes/descriptionNode.ts deleted file mode 100644 index 844029fc0d..0000000000 --- a/src/view/treeNodes/descriptionNode.ts +++ /dev/null @@ -1,31 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { TreeNode, Revealable } from './treeNode'; -import { PullRequestModel } from '../../github/pullRequestModel'; - -export class DescriptionNode extends TreeNode implements vscode.TreeItem { - public command?: vscode.Command; - public contextValue?: string; - - constructor(public parent: TreeNode | vscode.TreeView<TreeNode> | Revealable<TreeNode>, public label: string, public iconPath: string | vscode.Uri | { light: string | vscode.Uri; dark: string | vscode.Uri }, public pullRequestModel: PullRequestModel) { - super(); - - this.command = { - title: 'View Pull Request Description', - command: 'pr.openDescription', - arguments: [ - this - ] - }; - - this.contextValue = 'description'; - } - - getTreeItem(): vscode.TreeItem { - return this; - } -} diff --git a/src/view/treeNodes/directoryTreeNode.ts b/src/view/treeNodes/directoryTreeNode.ts index 7daa3ebaa3..4b0d6dfdeb 100644 --- a/src/view/treeNodes/directoryTreeNode.ts +++ b/src/view/treeNodes/directoryTreeNode.ts @@ -4,24 +4,23 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { TreeNode } from './treeNode'; -import { RemoteFileChangeNode, InMemFileChangeNode, GitFileChangeNode } from './fileChangeNode'; +import { GitFileChangeNode, InMemFileChangeNode, RemoteFileChangeNode } from './fileChangeNode'; +import { TreeNode, TreeNodeParent } from './treeNode'; export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem { public collapsibleState: vscode.TreeItemCollapsibleState; - public children: (RemoteFileChangeNode | InMemFileChangeNode | GitFileChangeNode | DirectoryTreeNode)[] = new Array(); + public override _children: (RemoteFileChangeNode | InMemFileChangeNode | GitFileChangeNode | DirectoryTreeNode)[] = []; private pathToChild: Map<string, DirectoryTreeNode> = new Map(); + public checkboxState?: { state: vscode.TreeItemCheckboxState, tooltip: string, accessibilityInformation: vscode.AccessibilityInformation }; - constructor( - public parent: TreeNode | vscode.TreeView<TreeNode>, - 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 { @@ -30,13 +29,13 @@ export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem { } 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(); // reccursive + n.trimTree(); // recursive } }); @@ -47,10 +46,10 @@ export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem { // 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; } @@ -60,11 +59,12 @@ export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem { if (this.label.startsWith('/')) { this.label = this.label.substr(1); } - this.children = child.children; + 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; } @@ -72,7 +72,7 @@ export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem { 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); @@ -83,16 +83,14 @@ export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem { }); // sort - dirs.sort((a, b) => (a.label < b.label) ? -1 : 1); - files.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 { - const paths = file.fileName.split('/'); - file.description = ''; - + const paths = file.changeModel.fileName.split('/'); this.addPathRecc(paths, file); } @@ -102,7 +100,8 @@ export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem { } if (paths.length === 1) { - this.children.push(file); + file.parent = this; + this._children.push(file); return; } @@ -113,13 +112,37 @@ export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem { if (!node) { node = new DirectoryTreeNode(this, dir); this.pathToChild.set(dir, node); - this.children.push(node); + this._children.push(node); } node.addPathRecc(tail, file); } + private allChildrenViewed(): boolean { + for (const child of this._children) { + if (child instanceof DirectoryTreeNode) { + if (!child.allChildrenViewed()) { + return false; + } + } else if (!child.checkboxState || child.checkboxState.state !== vscode.TreeItemCheckboxState.Checked) { + return false; + } + } + return true; + } + + private setCheckboxState(isChecked: boolean) { + this.checkboxState = isChecked ? + { 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!) } }; + } + + public updateCheckboxFromChildren(): void { + this.setCheckboxState(this.allChildrenViewed()); + } + getTreeItem(): vscode.TreeItem { + this.updateCheckboxFromChildren(); return this; } } diff --git a/src/view/treeNodes/fileChangeNode.ts b/src/view/treeNodes/fileChangeNode.ts index 2c78c71464..4e1e8ac0c6 100644 --- a/src/view/treeNodes/fileChangeNode.ts +++ b/src/view/treeNodes/fileChangeNode.ts @@ -3,219 +3,485 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as vscode from 'vscode'; import * as path from 'path'; -import { DiffHunk, DiffChangeType } from '../../common/diffHunk'; -import { GitChangeType } from '../../common/file'; -import { TreeNode } from './treeNode'; -import { IComment } from '../../common/comment'; -import { getDiffLineByPosition, getZeroBased } from '../../common/diffPositionMapping'; -import { toResourceUri } from '../../common/uri'; -import { PullRequestModel } from '../../github/pullRequestModel'; -import { DecorationProvider } from '../treeDecorationProvider'; - -/** - * File change node whose content can not be resolved locally and we direct users to GitHub. - */ -export class RemoteFileChangeNode extends TreeNode implements vscode.TreeItem { - public label: string; - public description: string; - public iconPath?: string | vscode.Uri | { light: string | vscode.Uri; dark: string | vscode.Uri } | vscode.ThemeIcon; - public command: vscode.Command; - public resourceUri: vscode.Uri; - public contextValue: string; +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, 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 } from '../../github/folderRepositoryManager'; +import { IResolvedPullRequestModel, PullRequestModel } from '../../github/pullRequestModel'; +import { FileChangeModel, GitFileChangeModel, InMemFileChangeModel, RemoteFileChangeModel } from '../fileChangeModel'; +import { TreeNode, TreeNodeParent } from './treeNode'; - constructor( - public readonly parent: TreeNode | vscode.TreeView<TreeNode>, - public readonly pullRequest: PullRequestModel, - public readonly status: GitChangeType, - public readonly fileName: string, - public readonly previousFileName: string | undefined, - public readonly blobUrl: string, - public readonly filePath: vscode.Uri, - public readonly parentFilePath: vscode.Uri - ) { - super(); - this.contextValue = `filechange:${GitChangeType[status]}`; - this.label = path.basename(fileName); - this.description = path.relative('.', path.dirname(fileName)); - this.iconPath = vscode.ThemeIcon.File; - this.resourceUri = toResourceUri(vscode.Uri.parse(this.blobUrl), pullRequest.number, fileName, status); +export function openFileCommand(uri: vscode.Uri, inputOpts: vscode.TextDocumentShowOptions = {}): vscode.Command { + const activeTextEditor = vscode.window.activeTextEditor; + const opts = { + ...inputOpts, ...{ + viewColumn: vscode.ViewColumn.Active, + } + }; - this.command = { - title: 'show remote file', - command: 'pr.openDiffView', - arguments: [ - this - ] - }; + // Check if active text editor has same path as other editor. we cannot compare via + // URI.toString() here because the schemas can be different. Instead we just go by path. + if (activeTextEditor && activeTextEditor.document.uri.path === uri.path) { + opts.selection = activeTextEditor.selection; } + return { + command: 'vscode.open', + arguments: [uri, opts], + title: 'Open File', + }; +} - getTreeItem(): vscode.TreeItem { - return this; +async function openDiffCommand( + folderManager: FolderRepositoryManager, + parentFilePath: vscode.Uri, + filePath: vscode.Uri, + opts: vscode.TextDocumentShowOptions | undefined, + status: GitChangeType, +): Promise<vscode.Command> { + 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; + } + if (status === GitChangeType.DELETE) { + headURI = EMPTY_IMAGE_URI; + } } + + const pathSegments = filePath.path.split('/'); + return { + command: 'vscode.diff', + arguments: [parentURI, headURI, `${pathSegments[pathSegments.length - 1]} (Pull Request)`, opts], + 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.TreeItem { - public label: string; - public description: string; - public iconPath?: string | vscode.Uri | { light: string | vscode.Uri; dark: string | vscode.Uri } | vscode.ThemeIcon; - public resourceUri: vscode.Uri; - public parentSha: string; + public iconPath?: + | 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; accessibilityInformation: vscode.AccessibilityInformation }; + + get status(): GitChangeType { + return this.changeModel.status; + } + + get fileName(): string { + return this.changeModel.fileName; + } + + get blobUrl(): string | undefined { + return this.changeModel.blobUrl; + } + + get sha(): string | undefined { + return this.changeModel.sha; + } + + get tooltip(): string { + return this.resourceUri.fsPath; + } + constructor( - public readonly parent: TreeNode | vscode.TreeView<TreeNode>, - public readonly pullRequest: PullRequestModel, - public readonly status: GitChangeType, - public readonly fileName: string, - public readonly blobUrl: string | undefined, - public readonly filePath: vscode.Uri, - public readonly parentFilePath: vscode.Uri, - public readonly diffHunks: DiffHunk[], - public comments: IComment[], - public readonly sha?: string + parent: TreeNodeParent, + protected readonly pullRequestManager: FolderRepositoryManager, + public readonly pullRequest: PullRequestModel & IResolvedPullRequestModel, + public readonly changeModel: FileChangeModel ) { - super(); - this.contextValue = `filechange:${GitChangeType[status]}`; - this.label = path.basename(fileName); - this.description = path.relative('.', path.dirname(fileName)); + 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.update(this.comments); - this.resourceUri = toResourceUri(vscode.Uri.file(this.fileName), this.pullRequest.number, this.fileName, this.status); - } + this.opts = {}; + this.updateShowOptions(); + this.fileChangeResourceUri = toResourceUri( + vscode.Uri.file(this.changeModel.fileName), + this.pullRequest.number, + this.changeModel.fileName, + this.changeModel.status, + this.changeModel.change instanceof InMemFileChange ? this.changeModel.change.previousFileName : undefined + ); + this.updateViewed(viewed); - private findFirstActiveComment() { - let activeComment: IComment | undefined; - this.comments.forEach(comment => { - if (!activeComment && comment.position) { - activeComment = comment; - return; - } + this.childrenDisposables.push( + this.pullRequest.onDidChangeReviewThreads(e => { + if ([...e.added, ...e.removed].some(thread => thread.path === this.changeModel.fileName)) { + this.updateShowOptions(); + } + }), + ); - if (activeComment && comment.position && comment.position < activeComment.position!) { - activeComment = comment; - } - }); + this.childrenDisposables.push( + this.pullRequest.onDidChangeFileViewedState(e => { + 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); + } + }), + ); - return activeComment; + this.accessibilityInformation = { label: `${this.label} pull request diff`, role: 'link' }; + this.description = this._getDescription(); } - update(comments: IComment[]) { - this.comments = comments; - DecorationProvider.updateFileComments(this.resourceUri, this.pullRequest.number, this.fileName, comments.length > 0); + get resourceUri(): vscode.Uri { + return this.changeModel.filePath.with({ query: this.fileChangeResourceUri.query }); + } - if (comments && comments.length) { - const comment = this.findFirstActiveComment(); - if (comment) { - const diffLine = getDiffLineByPosition(this.diffHunks, comment.position === undefined ? comment.originalPosition! : comment.position); - if (diffLine) { - // If the diff is a deletion, the new line number is invalid so use the old line number. Ensure the line number is positive. - const lineNumber = Math.max(getZeroBased(diffLine.type === DiffChangeType.Delete ? diffLine.oldLineNumber : diffLine.newLineNumber), 0); - this.opts.selection = new vscode.Range(lineNumber, 0, lineNumber, 0); - } - } + protected _getDescription(): string | true { + const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<string>(FILE_LIST_LAYOUT); + if (layout === 'flat') { + return true; } else { - delete this.opts.selection; + return ''; } } - getCommentPosition(comment: IComment) { - const diffLine = getDiffLineByPosition(this.diffHunks, comment.position === undefined ? comment.originalPosition! : comment.position); + /** + * 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; + } - if (diffLine) { - // If the diff is a deletion, the new line number is invalid so use the old line number. Ensure the line number is positive. - const lineNumber = Math.max(getZeroBased(diffLine.type === DiffChangeType.Delete ? diffLine.oldLineNumber : diffLine.newLineNumber), 0); - return lineNumber; + updateViewed(viewed: ViewedState) { + this.changeModel.updateViewed(viewed); + this.contextValue = `${Schemes.FileChange}:${GitChangeType[this.changeModel.status]}:${viewed === ViewedState.VIEWED ? 'viewed' : 'unviewed' + }`; + // 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(); + } - return 0; + public async markFileAsViewed(fromCheckboxChanged: boolean = true) { + await this.pullRequest.markFiles([this.fileName], !fromCheckboxChanged, 'viewed'); + this.pullRequestManager.setFileViewedContext(); + } + + public async unmarkFileAsViewed(fromCheckboxChanged: boolean = true) { + await this.pullRequest.markFiles([this.fileName], !fromCheckboxChanged, 'unviewed'); + this.pullRequestManager.setFileViewedContext(); + } + + override updateFromCheckboxChanged(newState: vscode.TreeItemCheckboxState) { + const viewed = newState === vscode.TreeItemCheckboxState.Checked ? ViewedState.VIEWED : ViewedState.UNVIEWED; + this.updateViewed(viewed); + } + + public refreshFileViewedContext() { + this.pullRequestManager.setFileViewedContext(); + } + + updateShowOptions() { + const reviewThreads = this.pullRequest.reviewThreadsCache; + const reviewThreadsByFile = groupBy(reviewThreads, thread => thread.path); + const reviewThreadsForNode = (reviewThreadsByFile[this.changeModel.fileName] || []).filter(thread => !thread.isOutdated); + + if (reviewThreadsForNode.length) { + reviewThreadsForNode.sort((a, b) => a.endLine - b.endLine); + const startLine = reviewThreadsForNode[0].startLine ?? reviewThreadsForNode[0].originalStartLine; + const endLine = reviewThreadsForNode[0].endLine ?? reviewThreadsForNode[0].originalEndLine; + this.opts.selection = new vscode.Range(startLine, 0, endLine, 0); + } else { + delete this.opts.selection; + } } getTreeItem(): vscode.TreeItem { return this; } + + openFileCommand(): vscode.Command { + return openFileCommand(this.changeModel.filePath); + } + + async openDiff(folderManager: FolderRepositoryManager, opts?: vscode.TextDocumentShowOptions): Promise<void> { + const command = await openDiffCommand( + folderManager, + this.changeModel.parentFilePath, + this.changeModel.filePath, + { + ...this.opts, + ...opts, + }, + this.changeModel.status, + ); + return vscode.commands.executeCommand(command.command, ...(command.arguments ?? [])); + } } /** - * File change node whose content is stored in memory and resolved when being revealed. + * File change node whose content can not be resolved locally and we direct users to GitHub. */ -export class InMemFileChangeNode extends FileChangeNode implements vscode.TreeItem { - public label: string; - public description: string; - public iconPath?: string | vscode.Uri | { light: string | vscode.Uri; dark: string | vscode.Uri } | vscode.ThemeIcon; - public resourceUri: vscode.Uri; - public parentSha: string; - public contextValue: string; - public command: vscode.Command; - public opts: vscode.TextDocumentShowOptions; +export class RemoteFileChangeNode extends FileChangeNode implements vscode.TreeItem { + protected override _getDescription(): string { + let description = vscode.workspace.asRelativePath(path.dirname(this.changeModel.fileName), false); + if (description === '.') { + description = ''; + } + return description; + } constructor( - public readonly parent: TreeNode | vscode.TreeView<TreeNode>, - public readonly pullRequest: PullRequestModel, - public readonly status: GitChangeType, - public readonly fileName: string, - public readonly previousFileName: string | undefined, - public readonly blobUrl: string, - public readonly filePath: vscode.Uri, - public readonly parentFilePath: vscode.Uri, - public isPartial: boolean, - public readonly patch: string, - public readonly diffHunks: DiffHunk[], - public comments: IComment[], - public readonly sha?: string + parent: TreeNodeParent, + folderRepositoryManager: FolderRepositoryManager, + pullRequest: PullRequestModel & IResolvedPullRequestModel, + changeModel: RemoteFileChangeModel ) { - super(parent, pullRequest, status, fileName, blobUrl, filePath, parentFilePath, diffHunks, comments, sha); + super(parent, folderRepositoryManager, pullRequest, changeModel); + this.fileChangeResourceUri = toResourceUri(vscode.Uri.parse(changeModel.blobUrl!), changeModel.pullRequest.number, changeModel.fileName, changeModel.status, changeModel.previousFileName); this.command = { - title: 'show diff', - command: 'pr.openDiffView', - arguments: [this] + command: 'pr.openFileOnGitHub', + title: 'Open File on GitHub', + arguments: [this], }; } + + override async openDiff(): Promise<void> { + return vscode.commands.executeCommand(this.command.command); + } + + override openFileCommand(): vscode.Command { + return this.command; + } +} + +/** + * File change node whose content is stored in memory and resolved when being revealed. + */ +export class InMemFileChangeNode extends FileChangeNode implements vscode.TreeItem { + constructor( + private readonly folderRepositoryManager: FolderRepositoryManager, + parent: TreeNodeParent, + pullRequest: PullRequestModel & IResolvedPullRequestModel, + changeModel: InMemFileChangeModel + ) { + super(parent, folderRepositoryManager, pullRequest, changeModel); + } + + get comments(): IComment[] { + return this.pullRequest.comments.filter(comment => (comment.path === this.changeModel.fileName) && (comment.position !== null)); + } + + async resolve(): Promise<void> { + 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, + ); + } + } } /** * File change node whose content can be resolved by git commit sha. */ export class GitFileChangeNode extends FileChangeNode implements vscode.TreeItem { - public label: string; - public description: string; - public iconPath?: string | vscode.Uri | { light: string | vscode.Uri; dark: string | vscode.Uri } | vscode.ThemeIcon; - public resourceUri: vscode.Uri; - public parentSha: string; - public contextValue: string; + constructor( + parent: TreeNodeParent, + pullRequestManager: FolderRepositoryManager, + pullRequest: PullRequestModel & IResolvedPullRequestModel, + changeModel: GitFileChangeModel, + private isCurrent?: boolean, + private _comments?: IComment[] + ) { + super(parent, pullRequestManager, pullRequest, changeModel); + } + + get comments(): IComment[] { + if (this._comments) { + return this._comments; + } + // if there's a commit sha, then the comment must belong to the commit. + return this.pullRequest.comments.filter(comment => { + if (!this.sha || this.sha === this.pullRequest.head.sha) { + return comment.position && (comment.path === this.changeModel.fileName); + } else { + return (comment.path === this.changeModel.fileName) && (comment.originalCommitId === this.sha); + } + }); + } + + private _useViewChangesCommand = false; + public useViewChangesCommand() { + this._useViewChangesCommand = true; + } + + private async alternateCommand(): Promise<vscode.Command> { + if (this.status === GitChangeType.DELETE || this.status === GitChangeType.ADD) { + // create an empty `review` uri without any path/commit info. + const emptyFileUri = this.changeModel.parentFilePath.with({ + query: JSON.stringify({ + path: null, + commit: null, + }), + }); + + return { + command: 'vscode.diff', + arguments: + this.status === GitChangeType.DELETE + ? [this.changeModel.parentFilePath, emptyFileUri, `${this.fileName}`, {}] + : [emptyFileUri, this.changeModel.parentFilePath, `${this.fileName}`, {}], + title: 'Open Diff', + }; + } + + // Show the file change in a diff view. + const { path: filePath, ref, commit, rootPath } = fromReviewUri(this.changeModel.filePath.query); + const previousCommit = `${commit}^`; + const query: ReviewUriParams = { + path: filePath, + ref: ref, + commit: previousCommit, + base: true, + isOutdated: true, + rootPath, + }; + const previousFileUri = this.changeModel.filePath.with({ query: JSON.stringify(query) }); + let currentFilePath = this.changeModel.filePath; + // If the commit is the most recent/current commit, then we just use the current file for the right. + // This is so that comments display properly. + if (this.isCurrent) { + currentFilePath = this.pullRequestManager.repository.rootUri.with({ path: path.posix.join(query.rootPath, query.path) }); + } + + const options: vscode.TextDocumentShowOptions = {}; + + const reviewThreads = this.pullRequest.reviewThreadsCache; + const reviewThreadsByFile = groupBy(reviewThreads, t => t.path); + const reviewThreadsForNode = (reviewThreadsByFile[this.fileName] || []) + .filter(thread => thread.isOutdated) + .sort((a, b) => a.endLine - b.endLine); + + if (reviewThreadsForNode.length) { + options.selection = new vscode.Range(reviewThreadsForNode[0].originalStartLine, 0, reviewThreadsForNode[0].originalEndLine, 0); + } + + return { + command: 'vscode.diff', + arguments: [ + previousFileUri, + currentFilePath, + `${this.fileName} from ${(commit || '').substr(0, 8)}`, + options, + ], + title: 'View Changes', + }; + } + + async resolve(): Promise<void> { + if (this._useViewChangesCommand) { + this.command = await this.alternateCommand(); + } else { + const openDiff = vscode.workspace.getConfiguration(GIT, this.pullRequestManager.repository.rootUri).get(OPEN_DIFF_ON_CLICK, true); + if (openDiff && this.status !== GitChangeType.ADD) { + this.command = await openDiffCommand( + this.pullRequestManager, + this.changeModel.parentFilePath, + this.changeModel.filePath, + this.opts, + this.status, + ); + } else { + this.command = this.openFileCommand(); + } + } + } +} + +/** + * 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 iconPath: vscode.ThemeIcon; + public fileChangeResourceUri: vscode.Uri; + public readonly tooltip: string; public command: vscode.Command; - public opts: vscode.TextDocumentShowOptions; constructor( - public readonly parent: TreeNode | vscode.TreeView<TreeNode>, - public readonly pullRequest: PullRequestModel, - public readonly status: GitChangeType, + parent: TreeNodeParent, public readonly fileName: string, - public readonly blobUrl: string | undefined, - public readonly filePath: vscode.Uri, - public readonly parentFilePath: vscode.Uri, - public readonly diffHunks: DiffHunk[], - public comments: IComment[] = [], - public readonly sha?: string, + public readonly previousFileName: string | undefined, + public readonly status: GitChangeType, + public readonly baseBranch: string, + public readonly headBranch: string, + public readonly isLocal: boolean ) { - super(parent, pullRequest, status, fileName, blobUrl, filePath, parentFilePath, diffHunks, comments, sha); + 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, + query: JSON.stringify({ status, fileName }), + }); + + 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 = toGitHubUri(vscode.Uri.file(fileName), scheme, { fileName, branch: baseBranch, isEmpty: true }); + break; + + case GitChangeType.RENAME: + parentURI = toGitHubUri(vscode.Uri.file(previousFileName!), scheme, { fileName: previousFileName!, branch: baseBranch, isEmpty: true }); + break; + + case GitChangeType.DELETE: + headURI = toGitHubUri(vscode.Uri.file(fileName), scheme, { fileName, branch: headBranch, isEmpty: true }); + break; + } + this.command = { - title: 'open changed file', - command: 'pr.openChangedFile', - arguments: [this] + title: 'Open Diff', + command: 'vscode.diff', + arguments: [parentURI, headURI, `${fileName} (Pull Request Preview)`], }; } + + get resourceUri(): vscode.Uri { + return vscode.Uri.file(this.fileName).with({ query: this.fileChangeResourceUri.query }); + } + + getTreeItem() { + return this; + } } export function gitFileChangeNodeFilter(nodes: (GitFileChangeNode | RemoteFileChangeNode)[]): GitFileChangeNode[] { return nodes.filter(node => node instanceof GitFileChangeNode) as GitFileChangeNode[]; -} \ No newline at end of file +} diff --git a/src/view/treeNodes/filesCategoryNode.ts b/src/view/treeNodes/filesCategoryNode.ts index 331ed29900..077974c8c5 100644 --- a/src/view/treeNodes/filesCategoryNode.ts +++ b/src/view/treeNodes/filesCategoryNode.ts @@ -4,43 +4,115 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { GitFileChangeNode, RemoteFileChangeNode } from './fileChangeNode'; -import { TreeNode } from './treeNode'; +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 = 'Files'; + public override readonly label: string = vscode.l10n.t('Files'); public collapsibleState: vscode.TreeItemCollapsibleState; private directories: TreeNode[] = []; - constructor(public parent: TreeNode | vscode.TreeView<TreeNode>, private _fileChanges: (GitFileChangeNode | RemoteFileChangeNode)[]) { - super(); - this.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; + constructor( + parent: TreeNodeParent, + private _reviewModel: ReviewModel, + _pullRequestModel: PullRequestModel + ) { + super(parent); + this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; + this.childrenDisposables = []; + this.childrenDisposables.push(this._reviewModel.onDidChangeLocalFileChanges(() => { + Logger.appendLine(`Local files have changed, refreshing Files node`, PR_TREE); + this.refresh(this); + })); + this.childrenDisposables.push(_pullRequestModel.onDidChangeReviewThreads(() => { + Logger.appendLine(`Review threads have changed, refreshing Files node`, PR_TREE); + this.refresh(this); + })); + 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; + } + + override async getChildren(): Promise<TreeNode[]> { + super.getChildren(false); + + Logger.appendLine(`Getting children for Files node`, PR_TREE); + if (!this._reviewModel.hasLocalFileChanges) { + // Provide loading feedback until we get the files. + return new Promise<TreeNode[]>(resolve => { + const promiseResolver = this._reviewModel.onDidChangeLocalFileChanges(() => { + resolve([]); + promiseResolver.dispose(); + }); + }); + } + + if (this._reviewModel.localFileChanges.length === 0) { + return [new LabelOnlyNode(this, vscode.l10n.t('No changed files'))]; + } + + let nodes: TreeNode[]; + 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'))]; + } - // tree view const dirNode = new DirectoryTreeNode(this, ''); - this._fileChanges.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; + this.directories.forEach(child => { child.parent = this; }); } else { this.directories = [dirNode]; } - } - getTreeItem(): vscode.TreeItem { - return this; - } - - async getChildren(): Promise<TreeNode[]> { - let nodes: TreeNode[]; - const layout = vscode.workspace.getConfiguration('githubPullRequests').get<string>('fileListLayout'); if (layout === 'tree') { nodes = this.directories; } else { - nodes = this._fileChanges; + const fileNodes = [...filesToShow]; + fileNodes.sort((a, b) => compareIgnoreCase(a.fileChangeResourceUri.toString(), b.fileChangeResourceUri.toString())); + // In flat layout, files are rendered as direct children of this node. + // Keep parent pointers aligned with the rendered hierarchy so reveal/getParent + // don't try to walk through hidden DirectoryTreeNode instances. + fileNodes.forEach(fileNode => { + fileNode.parent = this; + }); + nodes = fileNodes; } - return Promise.resolve(nodes); + Logger.appendLine(`Got all children for Files node`, PR_TREE); + this._children = nodes; + return nodes; } } diff --git a/src/view/treeNodes/pullRequestNode.ts b/src/view/treeNodes/pullRequestNode.ts index 2bf96e4ca2..f9ddf00328 100644 --- a/src/view/treeNodes/pullRequestNode.ts +++ b/src/view/treeNodes/pullRequestNode.ts @@ -4,99 +4,37 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import * as path from 'path'; -import * as uuid from 'uuid'; -import { parseDiff, getModifiedContentFromDiffHunk, DiffChangeType } from '../../common/diffHunk'; -import { getZeroBased, getAbsolutePosition, mapHeadLineToDiffHunkPosition } from '../../common/diffPositionMapping'; -import { SlimFileChange, GitChangeType } from '../../common/file'; +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 { Resource } from '../../common/resources'; -import { fromPRUri, toPRUri } from '../../common/uri'; -import { groupBy, uniqBy } from '../../common/utils'; -import { DescriptionNode } from './descriptionNode'; -import { RemoteFileChangeNode, InMemFileChangeNode, GitFileChangeNode } from './fileChangeNode'; -import { TreeNode } from './treeNode'; -import { getInMemPRContentProvider } from '../inMemPRContentProvider'; -import { IComment } from '../../common/comment'; -import { GHPRComment, GHPRCommentThread, TemporaryComment } from '../../github/prComment'; +import { FILE_LIST_LAYOUT, LIST_HORIZONTAL_SCROLLING, PR_SETTINGS_NAMESPACE, PULL_REQUEST_AVATAR_DISPLAY, PullRequestAvatarDisplay, SHOW_PULL_REQUEST_NUMBER_IN_TREE, WORKBENCH } from '../../common/settingKeys'; +import { createPRNodeUri, DataUri, fromPRUri, Schemes } from '../../common/uri'; import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; -import { PullRequestModel } from '../../github/pullRequestModel'; -import { createVSCodeCommentThread, parseGraphQLReaction, updateCommentThreadLabel, updateCommentReviewState, updateCommentReactions, CommentReactionHandler } from '../../github/utils'; -import { CommentHandler, registerCommentHandler, unregisterCommentHandler } from '../../commentHandlerResolver'; -import { ReactionGroup } from '../../github/graphql'; -import { getCommentingRanges } from '../../common/commentingRanges'; +import { CopilotWorkingStatus } from '../../github/githubRepository'; +import { GithubItemStateEnum } from '../../github/interface'; +import { IResolvedPullRequestModel, PullRequestModel } from '../../github/pullRequestModel'; +import { InMemFileChangeModel, RemoteFileChangeModel } from '../fileChangeModel'; +import { getInMemPRFileSystemProvider, provideDocumentContentForChangeModel } from '../inMemPRContentProvider'; +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'; -/** - * Thread data is raw data. It should be transformed to GHPRCommentThreads - * before being sent to VSCode. - */ -export interface ThreadData { - threadId: string; - uri: vscode.Uri; - range: vscode.Range; - comments: IComment[]; - collapsibleState: vscode.CommentThreadCollapsibleState; -} - -export function getDocumentThreadDatas( - uri: vscode.Uri, - isBase: boolean, - fileChange: (RemoteFileChangeNode | InMemFileChangeNode | GitFileChangeNode), - matchingComments: IComment[]): ThreadData[] { - - if (!fileChange || fileChange instanceof RemoteFileChangeNode) { - return []; - } - - const sections = groupBy(matchingComments, comment => String(comment.position)); - const threads: ThreadData[] = []; - - for (const i in sections) { - const comments = sections[i]; - - const firstComment = comments[0]; - const commentAbsolutePosition = getAbsolutePosition(firstComment, fileChange.diffHunks, isBase); - - if (commentAbsolutePosition < 0) { - continue; - } - - const pos = new vscode.Position(getZeroBased(commentAbsolutePosition), 0); - const range = new vscode.Range(pos, pos); - - threads.push({ - threadId: firstComment.id.toString(), - uri: uri, - range, - comments, - collapsibleState: vscode.CommentThreadCollapsibleState.Expanded - }); - } - - return threads; -} - -export class PRNode extends TreeNode implements CommentHandler, vscode.CommentingRangeProvider, CommentReactionHandler { +export class PRNode extends TreeNode implements vscode.CommentingRangeProvider2 { static ID = 'PRNode'; private _fileChanges: (RemoteFileChangeNode | InMemFileChangeNode)[] | undefined; private _commentController?: vscode.CommentController; - public get commentController(): vscode.CommentController | undefined { - return this._commentController; - } - - private _prCommentController?: vscode.Disposable & { commentThreadCache: { [key: string]: GHPRCommentThread[] } }; - private _disposables: vscode.Disposable[] = []; private _inMemPRContentProvider?: vscode.Disposable; - private _refreshCommentsInProgress?: Promise<void>; - private _command: vscode.Command; - private _commentHandlerId: string; - public get command(): vscode.Command { return this._command; } @@ -105,56 +43,61 @@ export class PRNode extends TreeNode implements CommentHandler, vscode.Commentin this._command = newCommand; } + public get repository(): Repository { + return this._folderReposManager.repository; + } + constructor( - public parent: TreeNode | vscode.TreeView<TreeNode>, + parent: TreeNodeParent, private _folderReposManager: FolderRepositoryManager, public pullRequestModel: PullRequestModel, - private _isLocal: boolean + private _isLocal: boolean, + private _notificationProvider: NotificationsManager, + private _prsTreeModel: PrsTreeModel, ) { - super(); - this._commentHandlerId = uuid(); - registerCommentHandler(this._commentHandlerId, this); + super(parent); + this.registerSinceReviewChange(); + 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 { - if (this.childrenDisposables && this.childrenDisposables.length) { - this.childrenDisposables.forEach(dp => dp.dispose()); - } - - const descriptionNode = new DescriptionNode(this, 'Description', { - light: Resource.icons.light.Description, - dark: Resource.icons.dark.Description - }, this.pullRequestModel); + try { if (!this.pullRequestModel.isResolved()) { - return [descriptionNode]; + return []; } - this._fileChanges = await this.resolveFileChanges(); + [, this._fileChanges, ,] = await Promise.all([ + this.pullRequestModel.initializePullRequestFileViewState(), + this.resolveFileChangeNodes(), + (!this._commentController) ? this.resolvePRCommentController() : new Promise<void>(resolve => resolve()), + this.pullRequestModel.validateDraftMode() + ]); if (!this._inMemPRContentProvider) { - this._inMemPRContentProvider = getInMemPRContentProvider().registerTextDocumentContentProvider(this.pullRequestModel.number, this.provideDocumentContent.bind(this)); - } - - // The review manager will register a document comment's controller, so the node does not need to - if (!this.pullRequestModel.equals(this._folderReposManager.activePullRequest)) { - if (!this._prCommentController || !this._commentController) { - await this.resolvePRCommentController(); + this._inMemPRContentProvider = getInMemPRFileSystemProvider()?.registerTextDocumentContentProvider( + this.pullRequestModel.number, + this.provideDocumentContent.bind(this), + ); + if (this._inMemPRContentProvider) { + this._register(this._inMemPRContentProvider); } - - await this.refreshExistingPREditors(vscode.window.visibleTextEditors, true); - await this.pullRequestModel.validateDraftMode(); - await this.refreshContextKey(vscode.window.activeTextEditor); - } else { - await this.pullRequestModel.githubRepository.ensureCommentsController(); - this.pullRequestModel.githubRepository.commentsHandler!.clearCommentThreadCache(this.pullRequestModel.number); } - const result: TreeNode[] = [descriptionNode]; - const layout = vscode.workspace.getConfiguration('githubPullRequests').get<string>('fileListLayout'); + 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, ''); @@ -162,7 +105,8 @@ export class PRNode extends TreeNode implements CommentHandler, vscode.Commentin dirNode.finalize(); if (dirNode.label === '') { // nothing on the root changed, pull children to parent - result.push(...dirNode.children); + dirNode._children.forEach(child => { child.parent = this; }); + result.push(...dirNode._children); } else { result.push(dirNode); } @@ -171,833 +115,330 @@ export class PRNode extends TreeNode implements CommentHandler, vscode.Commentin result.push(...this._fileChanges); } - this.childrenDisposables = result; + if (this.pullRequestModel.showChangesSinceReview !== undefined) { + this.reopenNewPrDiffs(this.pullRequestModel); + } + + 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. + if (!this.pullRequestModel.reviewThreadsCacheReady) { + this.pullRequestModel.initializeReviewThreadCache(); + } + return result; } catch (e) { - Logger.appendLine(e); + Logger.error(`Error getting children ${e}: ${e.message}`, PRNode.ID); return []; } } - private async resolvePRCommentController(): Promise<vscode.Disposable & { commentThreadCache: { [key: string]: GHPRCommentThread[] } }> { - if (this._prCommentController) { - return this._prCommentController; + protected registerSinceReviewChange() { + 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}`) || e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${PULL_REQUEST_AVATAR_DISPLAY}`)) { + this.refresh(); + } + })); + } + + public async reopenNewPrDiffs(pullRequest: PullRequestModel) { + let hasOpenDiff: boolean = false; + vscode.window.tabGroups.all.map(tabGroup => { + tabGroup.tabs.map(tab => { + if ( + tab.input instanceof vscode.TabInputTextDiff && + tab.input.original.scheme === Schemes.Pr && + tab.input.modified.scheme === Schemes.Pr && + this._fileChanges + ) { + for (const localChange of this._fileChanges) { + + const originalParams = fromPRUri(tab.input.original); + const modifiedParams = fromPRUri(tab.input.modified); + const newLocalChangeParams = fromPRUri(localChange.changeModel.filePath); + if ( + originalParams?.prNumber === pullRequest.number && + modifiedParams?.prNumber === pullRequest.number && + localChange.fileName === modifiedParams.fileName && + newLocalChangeParams?.headCommit !== modifiedParams.headCommit + ) { + hasOpenDiff = true; + vscode.window.tabGroups.close(tab).then(_ => localChange.openDiff(this._folderReposManager, { preview: tab.isPreview })); + break; + } + } + } + }); + }); + if (pullRequest.showChangesSinceReview && !hasOpenDiff && this._fileChanges && this._fileChanges.length && !pullRequest.isActive) { + this._fileChanges[0].openDiff(this._folderReposManager, { preview: true }); + } + } + + private async resolvePRCommentController(): Promise<void> { + // If the current branch is this PR's branch, then we can rely on the review comment controller instead. + if (this.pullRequestModel.equals(this._folderReposManager.activePullRequest)) { + return; } await this.pullRequestModel.githubRepository.ensureCommentsController(); this._commentController = this.pullRequestModel.githubRepository.commentsController!; - this._prCommentController = this.pullRequestModel.githubRepository.commentsHandler!.registerCommentController(this.pullRequestModel.number, this); - this.registerListeners(); + this._register(this.pullRequestModel.githubRepository.commentsHandler!.registerCommentingRangeProvider( + this.pullRequestModel.number, + this + )); - return this._prCommentController; + 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 => { + 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); + fileChange.comments.forEach(c => (c.isDraft = newDraftMode)); } }); } - - const commentThreadCache = (await this.resolvePRCommentController()).commentThreadCache; - for (const fileName in commentThreadCache) { - commentThreadCache[fileName].forEach(thread => { - updateCommentReviewState(thread, newDraftMode); - }); - } - })); - - this._disposables.push(vscode.window.onDidChangeVisibleTextEditors(async e => { - // Create Comment Threads when the editor is visible - // Dispose when the editor is invisible and remove them from the cache map - // Comment Threads in cache map is updated only when users trigger refresh - if (!this._refreshCommentsInProgress) { - this._refreshCommentsInProgress = this.refreshExistingPREditors(e, false); - } else { - this._refreshCommentsInProgress = this._refreshCommentsInProgress.then(async _ => { - return await this.refreshExistingPREditors(e, false); - }); - } - })); - - this._disposables.push(vscode.window.onDidChangeActiveTextEditor(async e => { - await this.refreshContextKey(e); })); } - private async getFileChanges(): Promise<(RemoteFileChangeNode | InMemFileChangeNode)[]> { - if (!this._fileChanges) { - this._fileChanges = await this.resolveFileChanges(); + public async getFileChanges(noCache: boolean | void): Promise<(RemoteFileChangeNode | InMemFileChangeNode)[]> { + if (!this._fileChanges || noCache) { + this._fileChanges = await this.resolveFileChangeNodes(); } return this._fileChanges; } - private async resolveFileChanges(): Promise<(RemoteFileChangeNode | InMemFileChangeNode)[]> { + private async resolveFileChangeNodes(): Promise<(RemoteFileChangeNode | InMemFileChangeNode)[]> { if (!this.pullRequestModel.isResolved()) { return []; } - const comments = await this.pullRequestModel.getReviewComments(); - const data = await this.pullRequestModel.getFileChangesInfo(); + // If this PR is the the current PR, then we should be careful to use + // 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) && (this._folderReposManager.activePullRequest.fileChanges.size > 0)) { + this.pullRequestModel = this._folderReposManager.activePullRequest; + rawChanges.push(...this._folderReposManager.activePullRequest.fileChanges.values()); + } else { + rawChanges.push(...await this.pullRequestModel.getFileChangesInfo()); + } - // Merge base is set as part of getPullRequestFileChangesInfo + // Merge base is set as part of getFileChangesInfo const mergeBase = this.pullRequestModel.mergeBase; if (!mergeBase) { return []; } - const rawChanges = await parseDiff(data, this._folderReposManager.repository, mergeBase); - return rawChanges.map(change => { - const headCommit = this.pullRequestModel.head!.sha; - const parentFileName = change.status === GitChangeType.RENAME ? change.previousFileName! : change.fileName; if (change instanceof SlimFileChange) { + const changeModel = new RemoteFileChangeModel(this._folderReposManager, change, this.pullRequestModel); return new RemoteFileChangeNode( this, - this.pullRequestModel, - change.status, - change.fileName, - change.previousFileName, - change.blobUrl, - toPRUri(vscode.Uri.file(path.resolve(this._folderReposManager.repository.rootUri.fsPath, change.fileName)), this.pullRequestModel, change.baseCommit, headCommit, change.fileName, false, change.status), - toPRUri(vscode.Uri.file(path.resolve(this._folderReposManager.repository.rootUri.fsPath, parentFileName)), this.pullRequestModel, change.baseCommit, headCommit, change.fileName, true, change.status), + this._folderReposManager, + this.pullRequestModel as (PullRequestModel & IResolvedPullRequestModel), + changeModel ); } + const changeModel = new InMemFileChangeModel(this._folderReposManager, + this.pullRequestModel as (PullRequestModel & IResolvedPullRequestModel), + change, isCurrentPR, mergeBase); const changedItem = new InMemFileChangeNode( + this._folderReposManager, this, - this.pullRequestModel, - change.status, - change.fileName, - change.previousFileName, - change.blobUrl, - toPRUri(vscode.Uri.file(path.resolve(this._folderReposManager.repository.rootUri.fsPath, change.fileName)), this.pullRequestModel, change.baseCommit, headCommit, change.fileName, false, change.status), - toPRUri(vscode.Uri.file(path.resolve(this._folderReposManager.repository.rootUri.fsPath, parentFileName)), this.pullRequestModel, change.baseCommit, headCommit, change.fileName, true, change.status), - change.isPartial, - change.patch, - change.diffHunks, - comments.filter(comment => comment.path === change.fileName && comment.position !== null), + this.pullRequestModel as (PullRequestModel & IResolvedPullRequestModel), + changeModel ); return changedItem; }); } - async refreshExistingPREditors(editors: vscode.TextEditor[], incremental: boolean): Promise<void> { - let currentPRDocuments = editors.filter(editor => { - if (editor.document.uri.scheme !== 'pr') { - return false; - } - - const params = fromPRUri(editor.document.uri); - - if (!params || params.prNumber !== this.pullRequestModel.number) { - return false; - } - - return true; - }).map(editor => { - return { - fileName: fromPRUri(editor.document.uri)!.fileName, - document: editor.document - }; - }); - - const commentThreadCache = (await this.resolvePRCommentController()).commentThreadCache; - - for (const fileName in commentThreadCache) { - const commentThreads = commentThreadCache[fileName]; - - const matchedEditor = currentPRDocuments.find(editor => editor.fileName === fileName); - - if (!matchedEditor) { - commentThreads.forEach(thread => thread.dispose!()); - delete commentThreadCache[fileName]; - } - } - - if (!incremental) { - // it's triggered by file opening, so we only take care newly opened documents. - currentPRDocuments = currentPRDocuments.filter(editor => commentThreadCache[editor.fileName] === undefined); - } - - currentPRDocuments = uniqBy(currentPRDocuments, editor => editor.fileName); - - if (currentPRDocuments.length) { - const fileChanges = await this.getFileChanges(); - await this.pullRequestModel.validateDraftMode(); - currentPRDocuments.forEach(editor => { - const fileChange = fileChanges.find(fc => fc.fileName === editor.fileName); - - if (!fileChange || fileChange instanceof RemoteFileChangeNode) { - return; - } - - const parentFilePath = fileChange.parentFilePath; - const filePath = fileChange.filePath; - - const newLeftCommentThreads = getDocumentThreadDatas(parentFilePath, true, fileChange, fileChange.comments); - const newRightSideCommentThreads = getDocumentThreadDatas(filePath, false, fileChange, fileChange.comments); - - let oldCommentThreads: GHPRCommentThread[] = []; - - if (incremental) { - const cachedThreads = commentThreadCache[editor.fileName] || []; - const oldLeftSideCommentThreads = cachedThreads.filter(thread => thread.uri.toString() === parentFilePath.toString()); - const oldRightSideCommentThreads = cachedThreads.filter(thread => thread.uri.toString() === filePath.toString()); - - oldCommentThreads = [...oldLeftSideCommentThreads, ...oldRightSideCommentThreads]; - } - - this.updateFileChangeCommentThreads(oldCommentThreads, [...newLeftCommentThreads, ...newRightSideCommentThreads], fileChange, commentThreadCache); - }); + 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 _getBaseIcon(): Promise<vscode.Uri | vscode.ThemeIcon> { + const { state, isDraft } = this.pullRequestModel; + const iconMode = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<PullRequestAvatarDisplay>(PULL_REQUEST_AVATAR_DISPLAY, 'author'); + if (iconMode === 'state') { + return new vscode.ThemeIcon( + state === GithubItemStateEnum.Closed ? 'git-pull-request-closed' + : state === GithubItemStateEnum.Merged ? 'git-merge' + : isDraft ? 'git-pull-request-draft' + : 'git-pull-request', + new vscode.ThemeColor( + state === GithubItemStateEnum.Closed ? 'pullRequests.closed' + : state === GithubItemStateEnum.Merged ? 'pullRequests.merged' + : isDraft ? 'pullRequests.draft' + : 'pullRequests.open' + ) + ); + } else if (iconMode === 'generic') { + return new vscode.ThemeIcon('github'); + } + return this._getAuthorIcon(); + } + + 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._getBaseIcon(); + } + 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._getBaseIcon(); + } + } + + private _getLabel(): string { + const currentBranchIsForThisPR = this.pullRequestModel.equals(this._folderReposManager.activePullRequest); + const { title, number, author, isDraft } = this.pullRequestModel; + let label = ''; + if (currentBranchIsForThisPR) { + label += '$(check) '; } - } - private async refreshContextKey(editor: vscode.TextEditor | undefined) { - if (!editor) { - return; + if ( + vscode.workspace + .getConfiguration(PR_SETTINGS_NAMESPACE) + .get<boolean>(SHOW_PULL_REQUEST_NUMBER_IN_TREE, false) + ) { + label += `#${number}: `; } - const editorUri = editor.document.uri; - if (editorUri.scheme !== 'pr') { - return; + 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 params = fromPRUri(editorUri); - if (!params || params.prNumber !== this.pullRequestModel.number) { - return; + const iconMode = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<PullRequestAvatarDisplay>(PULL_REQUEST_AVATAR_DISPLAY, 'author'); + if (isDraft && iconMode !== 'state') { + label = `_${label}_`; } - this.setContextKey(this.pullRequestModel.hasPendingReview); - } - - private setContextKey(inDraftMode: boolean): void { - vscode.commands.executeCommand('setContext', 'prInDraft', inDraftMode); + return label; } - async revealComment(comment: IComment) { - const fileChange = (await this.getFileChanges()).find(fc => { - if (fc.fileName !== comment.path) { - return false; - } - - if (!fc.pullRequest.isResolved()) { - return false; - } - - if (fc.pullRequest.head.sha !== comment.commitId) { - return false; - } - - return true; - }); - - if (fileChange) { - await this.reveal(fileChange, { focus: true }); - if (!fileChange.command.arguments) { - return; - } - if (fileChange instanceof InMemFileChangeNode) { - const lineNumber = fileChange.getCommentPosition(comment); - const opts = fileChange.opts; - opts.selection = new vscode.Range(lineNumber, 0, lineNumber, 0); - fileChange.opts = opts; - await vscode.commands.executeCommand(fileChange.command.command, fileChange); - } else { - await vscode.commands.executeCommand(fileChange.command.command, ...fileChange.command.arguments!); - } - } - } - - getTreeItem(): vscode.TreeItem { + 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; - - const labelPrefix = (currentBranchIsForThisPR ? '✓ ' : ''); - const tooltipPrefix = (currentBranchIsForThisPR ? 'Current Branch * ' : ''); - const formattedPRNumber = number.toString(); - const label = `${labelPrefix}${title}`; - const tooltip = `${tooltipPrefix}${title} (#${formattedPRNumber}) by @${login}`; - const description = `#${formattedPRNumber}${isDraft ? '(draft)' : ''} by @${login}`; + 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.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'), - iconPath: this.pullRequestModel.userAvatarUri - ? this.pullRequestModel.userAvatarUri - : { light: Resource.icons.light.Avatar, dark: Resource.icons.dark.Avatar } + contextValue: + 'pullrequest' + + (this._isLocal ? ':local' : '') + + (currentBranchIsForThisPR ? ':active' : ':nonactive') + + (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 ${number}: ${title} by ${login}` + }, + resourceUri: createPRNodeUri(this.pullRequestModel, this.parent instanceof CategoryTreeNode && this.parent.isCopilot ? true : undefined), + command }; } - // #endregion - - // #region Helper - hasCommentThread(thread: GHPRCommentThread): boolean { - if (thread.uri.scheme !== 'pr') { - return false; - } - - if (this._folderReposManager.activePullRequest && this._folderReposManager.activePullRequest.number === this.pullRequestModel.number) { - return false; - } - - const params = fromPRUri(thread.uri); - - if (!params || params.prNumber !== this.pullRequestModel.number) { - return false; - } - - return true; - } - - private createCommentThreads(fileName: string, commentThreads: ThreadData[], commentThreadCache: { [key: string]: GHPRCommentThread[] }) { - const threads = commentThreads.map(thread => createVSCodeCommentThread(thread, this._commentController!)); - commentThreadCache[fileName] = threads; - } - - private updateCommentThreadComments(thread: GHPRCommentThread, newComments: (GHPRComment | TemporaryComment)[]) { - thread.comments = newComments; - updateCommentThreadLabel(thread); - } - - private async findMatchingFileNode(uri: vscode.Uri): Promise<InMemFileChangeNode> { - const params = fromPRUri(uri); - - if (!params) { - throw new Error(`${uri.toString()} is not valid PR document`); - } - - const fileChange = (await this.getFileChanges()).find(change => change.fileName === params.fileName); - - if (!fileChange) { - throw new Error('No matching file found'); - } - - if (fileChange instanceof RemoteFileChangeNode) { - throw new Error('Comments not supported on remote file changes'); - } - - return fileChange; - } - - /** - * Calculate the position of the comment within the diff of a file. - * @param fileChange The file change information - * @param thread The new comment thread, which has position information about where it is in the content displayed in the editor - */ - private calculateCommentPosition(fileChange: InMemFileChangeNode, thread: GHPRCommentThread): number { - const uri = thread.uri; - const params = fromPRUri(uri); - - const isBase = !!(params && params.isBase); - const position = mapHeadLineToDiffHunkPosition(fileChange.diffHunks, '', thread.range.start.line + 1, isBase); - - if (position < 0) { - throw new Error('Comment position cannot be negative'); - } - - return position; - } - - // #endregion - - async provideCommentingRanges(document: vscode.TextDocument, token: vscode.CancellationToken): Promise<vscode.Range[] | undefined> { - if (document.uri.scheme === 'pr') { + 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); if (!params || params.prNumber !== this.pullRequestModel.number) { - return; + return undefined; } - const fileChange = (await this.getFileChanges()).find(change => change.fileName === params.fileName); + const fileChange = (await this.getFileChanges()).find(change => change.changeModel.fileName === params.fileName); if (!fileChange || fileChange instanceof RemoteFileChangeNode) { - return; + return undefined; } - return getCommentingRanges(fileChange.diffHunks, params.isBase); + return { ranges: getCommentingRanges(await fileChange.changeModel.diffHunks(), params.isBase, PRNode.ID), enableFileComments: true }; } - } - - // #endregion - // #region Incremental updates - private updateFileChangeCommentThreads(oldCommentThreads: GHPRCommentThread[], newCommentThreads: ThreadData[], newFileChange: InMemFileChangeNode, commentThreadCache: { [key: string]: GHPRCommentThread[] }) { - // remove - oldCommentThreads.forEach(thread => { - // No current threads match old thread, it has been removed - const matchingThreads = newCommentThreads && newCommentThreads.filter(newThread => newThread.threadId === thread.threadId); - if (!matchingThreads.length) { - thread.dispose!(); - } - }); - - if (newCommentThreads && newCommentThreads.length) { - const added: ThreadData[] = []; - newCommentThreads.forEach(thread => { - const matchingCommentThreads = oldCommentThreads.filter(oldComment => oldComment.threadId === thread.threadId); - - if (matchingCommentThreads.length === 0) { - added.push(thread); - if (thread.uri.scheme === 'file') { - thread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed; - } - } - - matchingCommentThreads.forEach(existingThread => { - existingThread.comments = existingThread.comments.map(cmt => { - if (cmt instanceof TemporaryComment) { - // If the body of the temporary comment already matches the comment, then replace it. - // Otherwise, retain the temporary comment. - const matchingComment = thread.comments.find(c => c.body === cmt.body); - if (matchingComment) { - return new GHPRComment(matchingComment, existingThread); - } - - return cmt; - } - - // Update existing comments - const matchedComment = thread.comments.find(c => c.id.toString() === cmt.commentId); - if (matchedComment) { - return new GHPRComment(matchedComment, existingThread); - } - - // Remove comments that are no longer present - return undefined; - }).filter((c: TemporaryComment | GHPRComment | undefined): c is GHPRComment | TemporaryComment => !!c); - - const addedComments = thread.comments.filter(cmt => !existingThread.comments.some(existingComment => existingComment instanceof GHPRComment && existingComment.commentId === cmt.id.toString())); - existingThread.comments = [...existingThread.comments, ...addedComments.map(comment => new GHPRComment(comment, existingThread))]; - }); - }); - - if (added.length) { - this.createCommentThreads(newFileChange.fileName, added, commentThreadCache); - } - } + return undefined; } - // #endregion - // #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 ''; } - const fileChange = (await this.getFileChanges()).find(contentChange => contentChange.fileName === params.fileName); - if (!fileChange) { - Logger.appendLine(`PR> can not find content for document ${uri.toString()}`); - return ''; - } + const fileChange = (await this.getFileChanges()).find( + contentChange => contentChange.changeModel.fileName === params.fileName, + )?.changeModel; - if ((params.isBase && fileChange.status === GitChangeType.ADD) || (!params.isBase && fileChange.status === GitChangeType.DELETE)) { + if (!fileChange) { + Logger.appendLine(`Can not find content for document ${uri.toString()}`, 'PR'); return ''; } - if (fileChange instanceof RemoteFileChangeNode || fileChange.isPartial) { - try { - if (params.isBase) { - return this.pullRequestModel.getFile(fileChange.previousFileName || fileChange.fileName, params.baseCommit); - } else { - return this.pullRequestModel.getFile(fileChange.fileName, params.headCommit); - } - } catch (e) { - Logger.appendLine(`PR> Fetching file content failed: ${e}`); - vscode.window - .showWarningMessage('Opening this file locally failed. Would you like to view it on GitHub?', 'Open in GitHub') - .then(result => { - if (result === 'Open in GitHub') { - vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(fileChange.blobUrl)); - } - }); - return ''; - } - } - - if (fileChange instanceof InMemFileChangeNode) { - const readContentFromDiffHunk = fileChange.status === GitChangeType.ADD || fileChange.status === GitChangeType.DELETE; - - if (readContentFromDiffHunk) { - if (params.isBase) { - // left - const left = []; - for (let i = 0; i < fileChange.diffHunks.length; i++) { - for (let j = 0; j < fileChange.diffHunks[i].diffLines.length; j++) { - const diffLine = fileChange.diffHunks[i].diffLines[j]; - if (diffLine.type === DiffChangeType.Add) { - // nothing - } else if (diffLine.type === DiffChangeType.Delete) { - left.push(diffLine.text); - } else if (diffLine.type === DiffChangeType.Control) { - // nothing - } else { - left.push(diffLine.text); - } - } - } - - return left.join('\n'); - } else { - const right = []; - for (let i = 0; i < fileChange.diffHunks.length; i++) { - for (let j = 0; j < fileChange.diffHunks[i].diffLines.length; j++) { - const diffLine = fileChange.diffHunks[i].diffLines[j]; - if (diffLine.type === DiffChangeType.Add) { - right.push(diffLine.text); - } else if (diffLine.type === DiffChangeType.Delete) { - // nothing - } else if (diffLine.type === DiffChangeType.Control) { - // nothing - } else { - right.push(diffLine.text); - } - } - } - - return right.join('\n'); - } - } else { - const originalFileName = fileChange.status === GitChangeType.RENAME ? fileChange.previousFileName : fileChange.fileName; - const originalFilePath = path.join(this._folderReposManager.repository.rootUri.fsPath, originalFileName!); - const originalContent = await this._folderReposManager.repository.show(params.baseCommit, originalFilePath); - - if (params.isBase) { - return originalContent; - } else { - return getModifiedContentFromDiffHunk(originalContent, fileChange.patch); - } - } - } - - return ''; - } - - // #endregion - - // #region comment - public async createOrReplyComment(thread: GHPRCommentThread, input: string, inDraft?: boolean) { - const hasExistingComments = thread.comments.length; - const isDraft = inDraft !== undefined ? inDraft : this.pullRequestModel.hasPendingReview; - const temporaryCommentId = this.optimisticallyAddComment(thread, input, isDraft); - - try { - const fileChange = await this.findMatchingFileNode(thread.uri); - const rawComment = hasExistingComments - ? await this.reply(thread, input) - : await this.createFirstCommentInThread(thread, input, fileChange); - - fileChange.update(fileChange.comments.concat(rawComment!)); - - this.replaceTemporaryComment(thread, rawComment!, temporaryCommentId); - } catch (e) { - vscode.window.showErrorMessage(`Creating comment failed: ${e}`); - - thread.comments = thread.comments.map(c => { - if (c instanceof TemporaryComment && c.id === temporaryCommentId) { - c.mode = vscode.CommentMode.Editing; - } - - return c; - }); - } - } - - private optimisticallyAddComment(thread: GHPRCommentThread, input: string, inDraft: boolean): number { - const currentUser = this._folderReposManager.getCurrentUser(this.pullRequestModel); - const comment = new TemporaryComment(thread, input, inDraft, currentUser); - this.updateCommentThreadComments(thread, [...thread.comments, comment]); - return comment.id; - } - - private optimisticallyEditComment(thread: GHPRCommentThread, comment: GHPRComment): number { - const currentUser = this._folderReposManager.getCurrentUser(this.pullRequestModel); - const temporaryComment = new TemporaryComment(thread, comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, !!comment.label, currentUser, comment); - thread.comments = thread.comments.map(c => { - if (c instanceof GHPRComment && c.commentId === comment.commentId) { - return temporaryComment; - } - - return c; - }); - - return temporaryComment.id; - } - - private replaceTemporaryComment(thread: GHPRCommentThread, realComment: IComment, temporaryCommentId: number): void { - thread.comments = thread.comments.map(c => { - if (c instanceof TemporaryComment && c.id === temporaryCommentId) { - return new GHPRComment(realComment, thread); - } - - return c; - }); - } - - private reply(thread: GHPRCommentThread, input: string): Promise<IComment | undefined> { - const replyingTo = thread.comments[0]; - if (replyingTo instanceof GHPRComment) { - return this.pullRequestModel.createReviewCommentReply(input, replyingTo._rawComment); - } else { - // TODO can we do better? - throw new Error('Cannot respond to temporary comment'); - } - } - - private async updateCommentThreadCache(thread: GHPRCommentThread, fileChange: InMemFileChangeNode, comment: IComment): Promise<void> { - const commentThreadCache = (await this.resolvePRCommentController()).commentThreadCache; - const existingThreads = commentThreadCache[fileChange.fileName]; - if (existingThreads) { - commentThreadCache[fileChange.fileName] = [...existingThreads, thread]; - } else { - commentThreadCache[fileChange.fileName] = [thread]; - } - } - - private async createFirstCommentInThread(thread: GHPRCommentThread, input: string, fileChange: InMemFileChangeNode): Promise<IComment | undefined> { - const position = this.calculateCommentPosition(fileChange, thread); - const rawComment = await this.pullRequestModel.createReviewComment(input, fileChange.fileName, position); - - // Add new thread to cache - this.updateCommentThreadCache(thread, fileChange, rawComment!); - - return rawComment; - } - - public async editComment(thread: GHPRCommentThread, comment: GHPRComment | TemporaryComment): Promise<void> { - const fileChange = await this.findMatchingFileNode(thread.uri); - - if (comment instanceof GHPRComment) { - const temporaryCommentId = this.optimisticallyEditComment(thread, comment); - try { - const rawComment = await this.pullRequestModel.editReviewComment(comment._rawComment, comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body); - - const index = fileChange.comments.findIndex(c => c.id.toString() === comment.commentId); - if (index > -1) { - fileChange.comments.splice(index, 1, rawComment); - } - - this.replaceTemporaryComment(thread, rawComment!, temporaryCommentId); - } catch (e) { - vscode.window.showErrorMessage(`Editing comment failed ${e}`); - - thread.comments = thread.comments.map(c => { - if (c instanceof TemporaryComment && c.id === temporaryCommentId) { - return new GHPRComment(comment._rawComment, thread); - } - - return c; - }); - } - } else { - this.createOrReplyComment(thread, comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body); - } - } - - public async deleteComment(thread: GHPRCommentThread, comment: GHPRComment | TemporaryComment): Promise<void> { - if (comment instanceof GHPRComment) { - await this.pullRequestModel.deleteReviewComment(comment.commentId); - const fileChange = await this.findMatchingFileNode(thread.uri); - const index = fileChange.comments.findIndex(c => c.id.toString() === comment.commentId); - if (index > -1) { - fileChange.comments.splice(index, 1); - } - - thread.comments = thread.comments.filter(c => c instanceof GHPRComment && c.commentId !== comment.commentId); - - if (thread.comments.length === 0) { - const rawComment = comment._rawComment; - - if (rawComment.path) { - const commentThreadCache = (await this.resolvePRCommentController()).commentThreadCache; - const threadIndex = commentThreadCache[rawComment.path].findIndex(cachedThread => cachedThread.threadId === thread.threadId); - commentThreadCache[rawComment.path].splice(threadIndex, 1); - } - - thread.dispose!(); - } - - if (fileChange.comments.length === 0) { - fileChange.update(fileChange.comments); - } - } else { - thread.comments = thread.comments.filter(c => c instanceof TemporaryComment && c.id === comment.id); - } - - await this.pullRequestModel.validateDraftMode(); - } - // #endregion - - // #region Review - public async startReview(thread: GHPRCommentThread, input: string): Promise<void> { - const temporaryCommentId = this.optimisticallyAddComment(thread, input, true); - - try { - const fileChange = await this.findMatchingFileNode(thread.uri); - const position = this.calculateCommentPosition(fileChange, thread); - const newComment = await this.pullRequestModel.startReview( - { - body: input, - path: fileChange.fileName, - position - } - ); - - await this.updateCommentThreadCache(thread, fileChange, newComment); - this.replaceTemporaryComment(thread, newComment, temporaryCommentId); - fileChange.update(fileChange.comments.concat(newComment)); - this.setContextKey(true); - } catch (e) { - vscode.window.showErrorMessage(`Starting a review failed: ${e}`); - - thread.comments = thread.comments.map(c => { - if (c instanceof TemporaryComment && c.id === temporaryCommentId) { - c.mode = vscode.CommentMode.Editing; - } - - return c; - }); - } - } - - public async finishReview(thread: GHPRCommentThread, input: string): Promise<void> { - try { - await this.createOrReplyComment(thread, input, false); - await this.pullRequestModel.submitReview(); - this.setContextKey(false); - } catch (e) { - vscode.window.showErrorMessage(`Failed to submit the review: ${e}`); - } + return provideDocumentContentForChangeModel(this._folderReposManager, this.pullRequestModel, params, fileChange); } - public async deleteReview(): Promise<void> { - const { deletedReviewId, deletedReviewComments } = await this.pullRequestModel.deleteReview(); - - // Group comments by file and then position to create threads. - const commentsByPath = groupBy(deletedReviewComments, comment => comment.path || ''); - - for (const filePath in commentsByPath) { - const matchingFileChange = (await this.getFileChanges()).find(fileChange => fileChange.fileName === filePath); - - if (matchingFileChange && matchingFileChange instanceof InMemFileChangeNode) { - matchingFileChange.comments = matchingFileChange.comments.filter(comment => comment.pullRequestReviewId !== deletedReviewId); - matchingFileChange.update(matchingFileChange.comments); - const commentThreadCache = (await this.resolvePRCommentController()).commentThreadCache; - if (commentThreadCache[matchingFileChange.fileName]) { - const threads: GHPRCommentThread[] = []; - - commentThreadCache[matchingFileChange.fileName].forEach(thread => { - this.updateCommentThreadComments(thread, thread.comments.filter((comment: GHPRComment) => !deletedReviewComments.some(deletedComment => deletedComment.id.toString() === comment.commentId))); - if (!thread.comments.length) { - thread.dispose!(); - } else { - threads.push(thread); - } - }); - - if (threads.length) { - commentThreadCache[matchingFileChange.fileName] = threads; - } else { - delete commentThreadCache[matchingFileChange.fileName]; - } - } - } - } - - this.setContextKey(false); - } - - // #endregion - - // #region Reaction - async toggleReaction(comment: GHPRComment, reaction: vscode.CommentReaction): Promise<void> { - if (comment.parent!.uri.scheme !== 'pr') { - return; - } - - const params = fromPRUri(comment.parent!.uri); - - if (!params) { - return; - } - - const fileChange = await this.findMatchingFileNode(comment.parent!.uri); - const commentIndex = fileChange.comments.findIndex(c => c.id.toString() === comment.commentId); - - if (commentIndex < 0) { - return; - } - - let reactionGroups: ReactionGroup[]; - if (comment.reactions && !comment.reactions.find(ret => ret.label === reaction.label && !!ret.authorHasReacted)) { - // add reaction - const result = await this.pullRequestModel.addCommentReaction(comment._rawComment.graphNodeId, reaction); - reactionGroups = result.addReaction.subject.reactionGroups; - } else { - const result = await this.pullRequestModel.deleteCommentReaction(comment._rawComment.graphNodeId, reaction); - reactionGroups = result.removeReaction.subject.reactionGroups; - } - - fileChange.comments[commentIndex].reactions = parseGraphQLReaction(reactionGroups); - updateCommentReactions(comment, fileChange.comments[commentIndex].reactions!); - - const commentThreadCache = (await this.resolvePRCommentController()).commentThreadCache; - if (commentThreadCache[params.fileName]) { - commentThreadCache[params.fileName].forEach(thread => { - if (!thread.comments) { - return; - } - - if (thread.comments.find((cmt: GHPRComment) => cmt.commentId === comment.commentId)) { - // The following line is necessary to refresh the comments thread UI - // Read more: https://github.com/microsoft/vscode-pull-request-github/issues/1421#issuecomment-546995347 - thread.comments = thread.comments; - } - }); - } - } - - // #endregion - - dispose(): void { + override dispose(): void { super.dispose(); - - unregisterCommentHandler(this._commentHandlerId); - - if (this._inMemPRContentProvider) { - this._inMemPRContentProvider.dispose(); - } - - if (this._prCommentController) { - this._prCommentController.dispose(); - } - this._commentController = undefined; - - this._disposables.forEach(d => d.dispose()); } } diff --git a/src/view/treeNodes/repositoryChangesNode.ts b/src/view/treeNodes/repositoryChangesNode.ts index 392e91a596..df232a6bd6 100644 --- a/src/view/treeNodes/repositoryChangesNode.ts +++ b/src/view/treeNodes/repositoryChangesNode.ts @@ -4,40 +4,159 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { DescriptionNode } from './descriptionNode'; -import { FilesCategoryNode } from './filesCategoryNode'; -import { CommitsNode } from './commitsCategoryNode'; -import { TreeNode } from './treeNode'; -import { PullRequestModel } from '../../github/pullRequestModel'; +import { Repository } from '../../api/api'; +import Logger, { PR_TREE } from '../../common/logger'; +import { FILE_AUTO_REVEAL, PR_SETTINGS_NAMESPACE } from '../../common/settingKeys'; +import { DataUri, Schemes } from '../../common/uri'; import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; -import { IComment } from '../../common/comment'; -import { GitFileChangeNode, RemoteFileChangeNode } from './fileChangeNode'; +import { PullRequestModel } from '../../github/pullRequestModel'; +import { ProgressHelper } from '../progress'; +import { ReviewModel } from '../reviewModel'; +import { CommitsNode } from './commitsCategoryNode'; +import { FilesCategoryNode } from './filesCategoryNode'; +import { BaseTreeNode, TreeNode } from './treeNode'; +import { compareIgnoreCase } from '../../common/utils'; -export class RepositoryChangesNode extends DescriptionNode implements vscode.TreeItem { +export class RepositoryChangesNode extends TreeNode implements vscode.TreeItem { private _filesCategoryNode?: FilesCategoryNode; private _commitsCategoryNode?: CommitsNode; - public label: string; - readonly collapsibleState = vscode.TreeItemCollapsibleState.Expanded; + public command?: vscode.Command; public contextValue?: string; + public tooltip: string; + public iconPath: vscode.ThemeIcon | vscode.Uri | undefined; + readonly collapsibleState = vscode.TreeItemCollapsibleState.Expanded; + private isLocal: boolean; + public readonly repository: Repository; - constructor(public parent: TreeNode | vscode.TreeView<TreeNode>, - private _pullRequest: PullRequestModel, + constructor( + public override parent: BaseTreeNode, + public readonly pullRequestModel: PullRequestModel, private _pullRequestManager: FolderRepositoryManager, - private _comments: IComment[], - private _localFileChanges: (GitFileChangeNode | RemoteFileChangeNode)[]) { - super(parent, _pullRequest.title, _pullRequest.userAvatarUri!, _pullRequest); - this.label = this._pullRequest.title; + private _reviewModel: ReviewModel, + private _progress: ProgressHelper + ) { + 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._register(vscode.window.onDidChangeActiveTextEditor(e => { + if (this.isFileAutoRevealEnabled()) { + 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._register(this.parent.view.onDidChangeVisibility(_ => { + if (!this.isFileAutoRevealEnabled()) { + return; + } + const activeEditorUri = vscode.window.activeTextEditor?.document.uri.toString(); + this.revealActiveEditorInTree(activeEditorUri); + })); + + this._register(this.pullRequestModel.onDidChange(e => { + if (e.title || e.state) { + this.refresh(); + } + })); } - async getChildren(): Promise<TreeNode[]> { + private isFileAutoRevealEnabled(): boolean { + return vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<boolean>(FILE_AUTO_REVEAL, true); + } + + private revealActiveEditorInTree(activeEditorUri: string | undefined): void { + // File nodes are wired into the tree when FilesCategoryNode children are built. + // Skip reveal attempts before that to avoid invalid handle lookups. + if (!this._filesCategoryNode) { + return; + } + if (this.parent.view.visible && activeEditorUri) { + const matchingFile = this._reviewModel.localFileChanges.find(change => { + const changePath = change.changeModel.filePath.toString(); + if (process.platform === 'win32') { + return compareIgnoreCase(changePath, activeEditorUri) === 0; + } + return changePath === activeEditorUri; + }); + if (matchingFile && matchingFile.parent !== this.parent) { + this.reveal(matchingFile, { select: true }); + } + } + } + + override async getChildren(): Promise<TreeNode[]> { + await this._progress.progress; if (!this._filesCategoryNode || !this._commitsCategoryNode) { - this._filesCategoryNode = new FilesCategoryNode(this.parent, this._localFileChanges); - this._commitsCategoryNode = new CommitsNode(this.parent, this._pullRequestManager, this._pullRequest, this._comments); + Logger.appendLine(`Creating file and commit nodes for PR #${this.pullRequestModel.number}`, PR_TREE); + this._filesCategoryNode = new FilesCategoryNode(this.parent, this._reviewModel, this.pullRequestModel); + this._commitsCategoryNode = new CommitsNode( + this.parent, + this._pullRequestManager, + this.pullRequestModel, + ); } - return [this._filesCategoryNode, this._commitsCategoryNode]; + this._children = [this._filesCategoryNode, this._commitsCategoryNode]; + return this._children; } - getTreeItem(): vscode.TreeItem { + private setLabel() { + this.label = this.pullRequestModel.title; + if (this.label.length > 50) { + this.tooltip = this.label; + this.label = `${this.label.substring(0, 50)}...`; + } + } + + 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; } -} \ No newline at end of file + + 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 d55481c04a..399cbc1dca 100644 --- a/src/view/treeNodes/treeNode.ts +++ b/src/view/treeNodes/treeNode.ts @@ -4,39 +4,91 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { IComment } from '../../common/comment'; +import { Disposable, disposeAll } from '../../common/lifecycle'; +import Logger from '../../common/logger'; -export interface Revealable<T> { - reveal(element: T, options?: { select?: boolean, focus?: boolean, expand?: boolean | number }): Thenable<void>; - revealComment?(comment: IComment): Thenable<void>; +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 abstract class TreeNode implements vscode.Disposable { - childrenDisposables: vscode.Disposable[]; - parent: TreeNode | vscode.TreeView<TreeNode> | Revealable<TreeNode>; +export type TreeNodeParent = TreeNode | BaseTreeNode; + +export abstract class TreeNode extends Disposable { + protected _children: TreeNode[] | undefined; + childrenDisposables: vscode.Disposable[] = []; label?: string; + accessibilityInformation?: vscode.AccessibilityInformation; + id?: string; + description?: string | boolean; + + constructor(public parent: TreeNodeParent) { + super(); + } - constructor() { } - abstract getTreeItem(): vscode.TreeItem; + abstract getTreeItem(): vscode.TreeItem | Promise<vscode.TreeItem>; getParent(): TreeNode | undefined { if (this.parent instanceof TreeNode) { return this.parent; } } - async reveal(treeNode: TreeNode, options?: { select?: boolean, focus?: boolean, expand?: boolean | number }): Promise<void> { - return this.parent.reveal(treeNode || this, options); + 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 }, + ): Promise<void> { + try { + await this.parent.reveal(treeNode || this, options); + } catch (e) { + Logger.error(e, 'TreeNode'); + } + } + + refresh(treeNode?: TreeNode): void { + return this.parent.refresh(treeNode); } - async revealComment?(comment: IComment) { } + async cachedChildren(): Promise<TreeNode[]> { + if (this._children && this._children.length) { + return this._children; + } + return this.getChildren(); + } - async getChildren(): Promise<TreeNode[]> { + async getChildren(shouldDispose: boolean = true): Promise<TreeNode[]> { + if (this._children && this._children.length && shouldDispose) { + disposeAll(this._children); + } return []; } - dispose(): void { + updateFromCheckboxChanged(_newState: vscode.TreeItemCheckboxState): void { } + + override dispose(): void { + super.dispose(); if (this.childrenDisposables) { - this.childrenDisposables.forEach(dispose => dispose.dispose()); + disposeAll(this.childrenDisposables); } } +} + +export class LabelOnlyNode extends TreeNode { + public override readonly label: string = ''; + constructor(parent: TreeNodeParent, label: string) { + super(parent); + this.label = label; + } + getTreeItem(): vscode.TreeItem { + return new vscode.TreeItem(this.label); + } + } \ No newline at end of file diff --git a/src/view/treeNodes/treeUtils.ts b/src/view/treeNodes/treeUtils.ts new file mode 100644 index 0000000000..bd3a0fe7da --- /dev/null +++ b/src/view/treeNodes/treeUtils.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DirectoryTreeNode } from './directoryTreeNode'; +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[] = []; + + for (const [node, newState] of checkboxUpdates.items) { + if (node instanceof FileChangeNode) { + if (newState === vscode.TreeItemCheckboxState.Checked) { + checkedNodes.push(node); + } else { + uncheckedNodes.push(node); + } + node.updateFromCheckboxChanged(newState); + } else if (node instanceof DirectoryTreeNode) { + collectAllDescendants(node, newState, checkedNodes, uncheckedNodes); + } + } + + 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) { + selected.updateFromCheckboxChanged(vscode.TreeItemCheckboxState.Checked); + checkedNodes.push(selected); + } else if (selected.checkboxState?.state === vscode.TreeItemCheckboxState.Checked) { + selected.updateFromCheckboxChanged(vscode.TreeItemCheckboxState.Unchecked); + uncheckedNodes.push(selected); + } + } + } + } + + // Eagerly update ancestor directory checkbox states by walking from each + // affected file up through its parent directories. With manageCheckboxStateManually, + // we must set directory states before refresh rather than relying solely on + // getTreeItem(), since VS Code may not apply checkboxState changes during a refresh. + const allAffected = [...checkedNodes, ...uncheckedNodes]; + for (const node of allAffected) { + let parent = node.getParent(); + while (parent instanceof DirectoryTreeNode) { + parent.updateCheckboxFromChildren(); + parent = parent.getParent(); + } + } + + // Refresh the tree so checkbox visual state updates. + // Refreshing the topmost affected directory will cascade to all descendants. + const refreshedDirs = new Set<DirectoryTreeNode>(); + for (const node of allAffected) { + let topDir: DirectoryTreeNode | undefined; + let parent = node.getParent(); + while (parent instanceof DirectoryTreeNode) { + topDir = parent; + parent = parent.getParent(); + } + if (topDir && !refreshedDirs.has(topDir)) { + refreshedDirs.add(topDir); + topDir.refresh(topDir); + } + } + // If a directory was clicked directly, also refresh it + for (const [node] of checkboxUpdates.items) { + if (node instanceof DirectoryTreeNode && !refreshedDirs.has(node)) { + refreshedDirs.add(node); + node.refresh(node); + } + } + // For flat layout (files have no directory parent), refresh file nodes directly + for (const node of allAffected) { + const parent = node.getParent(); + if (!(parent instanceof DirectoryTreeNode)) { + node.refresh(node); + } + } + + // Send API requests without firing state change events (UI is already updated optimistically). + // This prevents race conditions where overlapping markFiles calls cause checkboxes to flicker. + if (checkedNodes.length > 0) { + const prModel = checkedNodes[0].pullRequest; + const filenames = checkedNodes.map(n => n.fileName); + prModel.markFiles(filenames, false, 'viewed').then(() => { + checkedNodes[0].refreshFileViewedContext(); + }); + } + if (uncheckedNodes.length > 0) { + const prModel = uncheckedNodes[0].pullRequest; + const filenames = uncheckedNodes.map(n => n.fileName); + prModel.markFiles(filenames, false, 'unviewed').then(() => { + uncheckedNodes[0].refreshFileViewedContext(); + }); + } + } + + function collectAllDescendants( + dirNode: DirectoryTreeNode, + newState: vscode.TreeItemCheckboxState, + checkedNodes: FileChangeNode[], + uncheckedNodes: FileChangeNode[] + ): void { + for (const child of dirNode._children) { + if (child instanceof FileChangeNode) { + if (newState === vscode.TreeItemCheckboxState.Checked) { + checkedNodes.push(child); + } else { + uncheckedNodes.push(child); + } + child.updateFromCheckboxChanged(newState); + } else if (child instanceof DirectoryTreeNode) { + collectAllDescendants(child, newState, checkedNodes, uncheckedNodes); + } + } + } +} \ No newline at end of file diff --git a/src/view/treeNodes/workspaceFolderNode.ts b/src/view/treeNodes/workspaceFolderNode.ts index 2150c4f434..46fd88e494 100644 --- a/src/view/treeNodes/workspaceFolderNode.ts +++ b/src/view/treeNodes/workspaceFolderNode.ts @@ -3,51 +3,93 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as vscode from 'vscode'; import * as path from 'path'; -import { PRType } from '../../github/interface'; -import { TreeNode } from './treeNode'; -import { FolderRepositoryManager, SETTINGS_NAMESPACE } from '../../github/folderRepositoryManager'; +import * as vscode from 'vscode'; +import { PR_SETTINGS_NAMESPACE, QUERIES } from '../../common/settingKeys'; import { ITelemetry } from '../../common/telemetry'; -import { CategoryTreeNode } from './categoryNode'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; +import { PRType } from '../../github/interface'; +import { PullRequestModel } from '../../github/pullRequestModel'; +import { PrsTreeModel } from '../prsTreeModel'; +import { CategoryTreeNode, isAllQuery, isLocalQuery } from './categoryNode'; +import { TreeNode, TreeNodeParent } from './treeNode'; +import { NotificationsManager } from '../../notifications/notificationsManager'; export interface IQueryInfo { label: string; query: string; } -export const QUERIES_SETTING = 'queries'; - export class WorkspaceFolderNode extends TreeNode implements vscode.TreeItem { - public readonly label: string; + 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: TreeNode | vscode.TreeView<TreeNode>, uri: vscode.Uri, private folderManager: FolderRepositoryManager, private telemetry: ITelemetry) { - super(); - this.parent = parent; + constructor( + parent: TreeNodeParent, + uri: vscode.Uri, + public readonly folderManager: FolderRepositoryManager, + private telemetry: ITelemetry, + private notificationProvider: NotificationsManager, + private context: vscode.ExtensionContext, + private readonly _prsTreeModel: PrsTreeModel + ) { + super(parent); this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; this.label = path.basename(uri.fsPath); + this.id = folderManager.repository.rootUri.toString(); + } + + 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 getQueries(folderManager: FolderRepositoryManager): IQueryInfo[] { - return vscode.workspace.getConfiguration(SETTINGS_NAMESPACE, folderManager.repository.rootUri).get<IQueryInfo[]>(QUERIES_SETTING) || []; + 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[]> { - return WorkspaceFolderNode.getCategoryTreeNodes(this.folderManager, this.telemetry, this); + 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(folderManager: FolderRepositoryManager, telemetry: ITelemetry, parent: TreeNode | vscode.TreeView<TreeNode>) { - const queryCategories = WorkspaceFolderNode.getQueries(folderManager).map(queryInfo => new CategoryTreeNode(parent, folderManager, telemetry, PRType.Query, queryInfo.label, queryInfo.query)); - return [ - new CategoryTreeNode(parent, folderManager, telemetry, PRType.LocalPullRequest), - ...queryCategories, - new CategoryTreeNode(parent, folderManager, telemetry, PRType.All) - ]; + public static async getCategoryTreeNodes( + folderManager: FolderRepositoryManager, + telemetry: ITelemetry, + parent: TreeNodeParent, + notificationProvider: NotificationsManager, + context: vscode.ExtensionContext, + prsTreeModel: PrsTreeModel, + ) { + 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)); + } + } + + return Array.from(queryCategories.values()); } } diff --git a/src/view/webviewViewCoordinator.ts b/src/view/webviewViewCoordinator.ts new file mode 100644 index 0000000000..da40fc4b3e --- /dev/null +++ b/src/view/webviewViewCoordinator.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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'; + +export class WebviewViewCoordinator extends Disposable { + private _webviewViewProvider?: PullRequestViewProvider; + private _pullRequestModel: Map<PullRequestModel, { folderRepositoryManager: FolderRepositoryManager, reviewManager: ReviewManager }> = new Map(); + private readonly _currentDisposables: Disposable[] = []; + + constructor(private _context: vscode.ExtensionContext) { + super(); + } + + public override dispose() { + super.dispose(); + this.reset(); + } + + reset() { + disposeAll(this._currentDisposables); + this._webviewViewProvider = undefined; + } + + private create(pullRequestModel: PullRequestModel, folderRepositoryManager: FolderRepositoryManager, reviewManager: ReviewManager) { + 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) { + if (replace) { + this._pullRequestModel.delete(replace); + } + this._pullRequestModel.set(pullRequestModel, { folderRepositoryManager, reviewManager }); + this.updatePullRequest(); + } + + private updatePullRequest() { + const pullRequestModel = Array.from(this._pullRequestModel.keys())[0]; + if (!pullRequestModel) { + this.reset(); + return; + } + const { folderRepositoryManager, reviewManager } = this._pullRequestModel.get(pullRequestModel)!; + if (!this._webviewViewProvider) { + this.create(pullRequestModel, folderRepositoryManager, reviewManager); + } else { + this._webviewViewProvider.updatePullRequest(pullRequestModel); + } + } + + public removePullRequest(pullRequestModel: PullRequestModel) { + const oldHead = Array.from(this._pullRequestModel.keys())[0]; + this._pullRequestModel.delete(pullRequestModel); + const newHead = Array.from(this._pullRequestModel.keys())[0]; + if (newHead !== oldHead) { + this.updatePullRequest(); + } + } + + public show(pullRequestModel: PullRequestModel) { + if (this._webviewViewProvider && (this._pullRequestModel.size > 0) && (Array.from(this._pullRequestModel.keys())[0] === pullRequestModel)) { + this._webviewViewProvider.show(); + } + } +} \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000000..87e0c3e4fd --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "incremental": true, + "isolatedModules": false, + "lib": ["es2019"], + "module": "esnext", + "moduleResolution": "node", + "noFallthroughCasesInSwitch": true, + "noImplicitAny": false, + "noImplicitReturns": false, + "noUnusedLocals": false, + "outDir": "dist", + "resolveJsonModule": true, + "rootDir": ".", + "skipLibCheck": true, + "sourceMap": true, + "strict": false, + "strictNullChecks": false, + "target": "es2019", + "useDefineForClassFields": true, + "noImplicitOverride": true + }, + "include": ["src", "webviews"], + "exclude": ["node_modules"] +} diff --git a/tsconfig.browser.json b/tsconfig.browser.json new file mode 100644 index 0000000000..db86274aac --- /dev/null +++ b/tsconfig.browser.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "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.json b/tsconfig.json index c8a8ec3650..c84f7118b4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,30 +1,17 @@ { + "extends": "./tsconfig.base.json", "compilerOptions": { - "module": "commonjs", - "target": "es6", - "outDir": "out", - "noUnusedLocals": true, - "lib": [ - "dom", - "esnext.asynciterable", - "es6" - ], - "sourceMap": true, - "rootDir": "./src", "jsx": "react", - "typeRoots": [ - "./node_modules/@types", - "./src/typings/" - ], - "noImplicitAny": true, + "jsxFactory": "vscpp", + "jsxFragmentFactory": "vscppf", "strictNullChecks": true, - "skipLibCheck": true + "tsBuildInfoFile": "tsconfig.tsbuildinfo", + "types": [ + "node" + ] }, - "include": [ - "src/**/*" - ], "exclude": [ "node_modules", - ".vscode-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 new file mode 100644 index 0000000000..dd680f25f5 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "lib": ["dom", "dom.iterable", "es2019"], + "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 new file mode 100644 index 0000000000..c806a45e06 --- /dev/null +++ b/tsconfig.webviews.json @@ -0,0 +1,18 @@ +{ + "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/tsfmt.json b/tsfmt.json new file mode 100644 index 0000000000..17c26bb085 --- /dev/null +++ b/tsfmt.json @@ -0,0 +1,17 @@ +{ + "tabSize": 4, + "indentSize": 4, + "convertTabsToSpaces": false, + "insertSpaceAfterCommaDelimiter": true, + "insertSpaceAfterSemicolonInForStatements": true, + "insertSpaceBeforeAndAfterBinaryOperators": true, + "insertSpaceAfterKeywordsInControlFlowStatements": true, + "insertSpaceAfterFunctionKeywordForAnonymousFunctions": true, + "insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": false, + "insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets": false, + "insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": false, + "insertSpaceAfterOpeningAndBeforeClosingEmptyBraces": true, + "insertSpaceBeforeFunctionParenthesis": false, + "placeOpenBraceOnNewLineForFunctions": false, + "placeOpenBraceOnNewLineForControlBlocks": false +} diff --git a/tslint.json b/tslint.json deleted file mode 100644 index af93590d6a..0000000000 --- a/tslint.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "rules": { - "import-spacing": true, - "indent": [ - true, - "tabs" - ], - "newline-before-return": false, - "no-consecutive-blank-lines": true, - "no-empty-line-after-opening-brace": false, - "no-irregular-whitespace": true, - "object-literal-key-quotes": [ - true, - "as-needed" - ], - "one-line": [ - true, - "check-open-brace", - "check-catch", - "check-else", - "check-whitespace" - ], - "quotemark": [ - true, - "single" - ], - "space-within-parens": true, - "typedef-whitespace": false, - "no-unused-expression": true, - "no-duplicate-variable": true, - "no-shadowed-variable": true, - "no-string-throw": true, - "no-bitwise": true, - "no-var-keyword": true, - "no-trailing-whitespace": true, - "curly": true, - "class-name": true, - "semicolon": [ - true, - "always" - ], - "triple-equals": true, - "whitespace": [ - true, - "check-branch" - ], - "prefer-const": true - }, - "linterOptions": { - "exclude": [ - "src/typings/**" - ] - } -} \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index 882e79de3e..26ea565bfc 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -3,123 +3,432 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +/* eslint-disable @typescript-eslint/no-var-requires */ +/* eslint-disable import/no-dynamic-require */ + //@ts-check +/** @typedef {import('webpack').Configuration} WebpackConfig **/ +const execFile = require('child_process').execFile; const path = require('path'); +const { EsbuildPlugin } = require('esbuild-loader'); +const ForkTsCheckerPlugin = require('fork-ts-checker-webpack-plugin'); +const JSON5 = require('json5'); +const TerserPlugin = require('terser-webpack-plugin'); const webpack = require('webpack'); -const TSLintPlugin = require('tslint-webpack-plugin'); - -function getWebviewConfig(env) { - /** @type webpack.Configuration */ - let webview = { - name: 'webview', - mode: env.production ? 'production' : 'development', - entry: { - index: './preview-src/index.ts' + +async function resolveTSConfig(configFile) { + const data = await new Promise((resolve, reject) => { + execFile( + 'npx', + ['tsc', `-p ${configFile}`, '--showConfig'], + { cwd: __dirname, encoding: 'utf8', shell: true }, + function (error, stdout, stderr) { + if (error != null) { + reject(error); + } + resolve(stdout); + }, + ); + }); + + const index = data.indexOf('{\n'); + const endIndex = data.indexOf('Done in'); + const substr = data.substring(index, endIndex > index ? endIndex : undefined); + const json = JSON5.parse(substr); + return json; +} + +/** + * @param { 'production' | 'development' | 'none' } mode + * @param {{ esbuild?: boolean; }} env + * @param { WebpackConfig['entry'] } entry + * @returns { Promise<WebpackConfig> } + */ +async function getWebviewConfig(mode, env, entry) { + const basePath = path.join(__dirname, 'webviews'); + + /** + * @type WebpackConfig['plugins'] | any + */ + const plugins = [ + new webpack.optimize.LimitChunkCountPlugin({ + maxChunks: 1 + }), + new ForkTsCheckerPlugin({ + async: false, + formatter: 'basic', + typescript: { + configFile: path.join(__dirname, 'tsconfig.webviews.json'), + }, + }), + ]; + + return { + name: 'webviews', + entry: entry, + mode: mode, + target: 'web', + devtool: mode !== 'production' ? 'source-map' : undefined, + 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 EsbuildPlugin({ + format: 'cjs', + minify: true, + treeShaking: true, + // Keep the class names + keepNames: true, + target: 'es2019', + }) + : new TerserPlugin({ + extractComments: false, + parallel: true, + terserOptions: { + ecma: 2019, + keep_classnames: /^AbortSignal$/, + module: true, + }, + }), + ], }, module: { rules: [ { + exclude: /node_modules/, + include: [basePath, path.join(__dirname, 'src'), path.join(__dirname, 'common')], test: /\.tsx?$/, - use: 'ts-loader', - exclude: /node_modules/ + use: env.esbuild + ? { + loader: 'esbuild-loader', + options: { + target: 'es2019', + tsconfigRaw: await resolveTSConfig(path.join(__dirname, 'tsconfig.webviews.json')), + }, + } + : { + loader: 'ts-loader', + options: { + configFile: path.join(__dirname, 'tsconfig.webviews.json'), + experimentalWatchApi: true, + transpileOnly: true, + }, + }, }, { test: /\.css/, - use: ['style-loader', 'css-loader'] + use: ['style-loader', 'css-loader'], }, { test: /\.svg/, - use: ['svg-inline-loader'] - } - ] + use: ['svg-inline-loader'], + }, + { + test: /\.ttf$/, + type: 'asset/resource' + }, + ], }, resolve: { - extensions: ['.tsx', '.ts', '.js', '.svg'] - }, - devtool: !env.production ? 'inline-source-map' : undefined, - output: { - filename: '[name].js', - path: path.resolve(__dirname, 'media') + extensions: ['.ts', '.tsx', '.js', '.jsx', '.json', '.svg'], + fallback: { + crypto: require.resolve("crypto-browserify"), + path: require.resolve('path-browserify'), + stream: require.resolve("stream-browserify"), + http: require.resolve("stream-http") + }, }, - plugins: [ - new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/), - new TSLintPlugin({ - files: ['./src/**/*.ts'] - }) - ] + plugins: plugins, }; - - return webview; } /** - * - * @param {*} env - * @returns webpack.Configuration + * @param { 'node' | 'webworker' } target + * @param { 'production' | 'development' | 'none' } mode + * @param {{ esbuild?: boolean; }} env + * @returns { Promise<WebpackConfig> } */ -function getExtensionConfig(env) { - /** @type webpack.Configuration */ - let config = { - name: 'extension', - mode: env.production ? 'production' : 'development', - target: 'node', - entry: { - extension: './src/extension.ts' +async function getExtensionConfig(target, mode, env) { + const basePath = path.join(__dirname, 'src'); + const glob = require('glob'); + + /** + * @type WebpackConfig['plugins'] | any + */ + const plugins = [ + new ForkTsCheckerPlugin({ + async: false, + 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( + __dirname, + 'node_modules', + '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 { + name: `extension:${target}`, + entry, + mode: mode, + target: target, + devtool: mode !== 'production' ? 'source-map' : undefined, + output: { + path: target === 'webworker' ? path.join(__dirname, 'dist', 'browser') : path.join(__dirname, 'dist'), + 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 EsbuildPlugin({ + format: 'cjs', + minify: true, + treeShaking: true, + // // Keep the class names + // keepNames: true, + target: 'es2019', + }) + : new TerserPlugin({ + extractComments: false, + parallel: true, + terserOptions: { + ecma: 2019, + // // Keep the class names + // keep_classnames: true, + module: true, + }, + }), + ], }, module: { rules: [ { + exclude: /node_modules/, + include: [path.join(__dirname, 'src'), path.join(__dirname, 'common')], test: /\.tsx?$/, - use: 'ts-loader', - exclude: /node_modules/ - }, - // FIXME: apollo-client uses .mjs, which imposes hard restrictions - // on imports available from other callers. They probably didn't know - // this. They just used .mjs because it seemed new and hip. - // - // 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.👇🏾 - { - test: /\.mjs$/, - include: /node_modules/, - type: "javascript/auto", + use: env.esbuild + ? { + loader: 'esbuild-loader', + options: { + target: 'es2019', + tsconfigRaw: await resolveTSConfig( + path.join( + __dirname, + target === 'webworker' ? 'tsconfig.browser.json' : 'tsconfig.json', + ), + ), + }, + } + : { + loader: 'ts-loader', + options: { + configFile: path.join( + __dirname, + target === 'webworker' ? 'tsconfig.browser.json' : 'tsconfig.json', + ), + experimentalWatchApi: true, + transpileOnly: true, + }, + }, }, + // // FIXME: apollo-client uses .mjs, which imposes hard restrictions + // // on imports available from other callers. They probably didn't know + // // this. They just used .mjs because it seemed new and hip. + // // + // // 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. + // { + // test: /\.mjs$/, + // include: /node_modules/, + // type: "javascript/auto", + // }, { - test: /\.gql/, + exclude: /node_modules/, + test: /\.(graphql|gql)$/, loader: 'graphql-tag/loader', - exclude: /node_modules/ } - ] + ], }, resolve: { - extensions: ['.tsx', '.ts', '.js'], - alias: { - "node-fetch": path.resolve(__dirname, 'node_modules/node-fetch/dist/index.cjs'), - } - }, - devtool: !env.production ? 'source-map' : undefined, - output: { - filename: '[name].js', - path: path.resolve(__dirname, 'media'), - libraryTarget: "commonjs", - devtoolModuleFilenameTemplate: 'file:///[absolute-resource-path]' + alias: + target === 'webworker' + ? { + 'node-fetch': 'cross-fetch', + '../env/node/net': path.resolve(__dirname, 'src', 'env', 'browser', 'net'), + '../env/node/ssh': path.resolve(__dirname, 'src', 'env', 'browser', 'ssh'), + '../../env/node/ssh': path.resolve(__dirname, 'src', 'env', 'browser', 'ssh'), + './env/node/gitProviders/api': path.resolve( + __dirname, + 'src', + 'env', + 'browser', + 'gitProviders', + 'api', + ) + } + : undefined, + // : { + // 'universal-user-agent': path.join(__dirname, 'node_modules', 'universal-user-agent', 'dist-node', 'index.js'), + // }, + fallback: + target === 'webworker' + ? { + crypto: require.resolve("crypto-browserify"), + path: require.resolve('path-browserify'), + stream: require.resolve("stream-browserify"), + url: false, + vm: false, + 'assert': require.resolve('assert'), + 'os': require.resolve('os-browserify/browser'), + "constants": require.resolve("constants-browserify"), + buffer: require.resolve('buffer'), + timers: require.resolve('timers-browserify'), + http: require.resolve("stream-http") + } + : { + http: require.resolve("stream-http") + }, + extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'], + symlinks: false, }, externals: { - 'vscode': 'commonjs vscode', - 'utf-8-validate': 'utf-8-validate', - 'bufferutil': 'bufferutil', - 'encoding': 'encoding', - 'applicationinsights-native-metrics': 'applicationinsights-native-metrics' + vscode: 'commonjs vscode', + // 'utf-8-validate': 'utf-8-validate', + // 'bufferutil': 'bufferutil', + // '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: { + preset: 'errors-warnings', + assets: true, + colors: true, + env: true, + errorsCount: true, + warningsCount: true, + timings: true, } }; - - return config; } -module.exports = function (env) { - env = env || {}; - env.production = !!env.production; - return [getExtensionConfig(env), getWebviewConfig(env)]; -}; \ No newline at end of file +module.exports = + /** + * @param {{ esbuild?: boolean; } | undefined } env + * @param {{ mode: 'production' | 'development' | 'none' | undefined; }} argv + * @returns { Promise<WebpackConfig[]> } + */ + async function (env, argv) { + const mode = argv.mode || 'none'; + + env = { + esbuild: false, + ...env, + }; + + return Promise.all([ + getExtensionConfig('node', mode, env), + getExtensionConfig('webworker', mode, env), + getWebviewConfig(mode, env, { + 'webview-pr-description': './webviews/editorWebview/index.ts', + 'webview-open-pr-view': './webviews/activityBarView/index.ts', + 'webview-create-pr-view-new': './webviews/createPullRequestViewNew/index.ts', + }), + ]); + }; diff --git a/webviews/activityBarView/app.tsx b/webviews/activityBarView/app.tsx new file mode 100644 index 0000000000..9d40442273 --- /dev/null +++ b/webviews/activityBarView/app.tsx @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import React, { useContext, useEffect, useState } from 'react'; +import { render } from 'react-dom'; +import { Overview } from './overview'; +import { PullRequest } from '../../src/github/views'; +import PullRequestContext from '../common/context'; + +export function main() { + render(<Root>{pr => <Overview {...pr} />}</Root>, document.getElementById('app')); +} + +export function Root({ children }) { + const ctx = useContext(PullRequestContext); + const [pr, setPR] = useState<PullRequest>(ctx.pr); + useEffect(() => { + ctx.onchange = setPR; + setPR(ctx.pr); + }, []); + 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/activityBarView/exit.tsx b/webviews/activityBarView/exit.tsx new file mode 100644 index 0000000000..bc70935338 --- /dev/null +++ b/webviews/activityBarView/exit.tsx @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import React, { useContext, useState } from 'react'; +import { GithubItemStateEnum } from '../../src/github/interface'; +import { PullRequest } from '../../src/github/views'; +import PullRequestContext from '../common/context'; + +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 '{doneCheckoutBranch}' + </button>); +}; + +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 '{doneCheckoutBranch}' </a>without deleting branch + </span> + ); +}; + +export const ExitSection = ({ pr }: { pr: PullRequest }) => { + const { exitReviewMode } = useContext(PullRequestContext); + const [isBusy, setBusy] = useState(false); + + const onClick = async () => { + try { + setBusy(true); + await exitReviewMode(); + } finally { + setBusy(false); + } + }; + + return ( + <div className="button-container"> + { + pr.state === GithubItemStateEnum.Open ? + <ExitButton doneCheckoutBranch={pr.doneCheckoutBranch} isBusy={isBusy} onClick={onClick} /> + : <ExitLink doneCheckoutBranch={pr.doneCheckoutBranch} onClick={onClick} /> + } + </div> + ); +}; \ No newline at end of file diff --git a/webviews/activityBarView/index.css b/webviews/activityBarView/index.css new file mode 100644 index 0000000000..94330d9f20 --- /dev/null +++ b/webviews/activityBarView/index.css @@ -0,0 +1,259 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +body { + padding: 10px 20px; +} + +textarea { + min-height: 80px; + max-height: 500px; + border-radius: 4px; +} + +.form-actions { + display: flex; + padding: 10px 0; +} + +.icon svg { + margin-right: 6px; + width: 16px; +} + +.reviewer-icons .icon svg { + margin-right: 0px; +} + +.reviewer-icons { + margin-left: 20px; +} + +#status-checks { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 0; +} + +.comment-form { + padding-bottom: 16px; +} + +.textarea-wrapper { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + box-sizing: border-box; + border: 1px solid var(--vscode-dropdown-border); + background-color: var(--vscode-input-background); + border-radius: 4px; + margin-bottom: 4px; + overflow: hidden; + resize: vertical; +} + +.textarea-wrapper:focus-within { + outline: 1px solid var(--vscode-focusBorder); +} + +.textarea-wrapper textarea { + flex: 1; + width: 100%; + box-sizing: border-box; + border: none; + background: transparent; + border-radius: 0; + outline: none; + margin: 0; + resize: none; +} + +.textarea-wrapper .comment-upload-button { + align-self: flex-start; + margin: 2px 0 4px 4px; + border: none; + background: none; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + color: var(--vscode-foreground); +} + +.textarea-wrapper .comment-upload-button:hover:not(:disabled) { + cursor: pointer; + background-color: var(--vscode-toolbar-hoverBackground); +} + +.textarea-wrapper .comment-upload-button:disabled { + opacity: 0.5; +} +.status-section { + padding-bottom: 16px; +} + +.ready-for-review-container { + display: flex; + flex-direction: column; + gap: 12px; + padding-bottom: 16px; +} + +.ready-for-review-heading, +.status-section p { + line-height: 1.5em; + margin: 0; +} + +.select-container { + display: flex; + flex-direction: column; + width: 100%; +} + +.select-control, +#merge-on-github, +form, +.button-container { + display: flex; + margin: auto; + max-width: 260px; + width: 100%; +} + +.button-container>button { + width: 100%; +} + +.button-container:has(> .dropdown-container) { + display: flex; + min-width: 0; +} + +.dropdown-container { + flex-grow: 1; + width: 100%; +} + +#merge-on-github { + justify-content: center; +} + +#comment-textarea { + margin-bottom: 10px; +} + +.select-control form { + flex-grow: 2; +} + +.select-control form input, +form button { + width: 100%; +} + +.select-control svg path { + fill: var(--vscode-button-foreground); +} + + +.select-control button, +.branch-status-container button, +input[type='submit'] { + white-space: normal; +} + +.select-control .open { + background-color: var(--vscode-button-hoverBackground); +} + +button .icon svg { + margin-right: 0; +} + +.options-select { + display: flex; + flex-direction: column; + width: inherit; + max-width: 260px; + margin: auto; +} + +.options-select button { + margin-top: 1px; + background-color: var(--vscode-dropdown-background); + color: var(--vscode-dropdown-foreground); + text-align: start; + white-space: break-spaces; +} + +.options-select button:hover, +.options-select button:focus { + color: var(--vscode-button-foreground); +} + +.branch-status-container { + padding: 10px 0; +} + +.radio-button { + display: flex; + align-items: center; + margin-right: 3px; +} + +.radio-button input { + margin-top: 0; +} + +@media (max-width: 250px) { + .form-actions { + flex-direction: column; + } + + .radio-button { + margin-bottom: 3px; + } +} + +#status-checks .branch-status-message { + display: flex; +} + +#status-checks .branch-status-icon { + display: flex; + padding-top: 1px; +} + +#status-checks .button-container { + padding-top: 16px; +} + +.comment-button { + display: flex; + flex-grow: 1; + min-width: 0; + position: relative; + align-items: center; +} + +.attestation-checkbox-row { + margin-top: 6px; + margin-right: auto; + margin-left: auto; + margin-bottom: 12px; + width: fit-content; +} + +.dropdown-container { + justify-content: center; +} + +button.split-left { + display: block; +} diff --git a/preview-src/index.ts b/webviews/activityBarView/index.ts similarity index 93% rename from preview-src/index.ts rename to webviews/activityBarView/index.ts index d6dc693526..20aa1f5bd3 100644 --- a/preview-src/index.ts +++ b/webviews/activityBarView/index.ts @@ -2,6 +2,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import '../common/common.css'; import './index.css'; import { main } from './app'; + addEventListener('load', main); diff --git a/webviews/activityBarView/overview.tsx b/webviews/activityBarView/overview.tsx new file mode 100644 index 0000000000..976a9d35fd --- /dev/null +++ b/webviews/activityBarView/overview.tsx @@ -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. + *--------------------------------------------------------------------------------------------*/ + +import React from 'react'; +import { ExitSection } from './exit'; +import { PullRequest } from '../../src/github/views'; + +import { AddCommentSimple } from '../components/comment'; +import { StatusChecksSection } from '../components/merge'; + +export const Overview = (pr: PullRequest) => { + return <> + <div id="main"> + <AddCommentSimple {...pr} /> + <StatusChecksSection pr={pr} isSimple={true} /> + <ExitSection pr={pr} /> + </div> + </>; +}; + 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 new file mode 100644 index 0000000000..f369e8770c --- /dev/null +++ b/webviews/common/cache.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. + *--------------------------------------------------------------------------------------------*/ + +import { vscode } from './message'; +import { PullRequest } from '../../src/github/views'; + +export function getState(): PullRequest { + return vscode.getState(); +} + +export function setState(pullRequest: PullRequest | undefined): void { + const oldPullRequest = getState(); + + if (oldPullRequest && oldPullRequest.number && oldPullRequest.number === pullRequest?.number) { + pullRequest.pendingCommentText = oldPullRequest.pendingCommentText; + } + + if (pullRequest) { + vscode.setState(pullRequest); + } +} + +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 new file mode 100644 index 0000000000..945a0946bb --- /dev/null +++ b/webviews/common/common.css @@ -0,0 +1,654 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +body a { + 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 { + text-decoration: underline; +} + +button, +input[type='submit'] { + color: var(--vscode-button-foreground); + font-family: var(--vscode-font-family); + border-radius: 4px; + border: 1px solid transparent; + 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: 4px 0 0 4px; +} + +button.select-right { + 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-color: var(--vscode-focusBorder); + outline-style: solid; + outline-width: 1px; + outline-offset: 2px; +} + +button:hover:enabled, +button:focus:enabled, +input[type='submit']:focus:enabled, +input[type='submit']:hover:enabled { + background-color: var(--vscode-button-hoverBackground); + cursor: pointer; +} + +button.secondary { + background-color: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + border-color: var(--vscode-button-secondaryBorder, transparent); +} + +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); +} + +textarea, +input[type='text'] { + display: block; + box-sizing: border-box; + padding: 8px; + width: 100%; + resize: vertical; + font-size: 13px; + border: 1px solid var(--vscode-dropdown-border); + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + font-family: var(--vscode-font-family); + border-radius: 4px; +} + +textarea::placeholder, +input[type='text']::placeholder { + color: var(--vscode-input-placeholderForeground); +} + +select { + display: block; + box-sizing: border-box; + padding: 4px 8px; + border-radius: 4px; + font-size: 13px; + border: 1px solid var(--vscode-dropdown-border); + background-color: var(--vscode-dropdown-background); + color: var(--vscode-dropdown-foreground); +} + +textarea:focus, +input[type='text']: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; +} + +/* + * Task list items rendered from markdown (e.g. `- [x] done`) produce + * `<li class="task-list-item"><input type="checkbox" checked disabled> ...</li>` + * without a `.checkbox-wrapper`, so the wrapper-based checkmark above does + * not apply. Pseudo-elements cannot be placed on `<input>` elements, so we + * draw the checkmark on the parent `<li>` using `:has()`, positioned over + * the checkbox. + */ +.task-list-item { + position: relative; +} + +.task-list-item:has(> input[type='checkbox']:checked)::before, +.task-list-item:has(> p > input[type='checkbox']:checked)::before { + content: ''; + position: absolute; + top: 5px; + left: 6px; + width: 5px; + height: 10px; + border: solid var(--vscode-checkbox-foreground); + border-width: 0 1px 1px 0; + transform: rotate(45deg); + pointer-events: none; +} + +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); +} + +.vscode-high-contrast input[type='checkbox']:focus { + outline: 1px solid var(--vscode-contrastActiveBorder); +} + +: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 { + display: none !important; +} + +body img.avatar, +body span.avatar-icon svg { + width: 20px; + height: 20px; + border-radius: 50%; +} + +body img.avatar { + vertical-align: middle; +} + +.avatar-link { + 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; + justify-content: space-between; +} + +.section-item .avatar-link { + margin-right: 6px; +} + +.section-item .avatar-container { + flex-shrink: 0; +} + +.section-item .login { + width: 129px; + flex-shrink: 0; +} + +.section-item img.avatar { + width: 20px; + height: 20px; +} + +.section-icon { + display: flex; + align-items: center; + justify-content: center; + padding: 3px; +} + +.section-icon.changes svg path { + fill: var(--vscode-list-errorForeground); +} + +.section-icon.commented svg path, +.section-icon.requested svg path { + fill: var(--vscode-list-warningForeground); +} + +.section-icon.approved svg path { + fill: var(--vscode-issues-open); +} + +.section-icon.issue-open svg path { + fill: var(--vscode-issues-open); +} + +.section-icon.issue-closed svg path { + fill: var(--vscode-issues-closed); +} + +.issue-item { + display: flex; + align-items: center; + gap: 6px; + color: var(--vscode-foreground); + text-decoration: none; + overflow: hidden; +} + +.issue-item:hover { + text-decoration: underline; + color: var(--vscode-foreground); +} + +.issue-item-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; +} + +.reviewer-icons { + display: flex; + gap: 4px; +} + +.reviewer-icons [role='alert'] { + position: absolute; + width: 0; + height: 0; + overflow: hidden; +} + +.push-right { + margin-left: auto; +} + +.avatar-with-author { + display: flex; + align-items: center; +} + +.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-panel-background); + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; +} + +.automerge-section .merge-select-container { + margin-left: 8px; +} + +.automerge-checkbox-wrapper, +.automerge-checkbox-label { + display: flex; + align-items: center; + margin-right: 4px; +} + +.automerge-checkbox-label { + min-width: 80px; +} + +.attestation-checkbox-wrapper { + display: flex; + align-items: center; + margin-right: 8px; +} + +.attestation-checkbox-label { + margin-left: 4px; + white-space: nowrap; + cursor: pointer; +} + +.comment-actions-right { + display: flex; + flex-grow: 1; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-width: 0; +} + +/* When the dropdown is grouped with the attestation checkbox we don't want it +to stretch and push the checkbox away from the button. */ +.comment-actions-right .dropdown-container.spreadable { + flex-grow: 0; + width: auto; +} + +.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:not(.secondary):not(.icon-button) { + background: var(--vscode-button-background); +} + + +.vscode-high-contrast input { + outline: none; + background: var(--vscode-input-background); + border: 1px solid var(--vscode-contrastBorder); +} + +.vscode-high-contrast button:focus { + border: 1px solid var(--vscode-contrastActiveBorder); +} + +.vscode-high-contrast button:hover { + border: 1px dotted var(--vscode-contrastActiveBorder); +} + +::-webkit-scrollbar-corner { + display: none; +} + +.labels-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.label { + display: flex; + justify-content: normal; + padding: 0 8px; + border-radius: 20px; + border-style: solid; + border-width: 1px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + 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; +} + +button.split-left:only-child { + border-radius: 4px; + border-right: 1px solid var(--vscode-button-border, transparent); +} + +.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 new file mode 100644 index 0000000000..5d34894694 --- /dev/null +++ b/webviews/common/context.tsx @@ -0,0 +1,594 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { getState, setState, updateState } from './cache'; +import { COMMENT_TEXTAREA_ID } from './constants'; +import { getMessageHandler, MessageHandler } from './message'; +import { CloseResult, DescriptionResult, OpenCommitChangesArgs, OpenLocalFileArgs } from '../../common/views'; +import { IComment } from '../../src/common/comment'; +import { EventType, ReviewEvent, SessionLinkInfo, TimelineEvent } from '../../src/common/timelineEvent'; +import { IProjectItem, MergeMethod, PullRequestCheckStatus, ReadyForReview } from '../../src/github/interface'; +import { CancelCodingAgentReply, ChangeAssigneesReply, ChangeBaseReply, ConvertToDraftReply, DeleteReviewResult, FileUploadCompletedMessage, MergeArguments, MergeResult, ProjectItemsReply, PullRequest, ReadyForReviewReply, SubmitReviewArgs, SubmitReviewReply, UploadFilesReply } from '../../src/github/views'; + +/** + * Encode a {@linkcode Uint8Array} as a base64 string. Uses fixed-size chunks to + * keep the conversion linear in time and avoid huge intermediate string + * allocations when handling large clipboard payloads. + */ +function bytesToBase64(bytes: Uint8Array): string { + const chunkSize = 0x8000; + const parts: string[] = []; + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize); + parts.push(String.fromCharCode.apply(null, chunk as unknown as number[])); + } + return btoa(parts.join('')); +} + +// Keep in sync with MAX_UPLOAD_SIZE_BYTES on the extension host. +const MAX_UPLOAD_SIZE_BYTES = 25 * 1024 * 1024; + +export class PRContext { + constructor( + public pr: PullRequest | undefined = getState(), + public onchange: ((ctx: PullRequest | undefined) => void) | null = null, + private _handler: MessageHandler | null = null, + ) { + if (!_handler) { + this._handler = getMessageHandler(this.handleMessage); + } + } + + public setTitle = async (title: string) => { + const result = await this.postMessage({ command: 'pr.edit-title', args: { text: title } }); + this.updatePR({ titleHTML: result.titleHTML }); + }; + + public setDescription = (description: string) => + this.postMessage({ command: 'pr.edit-description', args: { text: description } }); + + 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; + } + return this.postMessage({ + command: 'pr.checkout-default-branch', + args: this.pr.repositoryDefaultBranch, + }); + }; + + public gotoChangesSinceReview = () => this.postMessage({ command: 'pr.gotoChangesSinceReview' }); + + 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 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 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 = (): 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({ + events: pr.events.filter(e => e.id !== id), + }); + return; + } + const index = pr.events.findIndex(e => e.id === pullRequestReviewId); + if (index === -1) { + console.error('Could not find review:', pullRequestReviewId); + return; + } + const review: ReviewEvent = pr.events[index] as ReviewEvent; + if (!review.comments) { + console.error('No comments to delete for review:', pullRequestReviewId, review); + return; + } + pr.events.splice(index, 1, { + ...review, + comments: review.comments.filter(c => c.id !== id), + }); + 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); + if (body === pendingCommentDrafts[id]) { + return; + } + pendingCommentDrafts[id] = body; + this.updatePR({ pendingCommentDrafts: pendingCommentDrafts }); + }; + + private async submitReviewCommand(command: string, args: SubmitReviewArgs) { + try { + const result: SubmitReviewReply = await this.postMessage({ command, args }); + return this.appendReview(result); + } catch (error) { + return this.updatePR({ busy: false }); + } + } + + public requestChanges = (body: string, addAttestation: boolean = false) => + this.submitReviewCommand('pr.request-changes', { body, addAttestation }); + + public approve = (body: string, addAttestation: boolean = false) => + this.submitReviewCommand('pr.approve', { body, addAttestation }); + + public submit = (body: string, addAttestation: boolean = false) => + this.submitReviewCommand('pr.submit', { body, addAttestation }); + + private _uploadCompletionHandlers: Map<string, (message: FileUploadCompletedMessage) => void> = new Map(); + + /** + * Asks the host to prompt the user for files to upload. + * + * @param insertPlaceholders called once with the textual placeholders to insert into the textarea + * @param replacePlaceholder called as each upload finishes (or fails) to replace the placeholder with the resulting markdown (or remove it on error) + */ + public uploadFiles = async ( + insertPlaceholders: (placeholders: string) => void, + replacePlaceholder: (placeholder: string, markdownOrEmpty: string) => void, + ) => { + const result: UploadFilesReply | undefined = await this.postMessage({ command: 'pr.upload-files' }); + this._registerUploadHandlers(result, insertPlaceholders, replacePlaceholder); + }; + + /** + * Send a list of files (typically from a clipboard paste or drop) to the host for upload. + */ + public uploadPastedFiles = async ( + files: readonly File[], + insertPlaceholders: (placeholders: string) => void, + replacePlaceholder: (placeholder: string, markdownOrEmpty: string) => void, + ) => { + if (files.length === 0) { + return; + } + const oversized = files.find(f => f.size > MAX_UPLOAD_SIZE_BYTES); + if (oversized) { + const limitMb = MAX_UPLOAD_SIZE_BYTES / (1024 * 1024); + const sizeMb = Math.round(oversized.size / (1024 * 1024)); + void this.postMessage({ + command: 'alert', + args: `File "${oversized.name || 'pasted-file'}" is too large to upload (${sizeMb} MB). The maximum allowed size is ${limitMb} MB.`, + }); + return; + } + const fileArgs = await Promise.all(files.map(async file => { + const buffer = new Uint8Array(await file.arrayBuffer()); + const bytesBase64 = bytesToBase64(buffer); + return { name: file.name || 'pasted-file', type: file.type ?? '', bytesBase64 }; + })); + const result: UploadFilesReply | undefined = await this.postMessage({ + command: 'pr.upload-pasted-files', + args: { files: fileArgs }, + }); + this._registerUploadHandlers(result, insertPlaceholders, replacePlaceholder); + }; + + private _registerUploadHandlers( + result: UploadFilesReply | undefined, + insertPlaceholders: (placeholders: string) => void, + replacePlaceholder: (placeholder: string, markdownOrEmpty: string) => void, + ) { + if (!result || !result.uploads || result.uploads.length === 0) { + return; + } + const placeholdersText = result.uploads.map(u => u.placeholder).join('\n'); + insertPlaceholders(placeholdersText); + for (const upload of result.uploads) { + this._uploadCompletionHandlers.set(upload.placeholder, message => { + if (message.error) { + replacePlaceholder(message.placeholder, ''); + this.postMessage({ command: 'alert', args: `Failed to upload ${message.name}: ${message.error}` }); + } else { + replacePlaceholder(message.placeholder, message.markdown ?? ''); + } + }); + } + } + + /** + * Convenience wrapper that uploads files into the current pending comment text in the PR state. + */ + public uploadFilesIntoPendingComment = () => { + return this.uploadFiles( + placeholders => this._appendToPendingComment(placeholders), + (placeholder, markdown) => this._replaceInPendingComment(placeholder, markdown), + ); + }; + + /** + * Convenience wrapper that uploads pasted files into the current pending comment text. + */ + public uploadPastedFilesIntoPendingComment = (files: readonly File[]) => { + return this.uploadPastedFiles( + files, + placeholders => this._appendToPendingComment(placeholders), + (placeholder, markdown) => this._replaceInPendingComment(placeholder, markdown), + ); + }; + + private _appendToPendingComment(placeholders: string) { + const current = this.pr?.pendingCommentText ?? ''; + const separator = current.length > 0 && !current.endsWith('\n') ? '\n' : ''; + this.updatePR({ pendingCommentText: `${current}${separator}${placeholders}\n` }); + } + + private _replaceInPendingComment(placeholder: string, markdown: string) { + const current = this.pr?.pendingCommentText ?? ''; + this.updatePR({ pendingCommentText: current.replace(placeholder, markdown) }); + } + + private completeFileUpload(message: FileUploadCompletedMessage) { + const handler = this._uploadCompletionHandlers.get(message.placeholder); + if (handler) { + this._uploadCompletionHandlers.delete(message.placeholder); + handler(message); + } + } + + 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 { + 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 = pr.labels.filter(r => r.name !== label); + this.updatePR({ labels }); + }; + + public applyPatch = async (comment: IComment) => { + this.postMessage({ command: 'pr.apply-patch', args: { comment } }); + }; + + private appendReview(reply: SubmitReviewReply) { + const { pr: state } = this; + if (!state) { + throw new Error('Unexpectedly no pull request when trying to append review'); + } + const { events, additionalEvents, reviewers, reviewedEvent } = reply; + state.busy = false; + if (!events) { + this.updatePR(state); + return; + } + if (reviewers) { + state.reviewers = reviewers; + } + // If the caller sent a fresh full timeline (`events.length > 0`), replace state + // with it. Otherwise append `additionalEvents` (if any) followed by the review. + state.events = events.length === 0 + ? [...state.events, ...(additionalEvents ?? []), reviewedEvent] + : events; + if (reviewedEvent.event === EventType.Reviewed) { + state.currentUserReviewState = reviewedEvent.state; + } + state.pendingCommentText = ''; + state.pendingReviewType = undefined; + this.updatePR(state); + } + + 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 } }); + 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) => { + this.postMessage({ + command: 'pr.resolve-comment-thread', + args: { threadId: threadId, toResolve: newResolved, thread } + }).then((timelineEvents: TimelineEvent[] | undefined) => { + if (timelineEvents) { + this.updatePR({ events: timelineEvents }); + } + else { + this.refresh(); + } + }); + }; + + public openSessionLog = (link: SessionLinkInfo) => this.postMessage({ command: 'pr.open-session-log', args: { link } }); + + public openLocalFile = (file: string, startLine: number, endLine: number, href: string) => { + const args: OpenLocalFileArgs = { file, startLine, endLine, href }; + this.postMessage({ command: 'pr.open-local-file', args }); + }; + + public openDiffFromLink = (file: string, startLine: number, endLine: number, href: string) => { + const args: OpenLocalFileArgs = { file, startLine, endLine, href }; + this.postMessage({ command: 'pr.open-diff-from-link', args }); + }; + + public viewCheckLogs = (status: PullRequestCheckStatus) => this.postMessage({ command: 'pr.view-check-logs', args: { status } }); + + 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) { + this.onchange(this.pr); + } + return this; + }; + + updatePR = (pr: Partial<PullRequest> | undefined) => { + updateState(pr); + this.pr = this.pr ? { ...this.pr, ...pr } : pr as PullRequest; + if (this.onchange) { + this.onchange(this.pr); + } + return this; + }; + + postMessage(message: any) { + return (this._handler?.postMessage(message) ?? Promise.resolve(undefined)); + } + + handleMessage = (message: any) => { + switch (message.command) { + case 'pr.clear': + this.setPR(undefined); + return; + case 'pr.initialize': + return this.setPR(message.pullrequest); + case 'update-state': + return this.updatePR({ state: message.state }); + case 'pr.update-checkout-status': + return this.updatePR({ isCurrentlyCheckedOut: message.isCurrentlyCheckedOut }); + case 'pr.deleteBranch': + const stateChange: { isLocalHeadDeleted?: boolean, isRemoteHeadDeleted?: boolean } = {}; + message.branchTypes && message.branchTypes.map((branchType: string) => { + if (branchType === 'local') { + stateChange.isLocalHeadDeleted = true; + } else if ((branchType === 'remote') || (branchType === 'upstream')) { + stateChange.isRemoteHeadDeleted = true; + } + }); + return this.updatePR(stateChange); + case 'pr.enable-exit': + return this.updatePR({ isCurrentlyCheckedOut: true }); + case 'set-scroll': + window.scrollTo(message.scrollPosition.x, message.scrollPosition.y); + return; + case 'pr.scrollToPendingReview': + 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); + case 'pr.file-upload-completed': + return this.completeFileUpload(message); + } + }; + + public static instance = new PRContext(); +} + +const PullRequestContext = createContext<PRContext>(PRContext.instance); +export default PullRequestContext; diff --git a/webviews/common/createContextNew.ts b/webviews/common/createContextNew.ts new file mode 100644 index 0000000000..b1bec93809 --- /dev/null +++ b/webviews/common/createContextNew.ts @@ -0,0 +1,393 @@ +/*--------------------------------------------------------------------------------------------- + * 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(); + await this.postMessage({ + command: 'pr.create', + args, + }); + // Only clear persisted state after the create succeeds. Resetting before + // awaiting would discard the user's title/description if the create fails + // (for example when the branch push fails and needs `--force`), causing + // the description to fall back to the template on the next webview load. + vscode.setState(defaultCreateParams); + } 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 new file mode 100644 index 0000000000..89e59961b8 --- /dev/null +++ b/webviews/common/errorBoundary.tsx @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import React from 'react'; + +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: unknown): ErrorBoundaryState { + return { hasError: true }; + } + + override componentDidCatch(error: unknown, errorInfo: React.ErrorInfo): void { + console.error(error); + console.error(errorInfo); + } + + override render(): React.ReactNode { + if (this.state.hasError) { + return <div>Something went wrong.</div>; + } + + return this.props.children ?? null; + } +} \ No newline at end of file diff --git a/webviews/common/events.ts b/webviews/common/events.ts new file mode 100644 index 0000000000..77adc7ba9b --- /dev/null +++ b/webviews/common/events.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. + *--------------------------------------------------------------------------------------------*/ + +import { EventEmitter } from 'events'; + +export default new EventEmitter(); diff --git a/webviews/common/hooks.ts b/webviews/common/hooks.ts new file mode 100644 index 0000000000..d62d014932 --- /dev/null +++ b/webviews/common/hooks.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. + *--------------------------------------------------------------------------------------------*/ + +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; + +/** + * useState, but track the value of a prop. + * + * When the prop value changes, the tracked state will be updated to match. + * + * @param prop S the prop to track + */ +export function useStateProp<S>(prop: S): [S, Dispatch<SetStateAction<S>>] { + const [state, setState] = useState(prop); + useEffect(() => { + if (state !== prop) { + setState(prop); + } + }, [prop]); + return [state, setState]; +} diff --git a/webviews/common/label.tsx b/webviews/common/label.tsx new file mode 100644 index 0000000000..8cfc160578 --- /dev/null +++ b/webviews/common/label.tsx @@ -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. + *--------------------------------------------------------------------------------------------*/ + + +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/preview-src/message.ts b/webviews/common/message.ts similarity index 67% rename from preview-src/message.ts rename to webviews/common/message.ts index 7ccb978bd4..4d720f22f6 100644 --- a/preview-src/message.ts +++ b/webviews/common/message.ts @@ -1,69 +1,76 @@ -interface IRequestMessage<T> { - req: string; - command: string; - args: T; -} - -interface IReplyMessage { - seq: string; - err: any; - res: any; -} - -declare var acquireVsCodeApi: any; -export const vscode = acquireVsCodeApi(); - -export class MessageHandler { - private _commandHandler: ((message: any) => void) | null; - private lastSentReq: number; - private pendingReplies: any; - constructor(commandHandler: any) { - this._commandHandler = commandHandler; - this.lastSentReq = 0; - this.pendingReplies = Object.create(null); - window.addEventListener('message', this.handleMessage.bind(this)); - } - - public registerCommandHandler(commandHandler: (message: any) => void) { - this._commandHandler = commandHandler; - } - - public async postMessage(message: any): Promise<any> { - const req = String(++this.lastSentReq); - return new Promise<any>((resolve, reject) => { - this.pendingReplies[req] = { - resolve: resolve, - reject: reject - }; - message = Object.assign(message, { - req: req - }); - vscode.postMessage(message as IRequestMessage<any>); - }); - } - - // handle message should resolve promises - private handleMessage(event: any) { - const message: IReplyMessage = event.data; // The json data that the extension sent - if (message.seq) { - // this is a reply - const pendingReply = this.pendingReplies[message.seq]; - if (pendingReply) { - if (message.err) { - pendingReply.reject(message.err); - } else { - pendingReply.resolve(message.res); - } - return; - } - } - - if (this._commandHandler) { - this._commandHandler(message.res); - } - } -} - -export function getMessageHandler(handler: ((message: any) => void) | null) { - return new MessageHandler(handler); -} \ 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. + *--------------------------------------------------------------------------------------------*/ + +interface IRequestMessage<T> { + req: string; + command: string; + args: T; +} + +interface IReplyMessage { + seq: string; + 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: 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) as (this: Window, ev: MessageEvent<any>) => any); + } + + public registerCommandHandler(commandHandler: (message: any) => void) { + this._commandHandler = commandHandler; + } + + public async postMessage(message: any): Promise<any> { + const req = String(++this.lastSentReq); + return new Promise<any>((resolve, reject) => { + this.pendingReplies[req] = { + resolve: resolve, + reject: reject, + }; + message = Object.assign(message, { + req: req, + }); + vscode.postMessage(message as IRequestMessage<any>); + }); + } + + // handle message should resolve promises + private handleMessage(event: any) { + const message: IReplyMessage = event.data; // The json data that the extension sent + if (message.seq) { + // this is a reply + const pendingReply = this.pendingReplies[message.seq]; + if (pendingReply) { + if (message.err) { + pendingReply.reject(message.err); + } else { + pendingReply.resolve(message.res); + } + return; + } + } + + if (this._commandHandler) { + this._commandHandler(message.res); + } + } +} + +export function getMessageHandler(handler: ((message: any) => void) | null) { + return new MessageHandler(handler); +} diff --git a/webviews/components/automergeSelect.tsx b/webviews/components/automergeSelect.tsx new file mode 100644 index 0000000000..efc6c349a8 --- /dev/null +++ b/webviews/components/automergeSelect.tsx @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as React from 'react'; +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 }>) => Promise<void>; + baseHasMergeQueue: boolean; + allowAutoMerge?: boolean; + defaultMergeMethod?: MergeMethod; + mergeMethodsAvailability?: MergeMethodsAvailability; + autoMerge?: boolean; + isDraft?: boolean; +}) => { + if ((!allowAutoMerge && !autoMerge) || !mergeMethodsAvailability || !defaultMergeMethod) { + return null; + } + 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 checkbox-wrapper"> + <input + id="automerge-checkbox" + type="checkbox" + name="automerge" + checked={autoMerge} + 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 new file mode 100644 index 0000000000..0c2d6583c3 --- /dev/null +++ b/webviews/components/comment.tsx @@ -0,0 +1,841 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ContextDropdown } from './contextDropdown'; +import { cloudUploadIcon, 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, ReviewCommentContext, 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'; + +export type Props = { + headerInEditMode?: boolean; + isPRDescription?: boolean; + children?: React.ReactNode; + comment: IComment | ReviewEvent | PullRequest | CommentEvent; + allowEmpty?: boolean; +}; + +const association = ({ authorAssociation }: ReviewEvent, format = (assoc: string) => `(${assoc.toLowerCase()})`) => + authorAssociation.toLowerCase() === 'user' + ? format('you') + : authorAssociation && authorAssociation !== 'NONE' + ? format(authorAssociation) + : null; + +function filesFromClipboard(e: React.ClipboardEvent): File[] { + return Array.from(e.clipboardData?.files ?? []); +} + +function onPasteUploadFiles(uploader: (files: readonly File[]) => Promise<void>) { + return (e: React.ClipboardEvent<HTMLTextAreaElement>) => { + const files = filesFromClipboard(e); + if (files.length === 0) { + return; + } + e.preventDefault(); + void uploader(files); + }; +} + +function insertEditPlaceholders( + form: { current: HTMLFormElement | null | undefined }, + draftComment: { current: { body: string; dirty: boolean } }, + placeholders: string, +) { + const ta = form.current?.elements.namedItem('markdown') as HTMLTextAreaElement | null; + if (!ta) { + return; + } + const current = ta.value; + const separator = current.length > 0 && !current.endsWith('\n') ? '\n' : ''; + ta.value = `${current}${separator}${placeholders}\n`; + draftComment.current.body = ta.value; + draftComment.current.dirty = true; +} + +function replaceEditPlaceholder( + form: { current: HTMLFormElement | null | undefined }, + draftComment: { current: { body: string; dirty: boolean } }, + placeholder: string, + markdown: string, +) { + const ta = form.current?.elements.namedItem('markdown') as HTMLTextAreaElement | null; + if (!ta) { + return; + } + ta.value = ta.value.replace(placeholder, markdown); + draftComment.current.body = ta.value; + draftComment.current.dirty = true; +} + +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 [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(headerInEditMode ? <CommentBox for={comment} /> : <></>, {}, [ + <EditComment + id={id} + key={`editComment${id}`} + body={currentDraft || bodyMd} + isPRDescription={isPRDescription} + onCancel={() => { + if (pr?.pendingCommentDrafts) { + delete pr.pendingCommentDrafts[id]; + } + setEditMode(false); + }} + onSave={async text => { + try { + const result = isPRDescription + ? await setDescription(text) + : await editComment({ comment: comment as IComment, text }); + + setBodyHtml(result.bodyHTML); + setBodyMd(text); + } finally { + setEditMode(false); + } + }} + />, + ]); + } + + 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} + onMouseEnter={() => setShowActionBar(true)} + 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)} + > + {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} + </button> + ) : null} + {canDelete ? ( + <button + title="Delete comment" + className="icon-button" + onClick={() => deleteComment({ id, pullRequestReviewId })} + > + {trashIcon} + </button> + ) : null} + </div> + <CommentBody + comment={comment as IComment} + bodyHTML={bodyHTMLState} + body={bodyMd} + canApplyPatch={!!pr?.isCurrentlyCheckedOut} + allowEmpty={!!commentProps.allowEmpty} + specialDisplayBodyPostfix={(comment as IComment).specialDisplayBodyPostfix} + /> + {children} + </CommentBox> + ); +} + +type CommentBoxProps = { + for: IComment | ReviewEvent | PullRequest | CommentEvent; + header?: React.ReactChild; + 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 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"> + <h3 className={`review-comment-header${(isReviewEvent(comment) && comment.comments.length > 0) ? '' : ' no-details'}`}> + <Spaced> + <Avatar for={author} /> + <AuthorLink for={author} /> + {isReviewEvent(comment) ? association(comment) : null} + + + {createdAt ? ( + <> + {(isReviewEvent(comment) && comment.state) ? reviewDescriptor(comment.state) : 'commented'} + {nbsp} + <Timestamp href={htmlUrl} date={createdAt} /> + </> + ) : ( + <em>pending</em> + )} + {isDraft ? ( + <> + <span className="pending-label">Pending</span> + </> + ) : null} + </Spaced> + </h3> + {children} + </div> + </div> + ); +} + +type FormInputSet = { + [name: string]: HTMLInputElement | HTMLTextAreaElement; +}; + +type EditCommentProps = { + id: number; + body: string; + isPRDescription?: boolean; + onCancel: () => void; + onSave: (body: string) => Promise<any>; +}; + +function EditComment({ id, body, isPRDescription, onCancel, onSave }: EditCommentProps) { + const { updateDraft, pr, generateDescription, cancelGenerateDescription, uploadFiles, uploadPastedFiles } = 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(() => { + if (draftComment.current.dirty) { + updateDraft(id, draftComment.current.body); + draftComment.current.dirty = false; + } + }, 500); + return () => clearInterval(interval); + }, [draftComment]); + + const submit = useCallback(async () => { + const { markdown, submitButton }: FormInputSet = form.current!; + submitButton.disabled = true; + try { + await onSave(markdown.value); + } finally { + submitButton.disabled = false; + } + }, [form, onSave]); + + const onSubmit = useCallback( + event => { + event.preventDefault(); + submit(); + }, + [submit], + ); + + const onKeyDown = useCallback( + e => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + submit(); + } + }, + [submit], + ); + + const onInput = useCallback( + e => { + 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]); + + const handleUpload = useCallback(() => { + const textarea = form.current?.markdown as HTMLTextAreaElement | undefined; + if (!textarea) { + return; + } + uploadFiles( + placeholders => insertEditPlaceholders(form, draftComment, placeholders), + (placeholder, markdown) => replaceEditPlaceholder(form, draftComment, placeholder, markdown), + ); + }, [uploadFiles, form, draftComment]); + + const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => { + const files = filesFromClipboard(e); + if (files.length === 0) { + return; + } + e.preventDefault(); + void uploadPastedFiles( + files, + placeholders => insertEditPlaceholders(form, draftComment, placeholders), + (placeholder, markdown) => replaceEditPlaceholder(form, draftComment, placeholder, markdown), + ); + }, [uploadPastedFiles, form, draftComment]); + + return ( + <form ref={form as React.MutableRefObject<HTMLFormElement>} onSubmit={onSubmit}> + <div className="textarea-wrapper"> + <textarea name="markdown" defaultValue={body} onKeyDown={onKeyDown} onInput={onInput} onPaste={handlePaste} 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} + <button + type="button" + title="Upload files" + className="comment-upload-button icon-button" + onClick={handleUpload} + disabled={isGenerating} + > + {cloudUploadIcon} + </button> + </div> + <div className="form-actions"> + <button className="secondary" onClick={onCancel}> + Cancel + </button> + <button type="submit" name="submitButton">Save</button> + </div> + </form> + ); +} + +export interface Embodied { + comment?: IComment; + bodyHTML?: string; + body?: string; + canApplyPatch: boolean; + allowEmpty: boolean; + specialDisplayBodyPostfix?: string; +} + +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> + </div> + ); + } + + const { applyPatch } = useContext(PullRequestContext); + const renderedBody = <div dangerouslySetInnerHTML={{ __html: bodyHTML ?? '' }} />; + + const containsSuggestion = ((body || bodyHTML)?.indexOf('```diff') ?? -1) > -1; + const applyPatchButton = + 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, + owner, + repo, + number, + attestationCommitsEnabled, + isCurrentlyCheckedOut +}: PullRequest) { + const { updatePR, requestChanges, approve, close, openOnGitHub, submit, uploadFilesIntoPendingComment, uploadPastedFilesIntoPendingComment } = useContext(PullRequestContext); + const [isBusy, setBusy] = useState(false); + const [addAttestation, setAddAttestation] = useState(false); + const form = useRef<HTMLFormElement>(); + const textareaRef = useRef<HTMLTextAreaElement>(); + + emitter.addListener('quoteReply', (message: string) => { + const quoted = message.replace(/\n/g, '\n> '); + updatePR({ pendingCommentText: `> ${quoted} \n\n` }); + textareaRef.current?.scrollIntoView(); + textareaRef.current?.focus(); + }); + + const closeButton: React.MouseEventHandler<HTMLButtonElement> = e => { + e.preventDefault(); + const { value } = textareaRef.current!; + close(value); + }; + + 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, addAttestation); + break; + default: + await submit(value); + } + setBusy(false); + } + + const onKeyDown = e => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + submitAction(currentSelection); + } + }; + + 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) + + const showAttestationCheckbox = !!attestationCommitsEnabled && !!isCurrentlyCheckedOut && !!availableActions.approve && availableActions.approve === COMMENT_METHODS.approve; + + return ( + <form id="comment-form" ref={form as React.MutableRefObject<HTMLFormElement>} className="comment-form main-comment-form" > + <div className="textarea-wrapper"> + <textarea + id={COMMENT_TEXTAREA_ID} + name="body" + ref={textareaRef as React.MutableRefObject<HTMLTextAreaElement>} + onInput={({ target }) => updatePR({ pendingCommentText: (target as HTMLTextAreaElement).value })} + onKeyDown={onKeyDown} + onPaste={onPasteUploadFiles(uploadPastedFilesIntoPendingComment)} + value={pendingCommentText} + placeholder="Leave a comment" + onClick={() => { + if (!pendingCommentText && isCopilotOnMyBehalf && !textareaRef.current?.textContent) { + textareaRef.current!.textContent = '@copilot '; + textareaRef.current!.setSelectionRange(9, 9); + } + }} + /> + <button + type="button" + title="Upload files" + className="comment-upload-button icon-button" + onClick={() => uploadFilesIntoPendingComment()} + disabled={isBusy || busy} + > + {cloudUploadIcon} + </button> + </div> + <div className="form-actions"> + {(hasWritePermission || isAuthor) ? ( + <button + id="close" + className="secondary" + disabled={isBusy || state !== GithubItemStateEnum.Open} + onClick={closeButton} + data-command="close" + > + {isIssue ? 'Close Issue' : 'Close Pull Request'} + </button> + ) : null} + + + <div className="comment-actions-right"> + {showAttestationCheckbox ? ( + <div className="attestation-checkbox-wrapper checkbox-wrapper" title="Add a signed attestation commit to the head of the pull request branch when approving."> + <input + id="attestation-checkbox" + type="checkbox" + name="add-attestation" + checked={addAttestation} + disabled={isBusy || busy} + onChange={(e) => setAddAttestation(e.currentTarget.checked)} + /> + <label htmlFor="attestation-checkbox" className="attestation-checkbox-label">Add attestation</label> + </div> + ) : null} + + <ContextDropdown + optionsContext={() => makeCommentMenuContext(owner, repo, number, 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> + </div> + </form> + ); +} + +function commentMethods(isIssue: boolean) { + return isIssue ? ISSUE_COMMENT_METHODS : COMMENT_METHODS; +} + +const ISSUE_COMMENT_METHODS = { + comment: 'Comment', +}; + +const COMMENT_METHODS = { + ...ISSUE_COMMENT_METHODS, + approve: 'Approve', + requestChanges: 'Request Changes', +}; + +const makeCommentMenuContext = (owner: string, repo: string, number: number, availableActions: { comment?: string, approve?: string, requestChanges?: string }, pendingCommentText: string | undefined, shouldDisableNonApproveButtons: boolean) => { + const createMenuContexts: ReviewCommentContext = { + 'preventDefaultContextMenuItems': true, + 'github:reviewCommentMenu': true, + owner, + repo, + number, + body: pendingCommentText ?? '', + }; + 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, uploadFilesIntoPendingComment, uploadPastedFilesIntoPendingComment } = useContext(PullRequestContext); + const [isBusy, setBusy] = useState(false); + const [addAttestation, setAddAttestation] = useState(false); + const textareaRef = useRef<HTMLTextAreaElement>(); + let currentSelection: ReviewType = pr.lastReviewType ?? (pr.currentUserReviewState === 'APPROVED' ? ReviewType.Approve : (pr.currentUserReviewState === 'CHANGES_REQUESTED' ? ReviewType.RequestChanges : ReviewType.Comment)); + + async function submitAction(action: ReviewType): Promise<void> { + const { value } = textareaRef.current!; + if (pr.continueOnGitHub && action !== ReviewType.Comment) { + await openOnGitHub(); + return; + } + setBusy(true); + switch (action) { + case ReviewType.RequestChanges: + await requestChanges(value); + break; + case ReviewType.Approve: + await approve(value, addAttestation); + break; + default: + await submit(value); + } + setBusy(false); + } + + async function defaultSubmitAction(): Promise<void> { + await submitAction(currentSelection); + } + + const onChangeTextarea = (e: React.ChangeEvent<HTMLTextAreaElement>): void => { + updatePR({ pendingCommentText: e.target.value }); + }; + + const onKeyDown = e => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + defaultSubmitAction(); + } + }; + + const availableActions: { comment?: string, approve?: string, requestChanges?: string } = pr.isAuthor + ? { comment: 'Comment' } + : pr.continueOnGitHub + ? { + comment: 'Comment', + approve: 'Approve on github.com', + requestChanges: 'Request changes on github.com', + } + : 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) + + const showAttestationCheckbox = !!pr.attestationCommitsEnabled && !!pr.isCurrentlyCheckedOut && !!availableActions.approve && availableActions.approve === COMMENT_METHODS.approve; + + return ( + <span className="comment-form"> + <div className="textarea-wrapper"> + <textarea + id={COMMENT_TEXTAREA_ID} + name="body" + placeholder="Leave a comment" + ref={textareaRef as React.MutableRefObject<HTMLTextAreaElement>} + value={pr.pendingCommentText ?? ''} + onChange={onChangeTextarea} + onKeyDown={onKeyDown} + onPaste={onPasteUploadFiles(uploadPastedFilesIntoPendingComment)} + disabled={isBusy || pr.busy} + /> + <button + type="button" + title="Upload files" + className="comment-upload-button icon-button" + onClick={() => uploadFilesIntoPendingComment()} + disabled={isBusy || pr.busy} + > + {cloudUploadIcon} + </button> + </div> + {showAttestationCheckbox ? ( + <div className="attestation-checkbox-wrapper checkbox-wrapper attestation-checkbox-row" title="Add a signed attestation commit to the head of the pull request branch when approving."> + <input + id="attestation-checkbox-simple" + type="checkbox" + name="add-attestation" + checked={addAttestation} + disabled={isBusy || pr.busy} + onChange={(e) => setAddAttestation(e.currentTarget.checked)} + /> + <label htmlFor="attestation-checkbox-simple" className="attestation-checkbox-label">Add attestation</label> + </div> + ) : null} + <div className='comment-button'> + <ContextDropdown + optionsContext={() => makeCommentMenuContext(pr.owner, pr.repo, pr.number, 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/commitVerification.tsx b/webviews/components/commitVerification.tsx new file mode 100644 index 0000000000..b5f00a2677 --- /dev/null +++ b/webviews/components/commitVerification.tsx @@ -0,0 +1,115 @@ +/*--------------------------------------------------------------------------------------------- + * 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, useRef, useState } from 'react'; +import { checkIcon, errorIcon } from './icon'; +import { Avatar } from './user'; +import { CommitEvent } from '../../src/common/timelineEvent'; + +function commitVerificationMessage(verification: NonNullable<CommitEvent['verification']>): string { + if (verification.verified) { + if (verification.wasSignedByGitHub) { + return 'This commit was created on GitHub.com and signed with GitHub\u2019s verified signature.'; + } + return 'This commit was signed with the committer\u2019s verified signature.'; + } + return 'This commit is signed, but the signature could not be verified.'; +} + +export function CommitVerificationBadge({ verification, committedDate }: { verification: CommitEvent['verification']; committedDate: Date; }) { + const [open, setOpen] = useState(false); + const containerRef = useRef<HTMLSpanElement>(null); + + useEffect(() => { + if (!open) { + return; + } + const onDocumentClick = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setOpen(false); + } + }; + document.addEventListener('mousedown', onDocumentClick); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onDocumentClick); + document.removeEventListener('keydown', onKeyDown); + }; + }, [open]); + + if (!verification || verification.state === 'UNSIGNED') { + return null; + } + + const verified = verification.verified; + const label = verified ? 'Verified' : 'Unverified'; + const signer = verification.signer; + const keyLabel = verification.keyId + ? { name: 'GPG key ID', value: verification.keyId } + : (verification.keyFingerprint + ? { name: 'SSH key fingerprint', value: verification.keyFingerprint } + : undefined); + + return ( + <span className="verified-pill-container" ref={containerRef}> + <span + className={`verified-pill ${verified ? 'verified' : 'unverified'}`} + role="button" + tabIndex={0} + aria-expanded={open} + onClick={() => setOpen(value => !value)} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setOpen(value => !value); + } + }} + > + {label} + </span> + {open && ( + <div className="verified-popover" role="tooltip"> + <div className="verified-popover-header"> + <span className={`verified-popover-icon ${verified ? 'verified' : 'unverified'}`}> + {verified ? checkIcon : errorIcon} + </span> + <span className="verified-popover-message"> + {commitVerificationMessage(verification)} + </span> + </div> + {signer && ( + <div className="verified-popover-signer"> + <div className="avatar-container"> + <Avatar for={{ login: signer.login, avatarUrl: signer.avatarUrl, url: `https://github.com/${signer.login}` }} /> + </div> + <div className="verified-popover-signer-names"> + <span className="verified-popover-signer-login">{signer.login}</span> + {signer.name && <span className="verified-popover-signer-name">{signer.name}</span>} + </div> + </div> + )} + {keyLabel && ( + <div className="verified-popover-detail"> + {keyLabel.name}: {keyLabel.value} + </div> + )} + {verified && ( + <div className="verified-popover-detail"> + Verified on {new Date(committedDate).toLocaleString(undefined, { + year: 'numeric', month: 'short', day: 'numeric', + hour: 'numeric', minute: '2-digit', + })} + </div> + )} + </div> + )} + </span> + ); +} 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 new file mode 100644 index 0000000000..88e0f28bca --- /dev/null +++ b/webviews/components/diff.tsx @@ -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 React from 'react'; + +import { DiffChangeType, DiffHunk, DiffLine } from '../../src/common/diffHunk'; + +function Diff({ hunks }: { hunks: DiffHunk[] }) { + return ( + <div className="diff"> + {hunks.map((hunk, index) => ( + <Hunk key={index} hunk={hunk} /> + ))} + </div> + ); +} + +export default Diff; + +const Hunk = ({ hunk, maxLines = 8 }: { hunk: DiffHunk; maxLines?: number }) => ( + <> + {hunk.diffLines.slice(-maxLines).map(line => ( + <div key={keyForDiffLine(line)} className={`diffLine ${getDiffChangeClass(line.type)}`}> + <LineNumber num={line.oldLineNumber} /> + <LineNumber num={line.newLineNumber} /> + <div className="diffTypeSign">{line.raw.substr(0, 1)}</div> + <div className="lineContent">{line.raw.substr(1)}</div> + </div> + ))} + </> +); + +const keyForDiffLine = (diffLine: DiffLine) => `${diffLine.oldLineNumber}->${diffLine.newLineNumber}`; + +const LineNumber = ({ num }: { num: number }) => <div className="lineNumber">{num > 0 ? num : ' '}</div>; + +const getDiffChangeClass = (type: DiffChangeType) => DiffChangeType[type].toLowerCase(); diff --git a/webviews/components/dropdown.tsx b/webviews/components/dropdown.tsx new file mode 100644 index 0000000000..ca55183bae --- /dev/null +++ b/webviews/components/dropdown.tsx @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import React, { useState } from 'react'; +import { v4 as uuid } from 'uuid'; +import { chevronDownIcon } from './icon'; + +const enum KEYCODES { + esc = 27, + down = 40, + up = 38, +} + +export const Dropdown = ({ options, defaultOption, disabled, submitAction, changeAction }: { options: { [key: string]: string }, defaultOption: string, disabled?: boolean, submitAction: (string) => Promise<void>, changeAction?: (string) => Promise<void> }) => { + const [selectedMethod, selectMethod] = useState<string>(defaultOption); + const [areOptionsVisible, setOptionsVisible] = useState<boolean>(false); + + const dropdownId = uuid(); + const EXPAND_OPTIONS_BUTTON = `expandOptions${dropdownId}`; + + const onClick = () => { + setOptionsVisible(!areOptionsVisible); + }; + + const onMethodChange = e => { + selectMethod(e.target.value); + setOptionsVisible(false); + const primaryButton = document.getElementById(`confirm-button${dropdownId}`); + primaryButton?.focus(); + if (changeAction) { + changeAction(e.target.value); + } + }; + + const onKeyDown = e => { + if (areOptionsVisible) { + const currentElement = document.activeElement; + + switch (e.keyCode) { + case KEYCODES.esc: + setOptionsVisible(false); + const expandOptionsButton = document.getElementById(EXPAND_OPTIONS_BUTTON); + expandOptionsButton?.focus(); + break; + + case KEYCODES.down: + if (!currentElement?.id || currentElement.id === EXPAND_OPTIONS_BUTTON) { + const firstOptionButton = document.getElementById(`${dropdownId}option0`); + firstOptionButton?.focus(); + } else { + const regex = new RegExp(`${dropdownId}option([0-9])`); + const result = currentElement.id.match(regex); + if (result?.length) { + const index = parseInt(result[1]); + if (index < Object.entries(options).length - 1) { + const nextOption = document.getElementById(`${dropdownId}option${index + 1}`); + nextOption?.focus(); + } + } + } + break; + + case KEYCODES.up: + if (!currentElement?.id || currentElement.id === EXPAND_OPTIONS_BUTTON) { + const lastIndex = Object.entries(options).length - 1; + const lastOptionButton = document.getElementById(`${dropdownId}option${lastIndex}`); + lastOptionButton?.focus(); + } else { + const regex = new RegExp(`${dropdownId}option([0-9])`); + const result = currentElement.id.match(regex); + if (result?.length) { + const index = parseInt(result[1]); + if (index > 0) { + const nextOption = document.getElementById(`${dropdownId}option${index - 1}`); + nextOption?.focus(); + } + } + } + break; + } + } + }; + + const hasMultipleOptions = Object.entries(options).length > 1; + const expandButtonClass = !hasMultipleOptions ? 'hidden' : areOptionsVisible ? 'open' : ''; + + return ( + <div className="select-container" onKeyDown={onKeyDown}> + <div className="select-control"> + <Confirm + dropdownId={dropdownId} + className={hasMultipleOptions ? 'select-left' : ''} + options={options} + selected={selectedMethod} + submitAction={submitAction} + disabled={!!disabled} + /> + {hasMultipleOptions ? <div className={`split${disabled ? ' disabled' : ''}`}><div className={`separator${disabled ? ' disabled' : ''}`}></div></div> : null} + <button id={EXPAND_OPTIONS_BUTTON} className={'select-right ' + expandButtonClass} aria-label='Expand button options' onClick={onClick}> + {chevronDownIcon} + </button> + </div> + <div className={areOptionsVisible ? 'options-select' : 'hidden'}> + {Object.entries(options).map(([method, text], index) => ( + <button id={`${dropdownId}option${index}`} key={method} value={method} onClick={onMethodChange}> + {text} + </button> + ))} + </div> + </div> + ); +}; + +function Confirm({ + dropdownId, + className, + options, + selected, + disabled, + submitAction, +}: { + dropdownId: string; + className: string; + options: { [key: string]: string }; + selected: string; + disabled: boolean; + submitAction: (selected: string) => Promise<void>; +}) { + const [isBusy, setBusy] = useState(false); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + try { + setBusy(true); + await submitAction(selected); + } finally { + setBusy(false); + } + }; + + return ( + <form onSubmit={onSubmit}> + <input disabled={isBusy || disabled} type="submit" className={className} id={`confirm-button${dropdownId}`} value={options[selected]} /> + </form> + ); +} diff --git a/webviews/components/header.tsx b/webviews/components/header.tsx new file mode 100644 index 0000000000..8fc8592f6d --- /dev/null +++ b/webviews/components/header.tsx @@ -0,0 +1,406 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import React, { useContext, useState } from 'react'; +import { ContextDropdown } from './contextDropdown'; +import { copilotErrorIcon, copilotInProgressIcon, copilotSuccessIcon, copyIcon, editIcon, gitMergeIcon, gitPullRequestClosedIcon, gitPullRequestDraftIcon, gitPullRequestIcon, issuescon, loadingIcon, passIcon } from './icon'; +import { AuthorLink, Avatar } from './user'; +import { copilotEventToStatus, CopilotPRStatus, mostRecentCopilotEvent } from '../../src/common/copilot'; +import { CopilotStartedEvent, TimelineEvent } from '../../src/common/timelineEvent'; +import { GithubItemStateEnum, StateReason } from '../../src/github/interface'; +import { BaseContext, 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'; + +export function Header({ + canEdit, + state, + head, + base, + title, + titleHTML, + number, + url, + author, + isCurrentlyCheckedOut, + isDraft, + isIssue, + doneCheckoutBranch, + events, + owner, + repo, + busy, + stateReason +}: PullRequest) { + const [currentTitle, setCurrentTitle] = useStateProp(title); + const [inEditMode, setEditMode] = useState(false); + const codingAgentEvent = mostRecentCopilotEvent(events); + + return ( + <> + <Title + title={currentTitle} + titleHTML={titleHTML} + number={number} + url={url} + inEditMode={inEditMode} + setEditMode={setEditMode} + setCurrentTitle={setCurrentTitle} + canEdit={canEdit} + 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> + </> + ); +} + +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 + className="editing-form title-editing-form" + onSubmit={async evt => { + evt.preventDefault(); + try { + const form = evt.currentTarget; + const firstElement = form.elements[0] as HTMLInputElement | undefined; + const txt = firstElement ? firstElement.value : ''; + await setTitle(txt); + setCurrentTitle(txt); + } finally { + setEditMode(false); + } + }} + > + <input type="text" style={{ width: '100%' }} defaultValue={title} ></input> + <div className="form-actions"> + <button type="button" className="secondary" onClick={() => setEditMode(false)}> + Cancel + </button> + <button type="submit">Update</button> + </div> + </form> + ); + + const context: BaseContext = { + '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} 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> + ); + + const editableTitle = inEditMode ? titleForm : displayTitle; + return editableTitle; +} + +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"> + <CheckoutButton {...{ isCurrentlyCheckedOut, isIssue, doneCheckoutBranch, owner, repo, number }} /> + <button title="Refresh with the latest data from GitHub" onClick={refresh} className="secondary"> + Refresh + </button> + {busy ? ( + <div className='spinner'> + {loadingIcon} + </div> + ) : null} + </div> + ); +} + +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, + 'github:codingAgentMenu': true, + ...sessionLink + }; + + 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'>{icon}</span> + <span>{text}</span> + </div> + <div className="author"> + {<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> + ); +} + +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) => { + try { + setBusy(true); + + switch (command) { + case 'checkout': + await checkout(); + break; + case 'exitReviewMode': + await exitReviewMode(); + break; + case 'openChanges': + await openChanges(); + break; + default: + throw new Error(`Can't find action ${command}`); + } + } finally { + setBusy(false); + } + }; + + if (isIssue) { + return null; + } + + const context: OverviewContext = { + 'preventDefaultContextMenuItems': true, + 'github:checkoutMenu': true, + owner, + repo, + number + }; + + const actions: { label: string; value: string; action: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void }[] = []; + + if (isCurrentlyCheckedOut) { + actions.push({ + label: `Checkout '${doneCheckoutBranch}'`, + value: '', + action: () => onClick('exitReviewMode') + }); + } else { + 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, isIssue: boolean, stateReason?: StateReason) { + const closed = isIssue ? passIcon : gitPullRequestClosedIcon; + const open = isIssue ? issuescon : gitPullRequestIcon; + + if (state === GithubItemStateEnum.Merged) { + return { text: 'Merged', color: 'merged', icon: gitMergeIcon }; + } else if (state === GithubItemStateEnum.Open) { + return isDraft ? { text: 'Draft', color: 'draft', icon: gitPullRequestDraftIcon } : { text: 'Open', color: 'open', icon: open }; + } else { + let closedColor: string = 'closed'; + if (isIssue) { + closedColor = stateReason !== 'COMPLETED' ? 'draft' : 'merged'; + } + return { text: 'Closed', color: closedColor, icon: closed }; + } +} + +function getActionText(state: GithubItemStateEnum) { + if (state === GithubItemStateEnum.Merged) { + return 'merged changes'; + } else { + return 'wants to merge changes'; + } +} diff --git a/webviews/components/icon.tsx b/webviews/components/icon.tsx new file mode 100644 index 0000000000..daca557f3a --- /dev/null +++ b/webviews/components/icon.tsx @@ -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 React from 'react'; + +export const Icon = ({ className = '', src, title }: { className?: string; title?: string; src: string }) => ( + <span className={`icon ${className}`} title={title} dangerouslySetInnerHTML={{ __html: src }} /> +); + +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 cloudUploadIcon = <Icon src={require('../../resources/icons/codicons/cloud-upload.svg')} />; +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 outputIcon = <Icon src={require('../../resources/icons/codicons/output.svg')} />; +export const skipIcon = <Icon src={require('../../resources/icons/codicons/skip.svg')} className='skip' />; + +// 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 new file mode 100644 index 0000000000..c8d7d31ced --- /dev/null +++ b/webviews/components/merge.tsx @@ -0,0 +1,731 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import React, { + ChangeEventHandler, + useCallback, + useContext, + useEffect, + useReducer, + useRef, + useState, +} from 'react'; +import { AutoMerge, QueuedToMerge } from './automergeSelect'; +import { ContextDropdown } from './contextDropdown'; +import { Dropdown } from './dropdown'; +import { checkIcon, circleFilledIcon, closeIcon, gitMergeIcon, loadingIcon, outputIcon, 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, ReadyForReviewAndMergeContext, ReadyForReviewContext } 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 ? gitMergeIcon : null}</div>{' '} + {'Pull request successfully merged.'} + </div> + ) : pr.state === GithubItemStateEnum.Closed ? ( + <div className="branch-status-message">{'This pull request is closed.'}</div> + ) : null; +}; + +const DeleteOption = ({ pr }: { pr: PullRequest }) => { + return pr.state === GithubItemStateEnum.Open ? null : <DeleteBranch {...pr} />; +}; + +const StatusChecks = ({ pr }: { pr: PullRequest }) => { + const { state, status } = pr; + const [showDetails, toggleDetails] = useReducer( + show => !show, + status?.statuses.some(s => s.state === CheckState.Failure) ?? false, + ) as [boolean, () => void]; + + useEffect(() => { + if (status?.statuses.some(s => s.state === CheckState.Failure) ?? false) { + if (!showDetails) { + toggleDetails(); + } + } else { + if (showDetails) { + toggleDetails(); + } + } + }, [status?.statuses]); + + return state === GithubItemStateEnum.Open && status?.statuses.length ? ( + <> + <div className="status-section"> + <div className="status-item"> + <StateIcon state={status.state} /> + <p className="status-item-detail-text">{getSummaryLabel(status.statuses)}</p> + <button + id="status-checks-display-button" + className="secondary small-button" + onClick={toggleDetails} + aria-expanded={showDetails} + > + {showDetails ? 'Hide' : 'Show'} + </button> + </div> + {showDetails ? <StatusCheckDetails statuses={status.statuses} /> : null} + </div> + </> + ) : 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 }) => { + 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"> + {' '} + {reviewInfos.map(reviewerInfo => { + + return <Reviewer key={reviewerId(reviewerInfo.reviewState.reviewer)} {...reviewerInfo} />; + })} + </div> + ); +}; + +export const StatusChecksSection = ({ pr, isSimple }: { pr: PullRequest; isSimple: boolean }) => { + if (pr.isIssue) { + return null; + } + + return ( + <div id="status-checks"> + { + <> + <PRStatusMessage pr={pr} isSimple={isSimple} /> + <RequiredReviewers pr={pr} /> + <StatusChecks pr={pr} /> + <InlineReviewers pr={pr} isSimple={isSimple} /> + <MergeStatusAndActions pr={pr} isSimple={isSimple} /> + <DeleteOption pr={pr} /> + </> + } + </div> + ); +}; + +export const MergeStatusAndActions = ({ pr, isSimple }: { pr: PullRequest; isSimple: boolean }) => { + const { create, checkMergeability } = useContext(PullRequestContext); + + if (isSimple && pr.state !== GithubItemStateEnum.Open) { + const string = 'Create New Pull Request...'; + return ( + <div className="branch-status-container"> + <form> + <button type="submit" onClick={create}> + {string} + </button> + </form> + </div> + ); + } else if (pr.state !== GithubItemStateEnum.Open) { + return null; + } + + const { mergeable: _mergeable } = pr; + + const [mergeable, setMergeability] = useState(_mergeable); + if ((_mergeable !== mergeable) && (_mergeable !== PullRequestMergeability.Unknown)) { + setMergeability(_mergeable); + } + + useEffect(() => { + const handle = setInterval(async () => { + if (mergeable === PullRequestMergeability.Unknown) { + const newMergeability = await checkMergeability(); + setMergeability(newMergeability); + } + }, 3000); + return () => clearInterval(handle); + }, [mergeable]); + + return ( + <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} /> + </div> + ); +}; + +export default StatusChecksSection; + +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-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 OfferToUpdate = ({ mergeable, isSimple, isCurrentlyCheckedOut, canUpdateBranch }: { mergeable: PullRequestMergeability; isSimple: boolean; isCurrentlyCheckedOut: boolean, canUpdateBranch: boolean }) => { + const { updateBranch } = useContext(PullRequestContext); + const [isBusy, setBusy] = useState(false); + 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); + const result = await readyForReview(); + updatePR(result); + } finally { + setBusy(false); + } + }, [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 : 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'> + <ContextDropdown + optionsContext={() => { + if (!pr) { + throw new Error('PR context is required for ready for review options'); + } + let ctx: ReadyForReviewContext | ReadyForReviewAndMergeContext = { + 'preventDefaultContextMenuItems': true, + 'github:readyForReviewMenu': true, + owner: pr.owner, + repo: pr.repo, + number: pr.number, + }; + if (isCopilotOnMyBehalf) { + ctx = { + ...ctx, + 'github:readyForReviewMenuWithMerge': true, + mergeMethod, + }; + } + return JSON.stringify(ctx); + }} + 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> + {nbsp}using method{nbsp} + <MergeSelect ref={select} {...pr} /> + </div> + ); +}; + +export const PrActions = ({ pr, isSimple }: { pr: PullRequest; isSimple: boolean }) => { + 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 + if (!canEdit) { + return null; + } + + return <ReadyForReview isSimple={isSimple} isCopilotOnMyBehalf={isCopilotOnMyBehalf} mergeMethod={defaultMergeMethod} />; + } + + if (mergeable === PullRequestMergeability.Mergeable && hasWritePermission && !pr.mergeQueueEntry) { + return isSimple ? <MergeSimple {...pr} /> : <Merge {...pr} />; + } else if (!isSimple && hasWritePermission && !pr.mergeQueueEntry) { + const ctx = useContext(PullRequestContext); + return ( + <AutoMerge + updateState={(params: Partial<{ autoMerge: boolean; autoMergeMethod: MergeMethod }>) => { + return ctx.updateAutoMerge(params); + }} + {...pr} + baseHasMergeQueue={!!pr.mergeQueueMethod} + defaultMergeMethod={pr.autoMergeMethod ?? pr.defaultMergeMethod} + /> + ); + } else if (pr.mergeQueueEntry) { + return <QueuedToMerge mergeQueueEntry={pr.mergeQueueEntry} />; + } + + return null; +}; + +export const MergeOnGitHub = () => { + const { openOnGitHub } = useContext(PullRequestContext); + return ( + <button id="merge-on-github" type="submit" onClick={() => openOnGitHub()}> + Merge on github.com + </button> + ); +}; + +export const MergeSimple = (pr: PullRequest) => { + 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 newContext = await merge({ + title: '', + description: '', + method: selected, + }); + updatePR(newContext); + } + + const availableOptions = Object.keys(MERGE_METHODS) + .filter(method => pr.mergeMethodsAvailability[method]) + .reduce((methods, key) => { + methods[key] = MERGE_METHODS[key]; + return methods; + }, {}); + + return <Dropdown options={availableOptions} defaultOption={pr.defaultMergeMethod} submitAction={submitAction} />; +}; + +export const DeleteBranch = (pr: PullRequest) => { + const { deleteBranch } = useContext(PullRequestContext); + const [isBusy, setBusy] = useState(false); + + if (pr.isRemoteHeadDeleted !== false && pr.isLocalHeadDeleted !== false) { + return <div />; + } else { + return ( + <div className="branch-status-container"> + <form + onSubmit={async event => { + event.preventDefault(); + + try { + setBusy(true); + const result = await deleteBranch(); + if (result && result.cancelled) { + setBusy(false); + } + } finally { + setBusy(false); + } + }} + > + <button disabled={isBusy} className="secondary" type="submit"> + Delete Branch... + </button> + </form> + </div> + ); + } +}; + +function ConfirmMerge({ pr, method, cancel }: { pr: PullRequest; method: MergeMethod; cancel: () => void }) { + const { merge, updatePR, changeEmail } = useContext(PullRequestContext); + const [isBusy, setBusy] = useState(false); + const emailForCommit = pr.emailForCommit; + return ( + <div> + <form id='merge-comment-form' + onSubmit={async event => { + event.preventDefault(); + + try { + setBusy(true); + const { title, description }: any = event.target; + const mergeResult = await merge({ + title: title?.value, + description: description?.value, + method, + email: emailForCommit + }); + updatePR(mergeResult); + } finally { + setBusy(false); + } + }} + > + {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">{method === 'rebase' ? 'Confirm ' : ''}{MERGE_METHODS[method]}</button> + </div> + </form> + </div> + ); +} + +function getDefaultTitleText(mergeMethod: string, pr: PullRequest) { + switch (mergeMethod) { + case 'merge': + return pr.mergeCommitMeta?.title ?? `Merge pull request #${pr.number} from ${pr.head}`; + case 'squash': + return pr.squashCommitMeta?.title ?? `${pr.title} (#${pr.number})`; + default: + return ''; + } +} + +function getDefaultDescriptionText(mergeMethod: string, pr: PullRequest) { + switch (mergeMethod) { + case 'merge': + return pr.mergeCommitMeta?.description ?? pr.title; + case 'squash': + return pr.squashCommitMeta?.description ?? ''; + default: + return ''; + } +} + +const MERGE_METHODS = { + merge: 'Create Merge Commit', + squash: 'Squash and Merge', + rebase: 'Rebase and Merge', +}; + +type MergeSelectProps = Pick<PullRequest, 'mergeMethodsAvailability'> & + 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, 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>; + }, +); + +// 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[] }) => { + const { viewCheckLogs } = useContext(PullRequestContext); + const [loadingLogId, setLoadingLogId] = useState<string | null>(null); + // 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]; + }); + + const handleViewLogs = async (s: PullRequestCheckStatus) => { + setLoadingLogId(s.id); + try { + await viewCheckLogs(s); + } finally { + setLoadingLogId(null); + } + }; + + 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} + {s.isCheckRun && s.databaseId ? ( + s.state === CheckState.Failure ? ( + <button className="icon-button" title="View Logs" onClick={() => handleViewLogs(s)}> + {loadingLogId === s.id ? loadingIcon : outputIcon} + </button> + ) : <span className="view-check-logs-placeholder" /> + ) : null} + </div> + </div> + ))} + </div> + ); +}; + +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 CheckState.Success: + statusAdjective = 'successful'; + break; + case CheckState.Failure: + statusAdjective = 'failed'; + break; + case CheckState.Neutral: + statusAdjective = 'skipped'; + break; + default: + statusAdjective = 'pending'; + } + + const status = + numOfType > 1 ? `${numOfType} ${statusAdjective} checks` : `${numOfType} ${statusAdjective} check`; + + statusPhrases.push(status); + } + + return statusPhrases.join(' and '); +} + +function StateIcon({ state }: { state: CheckState }) { + switch (state) { + case CheckState.Neutral: + return skipIcon; + case CheckState.Success: + return checkIcon; + 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 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 new file mode 100644 index 0000000000..be8c02be01 --- /dev/null +++ b/webviews/components/reviewer.tsx @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import React, { cloneElement, useContext } from 'react'; +import { checkIcon, circleFilledIcon, commentIcon, requestChangesIcon, syncIcon } from './icon'; +import { AuthorLink, Avatar } from './user'; +import { ReviewEvent } from '../../src/common/timelineEvent'; +import { AccountType, isITeam, ReviewState } from '../../src/github/interface'; +import { ariaAnnouncementForReview } from '../common/aria'; +import PullRequestContext from '../common/context'; + +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"> + <Avatar for={reviewer} /> + <AuthorLink for={reviewer} /> + </div> + <div className="reviewer-icons"> + { + ((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(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(requestChangesIcon, { className: 'section-icon changes', title: 'Requested changes' }), +}; diff --git a/webviews/components/sidebar.tsx b/webviews/components/sidebar.tsx new file mode 100644 index 0000000000..59518a6e04 --- /dev/null +++ b/webviews/components/sidebar.tsx @@ -0,0 +1,604 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { closeIcon, copilotIcon, issuescon, passIcon, settingsIcon } from './icon'; +import { Reviewer } from './reviewer'; +import { COPILOT_LOGINS } from '../../src/common/copilot'; +import { gitHubLabelColor } from '../../src/common/utils'; +import { GithubItemStateEnum, IAccount, IMilestone, IProjectItem, isITeam, IssueReference, 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'; + +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, closingIssues = [], 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 && ( + <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 }); + }} + 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" + > + {settingsIcon} + </button> + </div> + )} + > + {reviewers && reviewers.length ? ( + reviewers.map(state => ( + <Reviewer key={reviewerId(state.reviewer)} {...{ reviewState: state }} /> + )) + ) : ( + <div className="section-placeholder">None yet</div> + )} + {!pr!.isDraft && (hasWritePermission || pr!.isAuthor) && ( + <ConvertToDraft /> + )} + </Section> + )} + + <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, 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" + > + {settingsIcon} + </button> + </div> + )} + > + {assignees && assignees.length ? ( + 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 ? ( + <> + — + <a + className="assign-yourself" + onClick={async () => { + const newAssignees = await addAssigneeYourself(); + updatePR({ assignees: newAssignees.assignees, events: newAssignees.events }); + }} + > + assign yourself + </a> + </> + ) : null} + </div> + )} + </Section> + + <Section + id="labels" + title="Labels" + hasWritePermission={hasWritePermission} + onHeaderClick={async () => { + const newLabels = await addLabels(); + updatePR({ labels: newLabels.added }); + }} + > + {labels.length ? ( + <div className="labels-list"> + {labels.map(label => ( + <Label key={label.name} {...label} canDelete={hasWritePermission} isDarkTheme={pr!.isDarkTheme}> + {hasWritePermission ? ( + <button className="icon-button" onClick={() => removeLabel(label.name)}> + {closeIcon}️ + </button> + ) : null} + </Label> + ))} + </div> + ) : ( + <div className="section-placeholder">None yet</div> + )} + </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 }); + }} + > + {milestone ? ( + <Milestone key={milestone.title} {...milestone} canDelete={hasWritePermission} /> + ) : ( + <div className="section-placeholder">No milestone</div> + )} + </Section> + + {closingIssues.length > 0 && ( + <Section + id="closingIssues" + title="Linked Issues" + hasWritePermission={false} + > + {closingIssues.map(issue => ( + <IssueItem key={issue.url} issue={issue} /> + ))} + </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 { canDelete, title } = milestone; + return ( + <div className="labels-list"> + <div + className="section-item label" + style={{ + backgroundColor: labelColor.backgroundColor, + color: labelColor.textColor, + borderColor: `${labelColor.borderColor}`, + }} + > + {title} + {canDelete ? ( + <button + className="icon-button" + onClick={async () => { + await removeMilestone(); + 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}️ + </button> + ) : null} + </div> + </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> + ); +} + +function IssueItem({ issue }: { issue: IssueReference }) { + const isOpen = issue.state === GithubItemStateEnum.Open; + return ( + <a className="issue-item" href={issue.url} title={`#${issue.number} ${issue.title}`}> + <span className={`section-icon ${isOpen ? 'issue-open' : 'issue-closed'}`}> + {isOpen ? issuescon : passIcon} + </span> + <span className="issue-item-text">#{issue.number} {issue.title}</span> + </a> + ); +} + diff --git a/webviews/components/space.tsx b/webviews/components/space.tsx new file mode 100644 index 0000000000..7e981d1e86 --- /dev/null +++ b/webviews/components/space.tsx @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as React from 'react'; + +export const nbsp = String.fromCharCode(0xa0); + +export const Spaced = ({ children }) => { + const count = React.Children.count(children); + return React.createElement(React.Fragment, { + children: React.Children.map(children, (c, i) => + typeof c === 'string' ? `${i > 0 ? nbsp : ''}${c}${((i < count - 1) && (typeof children[i + 1] !== 'string')) ? nbsp : ''}` : c, + ), + }); +}; diff --git a/webviews/components/stickyHeader.tsx b/webviews/components/stickyHeader.tsx new file mode 100644 index 0000000000..a459720522 --- /dev/null +++ b/webviews/components/stickyHeader.tsx @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as React from 'react'; +import { getStatus } from './header'; +import { copyIcon } from './icon'; +import { PullRequest } from '../../src/github/views'; +import PullRequestContext from '../common/context'; + +export function useStickyHeader(titleRef: React.RefObject<HTMLDivElement | null>): boolean { + const [isStuck, setIsStuck] = React.useState(false); + + React.useEffect(() => { + const el = titleRef.current; + if (!el) { + return; + } + + const observer = new IntersectionObserver( + ([entry]) => setIsStuck(!entry.isIntersecting), + { threshold: 0 }, + ); + observer.observe(el); + + return () => observer.disconnect(); + }, [titleRef]); + + return isStuck; +} + +export function StickyHeader({ pr, visible }: { pr: PullRequest; visible: boolean }): JSX.Element { + const { text, color, icon } = getStatus(pr.state, !!pr.isDraft, pr.isIssue, pr.stateReason); + const { copyPrLink } = React.useContext(PullRequestContext); + + const stickyRef = React.useCallback((node: HTMLDivElement | null) => { + if (node) { + if (visible) { + node.removeAttribute('inert'); + } else { + node.setAttribute('inert', ''); + } + } + }, [visible]); + + return ( + <div ref={stickyRef} className={`sticky-header${visible ? ' visible' : ''}`}> + <div className="sticky-header-left"> + <div id="sticky-status" className={`status-badge-${color}`}> + <span className="icon">{icon}</span> + <span>{text}</span> + </div> + <span className="sticky-header-title" dangerouslySetInnerHTML={{ __html: pr.titleHTML }} /> + <a + className="sticky-header-number" + href={pr.url} + title={pr.url} + data-vscode-context={JSON.stringify({ + url: pr.url, + preventDefaultContextMenuItems: true, + owner: pr.owner, + repo: pr.repo, + number: pr.number, + 'github:copyMenu': true, + })} + > + #{pr.number} + </a> + <button title="Copy Link" onClick={copyPrLink} className="icon-button sticky-header-copy" aria-label="Copy Pull Request Link"> + {copyIcon} + </button> + </div> + </div> + ); +} diff --git a/webviews/components/timeline.tsx b/webviews/components/timeline.tsx new file mode 100644 index 0000000000..315c7491aa --- /dev/null +++ b/webviews/components/timeline.tsx @@ -0,0 +1,665 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { CommentView } from './comment'; +import { CommitVerificationBadge } from './commitVerification'; +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'; + +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} />; + case EventType.Reviewed: + return <ReviewEventView key={`review${event.id}`} {...event} />; + case EventType.Commented: + return <CommentEventView key={`comment${event.id}`} {...event} />; + case EventType.Merged: + return <MergedEventView key={`merged${event.id}`} {...event} />; + case EventType.Assigned: + 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; + + +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> + <div className="timeline-detail"> + <div className='status-section'> + <CommitStateIcon status={event.status} /> + </div> + <CommitVerificationBadge verification={event.verification} committedDate={event.committedDate} /> + <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> + ); +}; + +const NewCommitsSinceReviewEventView = () => { + 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"> + {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={viewChanges} + disabled={busy} + > + View Changes + </button> + </div> + ); +}; + +const positionKey = (comment: IComment) => + comment.position !== null ? `pos:${comment.position}` : `ori:${comment.originalPosition}`; + +// Group review comments so that each rendered CommentThread contains exactly +// one review thread's comments. Prefer grouping by the thread's GraphQL id +// (set on each IComment when parsed from a review thread) so that resolve / +// unresolve actions target the right thread. Fall back to path+position for +// comments without a threadId (e.g. pending review comments not yet posted). +const groupCommentsByThread = (comments: IComment[]) => + groupBy(comments, comment => + comment.threadId + ? `thread:${comment.threadId}` + : comment.path + ':' + positionKey(comment)); + +const ReviewEventView = (event: ReviewEvent) => { + const comments = groupCommentsByThread(event.comments); + const reviewIsPending = event.state === 'PENDING'; + return ( + <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> + ) : null} + + {reviewIsPending ? <AddReviewSummaryComment /> : null} + </CommentView> + ); +}; + +function CommentThread({ thread, event }: { thread: IComment[]; event: ReviewEvent }) { + const comment = thread[0]; + const [revealed, setRevealed] = useState(!comment.isResolved); + const [resolved, setResolved] = useState(!!comment.isResolved); + const { openDiff, toggleResolveComment } = useContext(PullRequestContext); + + useEffect(() => { + setResolved(!!comment.isResolved); + setRevealed(!comment.isResolved); + }, [comment.isResolved]); + // Look up this group's own review thread info. A single ReviewEvent can + // have comments on multiple threads, so resolve actions must target the + // thread that owns this group's comments - looked up by `comment.threadId`. + const reviewThread = comment.threadId ? event.reviewThreads?.[comment.threadId] : undefined; + const resolvePermission = + reviewThread && + ((reviewThread.canResolve && !reviewThread.isResolved) || + (reviewThread.canUnresolve && reviewThread.isResolved)); + + const toggleResolve = () => { + if (reviewThread) { + const newResolved = !resolved; + setRevealed(!newResolved); + setResolved(newResolved); + toggleResolveComment(reviewThread.threadId, thread, newResolved); + } + }; + + return ( + <div key={event.id} className="diff-container"> + <div className="resolved-container"> + <div> + {comment.position === null ? ( + <span> + <span>{comment.path}</span> + <span className="outdatedLabel">Outdated</span> + </span> + ) : ( + <a className="diffPath" onClick={() => openDiff(comment)}> + {comment.path} + </a> + )} + {!resolved && !revealed ? <span className="unresolvedLabel">Unresolved</span> : null} + </div> + <button className="secondary" onClick={() => setRevealed(!revealed)}> + {revealed ? 'Hide' : 'Show'} + </button> + </div> + {revealed ? ( + <div> + <Diff hunks={comment.diffHunks ?? []} /> + {thread.map(c => ( + <CommentView key={c.id} comment={c} /> + ))} + {resolvePermission ? ( + <div className="resolve-comment-row"> + <button className="secondary comment-resolve" onClick={() => toggleResolve()}> + {resolved ? 'Unresolve Conversation' : 'Resolve Conversation'} + </button> + </div> + ) : null} + </div> + ) : null} + </div> + ); +} + +function AddReviewSummaryComment() { + 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(''); + const [addAttestation, setAddAttestation] = useState(false); + + const showAttestationCheckbox = !isAuthor && !!pr?.attestationCommitsEnabled && !!pr?.isCurrentlyCheckedOut; + + 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, addAttestation); + break; + case ReviewType.Approve: + await approve(value, addAttestation); + break; + default: + await submit(value, addAttestation); + } + 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 + 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> + {showAttestationCheckbox ? ( + <div className="attestation-checkbox-wrapper checkbox-wrapper" title="Add a signed attestation commit to the head of the pull request branch when submitting this review."> + <input + id="attestation-checkbox-summary" + type="checkbox" + name="add-attestation" + checked={addAttestation} + disabled={isBusy || pr?.busy} + onChange={(e) => setAddAttestation(e.currentTarget.checked)} + /> + <label htmlFor="attestation-checkbox-summary" className="attestation-checkbox-label">Add attestation</label> + </div> + ) : null} + {isAuthor ? null : ( + <button + id="request-changes" + className='secondary' + disabled={isBusy || pr.busy || shouldDisableButtons} + onClick={(event) => submitAction(event, ReviewType.RequestChanges)} + > + Request Changes + </button> + )} + {isAuthor ? null : ( + <button + id="approve" className='secondary' + disabled={isBusy || pr.busy} + onClick={(event) => submitAction(event, ReviewType.Approve)} + > + Approve + </button> + )} + <button + disabled={isBusy || pr.busy || shouldDisableButtons} + onClick={(event) => submitAction(event, ReviewType.Comment)} + >Submit Review</button> + </div> + </form> + ); +} + +const CommentEventView = (event: CommentEvent) => <CommentView headerInEditMode comment={event} />; + +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> + ); +}; + +const HeadDeleteEventView = (event: HeadRefDeleteEvent) => ( + <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"> + deleted the {event.headRef} branch{nbsp} + </div> + </div> + <Timestamp date={event.createdAt} /> + </div> +); + +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 new file mode 100644 index 0000000000..25b15cd05c --- /dev/null +++ b/webviews/components/timestamp.tsx @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * 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, 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}> + {timeString} + </a> + ) : ( + <div className="timestamp" title={title}> + {timeString} + </div> + ); +}; + +export default Timestamp; diff --git a/webviews/components/user.tsx b/webviews/components/user.tsx new file mode 100644 index 0000000000..edfd51e3d7 --- /dev/null +++ b/webviews/components/user.tsx @@ -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 React from 'react'; +import { Icon } from './icon'; +import { IAccount, IActor, ITeam, reviewerLabel } from '../../src/github/interface'; + +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')} /> + )} + </> +); + +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/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/createPullRequestViewNew/index.ts b/webviews/createPullRequestViewNew/index.ts new file mode 100644 index 0000000000..20aa1f5bd3 --- /dev/null +++ b/webviews/createPullRequestViewNew/index.ts @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import '../common/common.css'; +import './index.css'; +import { main } from './app'; + +addEventListener('load', main); diff --git a/webviews/editorWebview/app.tsx b/webviews/editorWebview/app.tsx new file mode 100644 index 0000000000..292438a3b6 --- /dev/null +++ b/webviews/editorWebview/app.tsx @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * 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 { Overview } from './overview'; +import { extractCodeReferenceLinkMetadata } from '../../src/common/utils'; +import { PullRequest } from '../../src/github/views'; +import { COMMENT_TEXTAREA_ID } from '../common/constants'; +import PullRequestContext from '../common/context'; + +export function main() { + render(<Root>{pr => <Overview {...pr} />}</Root>, document.getElementById('app')); +} + +export function Root({ children }) { + const ctx = useContext(PullRequestContext); + 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); + }, []); + + useEffect(() => { + const handleLinkClick = (event: MouseEvent) => { + const target = event.target as HTMLElement; + const anchor = target.closest('a[data-local-file]'); + if (anchor) { + const metadata = extractCodeReferenceLinkMetadata(anchor); + if (metadata) { + // Prevent default navigation, handlers will fallback to opening the link externally if they fail + event.preventDefault(); + event.stopPropagation(); + + // Open diff view for diff links, local file for blob permalinks + if (metadata.linkType === 'diff') { + ctx.openDiffFromLink(metadata.localFile, metadata.startLine, metadata.endLine, metadata.href); + } else { + ctx.openLocalFile(metadata.localFile, metadata.startLine, metadata.endLine, metadata.href); + } + } + } + }; + + document.addEventListener('click', handleLinkClick, true); + return () => document.removeEventListener('click', handleLinkClick, true); + }, [ctx]); + + 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 new file mode 100644 index 0000000000..671b36c8f3 --- /dev/null +++ b/webviews/editorWebview/index.css @@ -0,0 +1,1953 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +#app { + display: grid; + grid-template-columns: 1fr minmax(200px, 300px); + column-gap: 32px; +} + +#title { + grid-column-start: 1; + grid-column-end: 3; + grid-row: 1; +} + +#main { + grid-column: 1; + grid-row: 2; + display: flex; + flex-direction: column; + gap: 16px; +} + +#sidebar { + display: flex; + flex-direction: column; + gap: 16px; + grid-column: 2; + grid-row: 2; +} + +#project a { + cursor: pointer; +} + +a:focus, +input:focus, +select:focus, +textarea:focus, +.title-text:focus { + outline: 1px solid var(--vscode-focusBorder); +} + +/* Sticky Header */ + +.sticky-header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 32px; + background: var(--vscode-editor-background); + border-bottom: 1px solid var(--vscode-editorWidget-border); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); + transform: translateY(-100%); + opacity: 0; + transition: transform 0.2s ease, opacity 0.2s ease; + pointer-events: none; +} + +.vscode-high-contrast .sticky-header { + box-shadow: none; +} + +.sticky-header.visible { + transform: translateY(0); + opacity: 1; + pointer-events: auto; +} + +.sticky-header-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.sticky-header-left #sticky-status { + flex-shrink: 0; + box-sizing: border-box; + line-height: 18px; + color: var(--vscode-button-foreground); + border-radius: 18px; + padding: 2px 10px; + font-weight: 600; + font-size: 12px; + display: flex; + gap: 4px; + align-items: center; +} + +.sticky-header-left #sticky-status svg path { + fill: var(--vscode-button-foreground); +} + +.vscode-high-contrast .sticky-header-left #sticky-status { + border: 1px solid var(--vscode-contrastBorder); + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +.vscode-high-contrast .sticky-header-left #sticky-status svg path { + fill: var(--vscode-button-foreground); +} + +.sticky-header-title { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.sticky-header-number { + flex-shrink: 0; + color: var(--vscode-textLink-foreground); + white-space: nowrap; +} + +.sticky-header-number:hover { + color: var(--vscode-textLink-activeForeground); +} + +.sticky-header .icon-button { + color: var(--vscode-foreground); +} + +.sticky-header .icon-button:hover, +.sticky-header .icon-button:focus { + background-color: var(--vscode-toolbar-hoverBackground); + cursor: pointer; +} + +.sticky-header .icon-button:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +.title-text { + margin-right: 5px; +} + +.title { + display: flex; + align-items: flex-start; + 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 { + margin-left: 5px; +} + +.loading-indicator { + position: fixed; + top: 50%; + left: 50%; + 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; +} + +h2 { + margin: 0; +} + +body hr { + display: block; + height: 1px; + border: 0; + border-top: 1px solid #555; + margin: 0 !important; + padding: 0; +} + +body .comment-container .avatar-container { + margin-right: 12px; +} + +body .comment-container .avatar-container a { + display: flex; +} + +body .comment-container .avatar-container img.avatar, +body .comment-container .avatar-container .avatar-icon svg { + margin-right: 0; +} + +.vscode-light .avatar-icon { + filter: invert(100%); +} + +body a.avatar-link:focus { + outline-offset: 2px; +} + +body .comment-container.comment, +body .comment-container.review { + background-color: var(--vscode-editor-background); +} + +.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; + width: 100%; + box-sizing: border-box; + padding: 8px 16px; + color: var(--vscode-foreground); + align-items: center; + 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 { + float: right; + height: 32px; +} + +.review-comment-header .comment-actions { + margin-left: auto; +} + +.review-comment-header .pending { + color: inherit; + font-style: italic; +} + +.comment-actions button { + background-color: transparent; + padding: 0; + line-height: normal; + font-size: 11px; +} + +.comment-actions button svg { + margin-right: 0; + 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; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--vscode-editorHoverWidget-border); +} + +.status-check-details { + display: flex; + align-items: center; + gap: 8px; +} + +.status-check > div:not(.status-check-details) { + display: flex; + align-items: center; + gap: 4px; +} + +.view-check-logs-placeholder { + width: 20px; +} + +#merge-on-github { + margin-top: 10px; +} + +.status-item { + padding: 12px 16px; + border-bottom: 1px solid var(--vscode-editorHoverWidget-border); +} + +.status-item:first-of-type { + background: var(--vscode-panel-background); + border-top-left-radius: 6px; + border-top-right-radius: 6px; +} + +.status-item, +.form-actions, +.ready-for-review-text-wrapper { + display: flex; + gap: 8px; + 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; +} + +.status-check-detail-text { + margin-right: 8px; +} + +.status-section p { + 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-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; +} + +.ready-for-review-heading { + font-weight: 600; +} + +.ready-for-review-meta { + font-size: 0.9; +} + +#status-checks { + border: 1px solid var(--vscode-editorHoverWidget-border); + border-radius: 6px; +} + +#status-checks .label { + display: inline-flex; + margin-right: 16px; +} + +#status-checks a { + cursor: pointer; +} + +#status-checks .icon-button:hover, +#status-checks .icon-button:focus { + background-color: var(--vscode-toolbar-hoverBackground); +} +#status-checks summary { + display: flex; + align-items: center; +} + +#status-checks-display-button { + margin-left: auto; +} + +#status-checks .avatar-link svg { + width: 24px; + margin-right: 0px; + vertical-align: middle; +} + +.status-check .avatar-link .avatar-icon { + margin-right: 0px; +} + +#status-checks .merge-select-container { + display: flex; + align-items: center; + background-color: var(--vscode-panel-background); + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; +} + +#status-checks .merge-select-container>* { + margin-right: 5px; +} + +#status-checks .merge-select-container>select { + margin-left: 5px; +} + +#status-checks .branch-status-container { + display: inline-block; +} + +#status-checks .branch-status-message { + display: inline-block; + line-height: 100%; + padding: 16px; +} + +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); + color: var(--vscode-badge-foreground); + font-size: 11px; + font-weight: 600; + border-radius: 20px; + padding: 4px 8px; + margin-left: 6px; +} + +body .resolved-container .unresolvedLabel { + font-style: italic; + margin-left: 5px; +} + +body .diff .diffPath { + margin-right: 4px; +} + +.comment-container form, +#merge-comment-form { + padding: 16px; + background-color: var(--vscode-panel-background); +} + +body .comment-container .comment-body, +.review-body { + padding: 16px; + border-top: none; +} + +body .comment-container .review-comment-container .review-comment-body { + display: flex; + flex-direction: column; + gap: 16px; + border: none; +} + +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 { + margin-bottom: 0; +} + +body { + margin: auto; + width: 100%; + max-width: 1280px; + padding: 0 32px; + box-sizing: border-box; +} + +body .hidden-focusable { + height: 0 !important; + overflow: hidden; +} + +.comment-actions button:hover:enabled, +.comment-actions button:focus:enabled { + background-color: transparent; +} + +body button.checkedOut { + color: var(--vscode-foreground); + opacity: 1 !important; + background-color: transparent; +} + +body button .icon { + width: 16px; + height: 16px; +} + +.prIcon { + display: flex; + border-radius: 10px; + margin-right: 5px; + margin-top: 18px; +} + +.overview-title { + display: flex; + align-items: center; +} + +.overview-title h2 { + font-size: 32px; + margin-right: 6px; +} + +.overview-title textarea { + min-height: 50px; +} + +.title-container { + width: 100%; +} + +.subtitle { + display: flex; + align-items: center; + flex-wrap: wrap; + row-gap: 12px; +} + +.subtitle .avatar, +.subtitle .avatar-icon svg { + margin-top: 2px; + margin-right: 6px; +} + +.subtitle .author { + display: flex; +} + +.merge-branches { + display: inline-flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.branch-tag { + margin-top: 0; + padding: 4px; + background: var(--vscode-editorInlayHint-background); + color: var(--vscode-editorInlayHint-foreground); + border-radius: 4px; +} + +.subtitle .created-at { + margin-left: auto; + white-space: nowrap; +} + +.button-group { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: flex-start; +} + +small-button { + display: flex; + font-size: 11px; + 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; + color: var(--vscode-button-foreground); + border-radius: 18px; + padding: 4px 12px; + margin-right: 10px; + font-weight: 600; + display: flex; + gap: 4px; +} + +#status svg path { + fill: var(--vscode-button-foreground); +} + +.vscode-high-contrast #status { + border: 1px solid var(--vscode-contrastBorder); + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); +} + +.vscode-high-contrast #status svg path { + fill: var(--vscode-badge-foreground); +} + +.status-badge-merged { + background-color: var(--vscode-pullRequests-merged); +} + +.status-badge-open { + background-color: var(--vscode-pullRequests-open); +} + +.status-badge-closed { + background-color: var(--vscode-pullRequests-closed); +} + +.status-badge-draft { + background-color: var(--vscode-pullRequests-draft); +} + +.section { + padding-bottom: 16px; + border-bottom: 1px solid var(--vscode-editorWidget-border); + display: flex; + flex-direction: column; + gap: 8px; +} + +.section:last-of-type { + padding-bottom: 0px; + border-bottom: none; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.section-header.clickable { + cursor: pointer; +} + +.section-header .section-title { + font-weight: 600; +} + +.section-placeholder { + color: var(--vscode-descriptionForeground); +} + +.assign-yourself:hover { + cursor: pointer; +} + +.section svg { + width: 16px; + height: 16px; + display: block; + margin-right: 0; +} + +.section .icon-button, +.section .icon-button .icon { + color: currentColor; +} + +.icon-button-group { + display: flex; + flex-direction: row; +} + +.section svg path { + fill: currentColor; +} + +.commit svg { + width: 16px; + height: auto; + margin-right: 8px; + flex-shrink: 0; +} + +.comment-container.commit { + border: none; + padding: 4px 16px; +} + +.comment-container.commit, +.comment-container.merged { + box-sizing: border-box; +} + +.commit, +.review, +.merged { + display: flex; + width: 100%; + border: none; + color: var(--vscode-foreground); +} + +.review { + margin: 0px 8px; + padding: 4px 0; +} + +.commit .commit-message, +.commit .timeline-with-detail, +.merged .merged-message { + 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: 6px; + flex-shrink: 0; +} + +.commit-message .icon { + padding-top: 2px; +} + +.commit .avatar-container .avatar, +.commit .avatar-container .avatar-icon, +.commit .avatar-container .avatar-icon svg, +.merged .avatar-container .avatar, +.merged .avatar-container .avatar-icon, +.merged .avatar-container .avatar-icon svg { + width: 18px; + height: 18px; +} + +.message-container { + display: inline-grid; +} + +.commit .commit-message .message, +.merged .merged-message .message { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.commit .commit-message a.message { + cursor: pointer; +} + +.timeline-detail { + display: flex; + align-items: center; + gap: 8px; +} + +.commit .sha { + min-width: 50px; + font-family: var(--vscode-editor-font-family); + margin-bottom: -2px; + cursor: pointer; +} + +.verified-pill-container { + position: relative; + display: inline-flex; + align-items: center; +} + +.verified-pill-container .verified-pill { + display: inline-flex; + align-items: center; + padding: 0 7px; + height: 18px; + line-height: 18px; + font-size: 11px; + font-weight: 500; + font-family: inherit; + border-radius: 2em; + border: 1px solid var(--vscode-badge-background); + background: transparent; + color: var(--vscode-descriptionForeground); + white-space: nowrap; + cursor: pointer; +} + +.verified-pill-container .verified-pill.verified { + color: var(--vscode-issues-open); + border-color: var(--vscode-issues-open); +} + +.verified-pill:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +.verified-popover { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 100; + width: 300px; + max-width: 90vw; + padding: 12px 16px; + background-color: var(--vscode-editorHoverWidget-background); + color: var(--vscode-editorHoverWidget-foreground); + border: 1px solid var(--vscode-editorHoverWidget-border); + border-radius: 4px; + box-shadow: 0 2px 8px var(--vscode-widget-shadow); + cursor: default; + text-align: left; + white-space: normal; +} + +.verified-popover-header { + display: flex; + align-items: flex-start; + gap: 8px; + padding-bottom: 12px; + margin-bottom: 12px; + border-bottom: 1px solid var(--vscode-editorHoverWidget-border); +} + +.verified-popover-icon { + flex-shrink: 0; + display: flex; + margin-top: 1px; +} + +.verified-popover-icon svg { + width: 16px; + height: 16px; +} + +.verified-popover-icon.verified svg path { + fill: var(--vscode-issues-open); +} + +.verified-popover-icon.unverified svg path { + fill: var(--vscode-errorForeground); +} + +.verified-popover-message { + line-height: 1.4; +} + +.verified-popover-signer { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.verified-popover-signer .avatar-container .avatar, +.verified-popover-signer .avatar-container .avatar-icon, +.verified-popover-signer .avatar-container .avatar-icon svg { + width: 20px; + height: 20px; + border-radius: 50%; +} + +.verified-popover-signer-names { + display: flex; + flex-direction: column; + line-height: 1.3; +} + +.verified-popover-signer-login { + font-weight: 600; +} + +.verified-popover-signer-name { + color: var(--vscode-descriptionForeground); +} + +.verified-popover-detail { + color: var(--vscode-descriptionForeground); + line-height: 1.6; + word-break: break-all; +} + +.merged .merged-message .message, +.merged .inline-sha { + margin: 0 4px 0 0; +} + +.merged svg { + width: 14px; + height: auto; + margin-right: 8px; + flex-shrink: 0; +} + +.details { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; +} + +#description .comment-container { + padding-top: 0px; +} + +.comment-container { + position: relative; + width: 100%; + display: flex; + margin: 0; + align-items: center; + border-radius: 6px; + border: 1px solid var(--vscode-editorHoverWidget-border); +} + +.comment-container[data-type='commit'] { + padding: 8px 0; + border: none; +} + +.comment-container[data-type='commit']+.comment-container[data-type='commit'] { + border-top: none; +} + +.comment-body .review-comment { + box-sizing: border-box; + border-top: 1px solid var(--vscode-editorHoverWidget-border); +} + +.resolve-comment-row { + display: flex; + align-items: center; + padding: 16px; + background-color: var(--vscode-editorHoverWidget-background); + border-top: 1px solid var(--vscode-editorHoverWidget-border); + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; +} + +.review-comment-container .review-comment .review-comment-header { + padding: 16px 16px 8px 16px; + border: none; + background: none; +} + +.review-comment-container .review-comment .comment-body { + border: none; + padding: 0px 16px 8px 16px; +} + +.review-comment-container .review-comment .comment-body:last-of-type { + padding: 0px 16px 16px 16px; +} + +.comment-body .line { + align-items: center; + display: flex; + flex-wrap: wrap; + margin-bottom: 8px; +} + +body .comment-form { + padding: 20px 0 10px; +} + +.review-comment-container .comment-form { + margin: 0 0 0 36px; + padding: 10px 0; +} + +.task-list-item { + list-style-type: none; +} + +#status-checks textarea { + margin-top: 10px; +} + +textarea { + min-height: 100px; + max-height: 500px; +} + +.textarea-wrapper { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + box-sizing: border-box; + border: 1px solid var(--vscode-dropdown-border); + background-color: var(--vscode-input-background); + border-radius: 4px; + margin-bottom: 4px; + overflow: hidden; + resize: vertical; +} + +.textarea-wrapper:focus-within { + outline: 1px solid var(--vscode-focusBorder); +} + +.textarea-wrapper textarea { + flex: 1; + width: 100%; + box-sizing: border-box; + border: none; + background: transparent; + border-radius: 0; + outline: none; + margin: 0; + resize: none; + 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; +} + +.textarea-wrapper .comment-upload-button { + align-self: flex-start; + margin: 2px 0 4px 4px; + border: none; + background: none; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + color: var(--vscode-foreground); +} + +.textarea-wrapper .comment-upload-button:hover:not(:disabled) { + cursor: pointer; + background-color: var(--vscode-toolbar-hoverBackground); +} + +.textarea-wrapper .comment-upload-button:focus { + outline-style: none; +} + +.textarea-wrapper .comment-upload-button:focus-visible { + outline-width: 1px; + outline-style: solid; + outline-offset: -1px; + outline-color: var(--vscode-focusBorder); +} + +.textarea-wrapper .comment-upload-button:disabled { + opacity: 0.5; +} +.editing-form { + padding: 5px 0; + display: flex; + flex-direction: row; + min-width: 300px; +} + +.editing-form .form-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.comment-form .form-actions>button, +.comment-form .form-actions>input[type='submit'] { + margin-right: 0; + margin-left: 0; +} + +.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; +} + +#rebase-actions { + flex-direction: row-reverse; +} + +.main-comment-form>.form-actions { + margin-bottom: 10px; +} + +.details .comment-body { + padding: 19px 0; +} + +blockquote { + display: block; + flex-direction: column; + margin: 8px 0; + padding: 8px 12px; + border-left-width: 5px; + border-left-style: solid; +} + +blockquote p { + margin: 8px 0; +} + +blockquote p:first-child { + margin-top: 0; +} + +blockquote p:last-child { + margin-bottom: 0; +} + +.comment-body a:focus, +.comment-body input:focus, +.comment-body select:focus, +.comment-body textarea:focus { + outline-offset: -1px; +} + +.comment-body hr { + border: 0; + height: 2px; + border-bottom: 2px solid; +} + +.comment-body h1 { + padding-bottom: 0.3em; + line-height: 1.2; + border-bottom-width: 1px; + border-bottom-style: solid; +} + +.comment-body h1, +h2, +h3 { + font-weight: normal; +} + +.comment-body h1 code, +.comment-body h2 code, +.comment-body h3 code, +.comment-body h4 code, +.comment-body h5 code, +.comment-body h6 code { + font-size: inherit; + line-height: auto; +} + +.comment-body table { + border-collapse: collapse; +} + +.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 { + padding: 5px 10px; +} + +.comment-body table>tbody>tr+tr>td { + border-top: 1px solid; +} + +code { + font-family: var(--vscode-editor-font-family), Menlo, Monaco, Consolas, 'Droid Sans Mono', 'Courier New', monospace, 'Droid Sans Fallback'; +} + +.comment-body .snippet-clipboard-content { + display: grid; +} + +.comment-body video { + width: 100%; + border: 1px solid var(--vscode-editorWidget-border); + border-radius: 4px; +} + +.comment-body summary { + margin-bottom: 8px; +} + +.comment-body details summary::marker { + display: flex; +} + +.comment-body details summary svg { + margin-left: 8px; +} + +.comment-body body.wordWrap pre { + white-space: pre-wrap; +} + +.comment-body .mac code { + font-size: 12px; + line-height: 18px; +} + +.comment-body pre:not(.hljs), +.comment-body pre.hljs code>div { + padding: 16px; + border-radius: 6px; + overflow: auto; +} + +.timestamp, +.timestamp:hover { + color: var(--vscode-descriptionForeground); + white-space: nowrap; +} + +.timestamp { + overflow: hidden; + text-overflow: ellipsis; + padding-left: 8px; +} + +/** Theming */ + +.comment-body pre code { + color: var(--vscode-editor-foreground); +} + +.vscode-light .comment-body pre:not(.hljs), +.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 { + background-color: rgba(10, 10, 10, 0.4); +} + +.vscode-high-contrast .comment-body pre:not(.hljs), +.vscode-high-contrast .comment-body code>div { + background-color: var(--vscode-editor-background); + border: 1px solid var(--vscode-panel-border); +} + +.vscode-high-contrast .comment-body h1 { + border: 1px solid rgb(0, 0, 0); +} + +.vscode-high-contrast .comment-container .review-comment-header, +.vscode-high-contrast #status-checks { + background: none; + border: 1px solid var(--vscode-panel-border); +} + +.vscode-high-contrast .comment-container .comment-body, +.vscode-high-contrast .review-comment-container .review-body { + border: 1px solid var(--vscode-panel-border); +} + +.vscode-light .comment-body table>thead>tr>th { + border-color: rgba(0, 0, 0, 0.69); +} + +.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 { + 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 { + border-color: rgba(255, 255, 255, 0.18); +} + +.review-comment-body .diff-container { + border-radius: 4px; + border: 1px solid var(--vscode-editorHoverWidget-border); +} + +.review-comment-body .diff-container .review-comment-container .comment-container { + padding-top: 0; +} + +.review-comment-body .diff-container .comment-container { + border: none; +} + +.review-comment-body .diff-container .review-comment-container .review-comment-header .avatar-container { + margin-right: 4px; +} + +.review-comment-body .diff-container .review-comment-container .review-comment-header .avatar { + width: 18px; + height: 18px; +} + +.review-comment-body .diff-container .diff { + border-top: 1px solid var(--vscode-editorWidget-border); + overflow: scroll; +} + +.resolved-container { + padding: 6px 12px; + display: flex; + align-items: center; + justify-content: space-between; + background: var(--vscode-panel-background); + border-top-left-radius: 6px; + border-top-right-radius: 6px; +} + +.resolved-container .diffPath:hover { + text-decoration: underline; + color: var(--vscode-textLink-activeForeground); + cursor: pointer; +} + +.diff .diffLine { + display: flex; + font-size: 12px; + line-height: 20px; +} + +.win32 .diff .diffLine { + font-family: var(--vscode-editor-font-family), Consolas, Inconsolata, 'Courier New', monospace; +} + +.darwin .diff .diffLine { + font-family: var(--vscode-editor-font-family), Monaco, Menlo, Inconsolata, 'Courier New', monospace; +} + +.linux .diff .diffLine { + font-family: var(--vscode-editor-font-family), 'Droid Sans Mono', Inconsolata, 'Courier New', monospace, 'Droid Sans Fallback'; +} + +.diff .diffLine.add { + background-color: var(--vscode-diffEditor-insertedTextBackground); +} + +.diff .diffLine.delete { + background-color: var(--vscode-diffEditor-removedTextBackground); +} + +.diff .diffLine .diffTypeSign { + user-select: none; + padding-right: 5px; +} + +.diff .diffLine .lineNumber { + width: 1%; + min-width: 50px; + padding-right: 10px; + padding-left: 10px; + font-size: 12px; + line-height: 20px; + text-align: right; + white-space: nowrap; + box-sizing: border-box; + display: block; + user-select: none; + font-family: var(--vscode-editor-font-family); +} + +.github-checkbox { + pointer-events: none; +} + +.github-checkbox input { + color: rgb(84, 84, 84); + opacity: 0.6; +} + +/* High Contrast Mode */ + +.vscode-high-contrast a:focus { + outline-color: var(--vscode-contrastActiveBorder); +} + +.vscode-high-contrast .title { + border-bottom: 1px solid var(--vscode-contrastBorder); +} + +.vscode-high-contrast .title.stuck::after { + box-shadow: none; +} + +.vscode-high-contrast .diff .diffLine { + background: none; +} + +.vscode-high-contrast .resolved-container { + background: none; +} + +.vscode-high-contrast .diff-container { + border: 1px solid var(--vscode-contrastBorder); +} + +.vscode-high-contrast .diff .diffLine.add { + border: 1px dashed var(--vscode-diffEditor-insertedTextBorder); +} + +.vscode-high-contrast .diff .diffLine.delete { + border: 1px dashed var(--vscode-diffEditor-removedTextBorder); +} + +@media (max-width: 768px) { + .title { + border-bottom: none; + padding-bottom: 0px; + } + + .title.stuck .overview-title h2 { + font-size: 16px; + } + + #app { + display: block; + } + + #sidebar { + display: grid; + column-gap: 20px; + row-gap: 12px; + grid-template-columns: calc(50% - 10px) calc(50% - 10px); + padding: 0; + } + + .section-content { + display: flex; + flex-wrap: wrap; + } + + .section-item { + display: flex; + } + + body .hidden-focusable { + height: initial; + overflow: initial; + } + + .section-header button { + display: flex; + } + + .section-item .login { + width: auto; + margin-right: 4px; + } + + /* Hides bottom borders on bottom two sections */ + .section:nth-last-child(-n + 2) { + border-bottom: none; + } +} + +.icon { + width: 16px; + height: 16px; + font-size: 16px; + display: flex; +} + +.icon.copilot-icon { + margin-right: 6px; +} + +.action-bar { + position: absolute; + display: flex; + justify-content: space-between; + z-index: 100; + top: 9px; + right: 9px; +} + +.flex-action-bar { + display: flex; + justify-content: space-between; + align-items: center; + z-index: 100; + margin-left: 9px; + min-width: 42px; +} + +.action-bar>button, +.flex-action-bar>button { + margin-left: 4px; + margin-right: 4px; +} + +.title-editing-form { + flex-grow: 1; +} + +.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/index.ts b/webviews/editorWebview/index.ts new file mode 100644 index 0000000000..20aa1f5bd3 --- /dev/null +++ b/webviews/editorWebview/index.ts @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import '../common/common.css'; +import './index.css'; +import { main } from './app'; + +addEventListener('load', main); diff --git a/webviews/editorWebview/overview.tsx b/webviews/editorWebview/overview.tsx new file mode 100644 index 0000000000..0b319402ba --- /dev/null +++ b/webviews/editorWebview/overview.tsx @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as React from 'react'; +import { PullRequest } from '../../src/github/views'; + +import { AddComment, CommentView } from '../components/comment'; +import { Header } from '../components/header'; +import { StatusChecksSection } from '../components/merge'; +import Sidebar, { CollapsibleSidebar } from '../components/sidebar'; +import { StickyHeader, useStickyHeader } from '../components/stickyHeader'; +import { Timeline } from '../components/timeline'; + +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)'); + const titleRef = React.useRef<HTMLDivElement>(null); + const isStuck = useStickyHeader(titleRef); + + return <> + <StickyHeader pr={pr} visible={isStuck} /> + <div id="title" className="title" ref={titleRef}> + <div className="details"> + <Header {...pr} /> + </div> + </div> + {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/preview-src/test/app.test.tsx b/webviews/editorWebview/test/app.test.tsx similarity index 76% rename from preview-src/test/app.test.tsx rename to webviews/editorWebview/test/app.test.tsx index 461a89d2b6..ceaebf3f6d 100644 --- a/preview-src/test/app.test.tsx +++ b/webviews/editorWebview/test/app.test.tsx @@ -1,9 +1,9 @@ +import { default as assert } from 'assert'; import * as React from 'react'; -import assert = require('assert'); -import { render, cleanup } from 'react-testing-library'; -import { SinonSandbox, createSandbox } from 'sinon'; +import { cleanup, render } from 'react-testing-library'; +import { createSandbox, SinonSandbox } from 'sinon'; -import PullRequestContext, { PRContext } from '../context'; +import { PRContext, default as PullRequestContext } from '../../common/context'; import { Root } from '../app'; import { PullRequestBuilder } from './builder/pullRequest'; @@ -28,7 +28,7 @@ describe('Root', function () { const out = render( <PullRequestContext.Provider value={context}> <Root>{children}</Root> - </PullRequestContext.Provider> + </PullRequestContext.Provider>, ); assert(out.queryByText('Loading...')); @@ -43,9 +43,9 @@ describe('Root', function () { render( <PullRequestContext.Provider value={context}> <Root>{children}</Root> - </PullRequestContext.Provider> + </PullRequestContext.Provider>, ); assert(children.calledWith(pr)); }); -}); \ No newline at end of file +}); diff --git a/webviews/editorWebview/test/builder/account.ts b/webviews/editorWebview/test/builder/account.ts new file mode 100644 index 0000000000..c6fcd4fdb9 --- /dev/null +++ b/webviews/editorWebview/test/builder/account.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 { AccountType, IAccount } from '../../../../src/github/interface'; +import { createBuilderClass } from '../../../../src/test/builders/base'; + +export const AccountBuilder = createBuilderClass<IAccount>()({ + login: { default: 'me' }, + 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 new file mode 100644 index 0000000000..21076a8adb --- /dev/null +++ b/webviews/editorWebview/test/builder/pullRequest.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * 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'; + + +export const PullRequestBuilder = createBuilderClass<PullRequest>()({ + owner: { default: 'owner' }, + repo: { default: 'name' }, + number: { default: 1234 }, + title: { default: 'the default title' }, + titleHTML: { default: 'the default title' }, + url: { default: 'https://github.com/owner/name/pulls/1234' }, + createdAt: { default: '2019-01-01T10:00:00Z' }, + body: { default: 'the *default* body' }, + bodyHTML: { default: 'the <b>default</b> body' }, + author: { linked: AccountBuilder }, + state: { default: GithubItemStateEnum.Open }, + events: { default: [] }, + isCurrentlyCheckedOut: { default: true }, + isRemoteBaseDeleted: { default: false }, + base: { default: 'main' }, + isRemoteHeadDeleted: { default: false }, + 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: 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 }, + isEnterprise: { default: false }, + hasReviewDraft: { default: false }, + busy: { default: undefined }, + lastReviewType: { default: undefined }, + closingIssues: { default: [] }, + 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..cc564e029a --- /dev/null +++ b/webviews/editorWebview/test/overview.test.tsx @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * 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')); + + // Sticky header should exist but not be visible initially + const stickyHeader = out.container.querySelector('.sticky-header'); + assert(stickyHeader); + assert(!stickyHeader.classList.contains('visible')); + }); +}); diff --git a/yarn.lock b/yarn.lock deleted file mode 100644 index 609d460b84..0000000000 --- a/yarn.lock +++ /dev/null @@ -1,5369 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"@babel/code-frame@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8" - integrity sha512-OfC2uemaknXr87bdLUkWog7nYuliM9Ij5HUcajsVcMCpQrcLmtxRbVFTIqmcSkSeYRBFBRxs2FiUqFJDLdiebA== - dependencies: - "@babel/highlight" "^7.0.0" - -"@babel/highlight@^7.0.0": - version "7.5.0" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.5.0.tgz#56d11312bd9248fa619591d02472be6e8cb32540" - integrity sha512-7dV4eu9gBxoM0dAnj/BCFDW9LFU0zvTrkq0ugM7pnHEgguOEeOz1so2ZghEdzviYzQEED0r4EAgpsBChKy1TRQ== - dependencies: - chalk "^2.0.0" - esutils "^2.0.2" - js-tokens "^4.0.0" - -"@babel/runtime@^7.4.3": - version "7.8.4" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.8.4.tgz#d79f5a2040f7caa24d53e563aad49cbc05581308" - integrity sha512-neAp3zt80trRVBI1x0azq6c57aNBqYZH8KhMm3TaB7wEI5Q4A2SHfBHE8w9gOhI/lrqxtEbXZgQIrHP+wvSGwQ== - dependencies: - regenerator-runtime "^0.13.2" - -"@jest/types@^24.9.0": - version "24.9.0" - resolved "https://registry.yarnpkg.com/@jest/types/-/types-24.9.0.tgz#63cb26cb7500d069e5a389441a7c6ab5e909fc59" - integrity sha512-XKK7ze1apu5JWQ5eZjHITP66AX+QsLlbaJRBGYr8pNzwcAE2JVkwnf0yqjHTsDRcjR0mujy/NmZMXw5kl+kGBw== - dependencies: - "@types/istanbul-lib-coverage" "^2.0.0" - "@types/istanbul-reports" "^1.1.1" - "@types/yargs" "^13.0.0" - -"@octokit/auth-token@^2.4.0": - version "2.4.2" - resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-2.4.2.tgz#10d0ae979b100fa6b72fa0e8e63e27e6d0dbff8a" - integrity sha512-jE/lE/IKIz2v1+/P0u4fJqv0kYwXOTujKemJMFr6FeopsxlIK3+wKDCJGnysg81XID5TgZQbIfuJ5J0lnTiuyQ== - dependencies: - "@octokit/types" "^5.0.0" - -"@octokit/core@^3.0.0": - version "3.1.1" - resolved "https://registry.yarnpkg.com/@octokit/core/-/core-3.1.1.tgz#1856745aa8fb154cf1544a2a1b82586c809c5e66" - integrity sha512-cQ2HGrtyNJ1IBxpTP1U5m/FkMAJvgw7d2j1q3c9P0XUuYilEgF6e4naTpsgm4iVcQeOnccZlw7XHRIUBy0ymcg== - dependencies: - "@octokit/auth-token" "^2.4.0" - "@octokit/graphql" "^4.3.1" - "@octokit/request" "^5.4.0" - "@octokit/types" "^5.0.0" - before-after-hook "^2.1.0" - universal-user-agent "^6.0.0" - -"@octokit/endpoint@^6.0.1": - version "6.0.4" - resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-6.0.4.tgz#da3eafdee1fabd6e5b6ca311efcba26f0dd99848" - integrity sha512-ZJHIsvsClEE+6LaZXskDvWIqD3Ao7+2gc66pRG5Ov4MQtMvCU9wGu1TItw9aGNmRuU9x3Fei1yb+uqGaQnm0nw== - dependencies: - "@octokit/types" "^5.0.0" - is-plain-object "^3.0.0" - universal-user-agent "^6.0.0" - -"@octokit/graphql@^4.3.1": - version "4.5.2" - resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-4.5.2.tgz#33021ebf94939cf47562823851ab11fe64392274" - integrity sha512-SpB/JGdB7bxRj8qowwfAXjMpICUYSJqRDj26MKJAryRQBqp/ZzARsaO2LEFWzDaps0FLQoPYVGppS0HQXkBhdg== - dependencies: - "@octokit/request" "^5.3.0" - "@octokit/types" "^5.0.0" - universal-user-agent "^6.0.0" - -"@octokit/plugin-paginate-rest@^2.2.0": - version "2.2.3" - resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.2.3.tgz#a6ad4377e7e7832fb4bdd9d421e600cb7640ac27" - integrity sha512-eKTs91wXnJH8Yicwa30jz6DF50kAh7vkcqCQ9D7/tvBAP5KKkg6I2nNof8Mp/65G0Arjsb4QcOJcIEQY+rK1Rg== - dependencies: - "@octokit/types" "^5.0.0" - -"@octokit/plugin-request-log@^1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-1.0.0.tgz#eef87a431300f6148c39a7f75f8cfeb218b2547e" - integrity sha512-ywoxP68aOT3zHCLgWZgwUJatiENeHE7xJzYjfz8WI0goynp96wETBF+d95b8g/uL4QmS6owPVlaxiz3wyMAzcw== - -"@octokit/plugin-rest-endpoint-methods@4.1.0": - version "4.1.0" - resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.1.0.tgz#338c568177c4d4d753f9525af88b29cd0f091734" - integrity sha512-zbRTjm+xplSNlixotTVMvLJe8aRogUXS+r37wZK5EjLsNYH4j02K5XLMOWyYaSS4AJEZtPmzCcOcui4VzVGq+A== - dependencies: - "@octokit/types" "^5.1.0" - deprecation "^2.3.1" - -"@octokit/request-error@^2.0.0": - version "2.0.2" - resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-2.0.2.tgz#0e76b83f5d8fdda1db99027ea5f617c2e6ba9ed0" - integrity sha512-2BrmnvVSV1MXQvEkrb9zwzP0wXFNbPJij922kYBTLIlIafukrGOb+ABBT2+c6wZiuyWDH1K1zmjGQ0toN/wMWw== - dependencies: - "@octokit/types" "^5.0.1" - deprecation "^2.0.0" - once "^1.4.0" - -"@octokit/request@^5.3.0", "@octokit/request@^5.4.0": - version "5.4.6" - resolved "https://registry.yarnpkg.com/@octokit/request/-/request-5.4.6.tgz#e8cc8d4cfc654d30428ea92aaa62168fd5ead7eb" - integrity sha512-9r8Sn4CvqFI9LDLHl9P17EZHwj3ehwQnTpTE+LEneb0VBBqSiI/VS4rWIBfBhDrDs/aIGEGZRSB0QWAck8u+2g== - dependencies: - "@octokit/endpoint" "^6.0.1" - "@octokit/request-error" "^2.0.0" - "@octokit/types" "^5.0.0" - deprecation "^2.0.0" - is-plain-object "^3.0.0" - node-fetch "^2.3.0" - once "^1.4.0" - universal-user-agent "^6.0.0" - -"@octokit/rest@^18.0.0": - version "18.0.1" - resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-18.0.1.tgz#46ee234770c5ff4c646f7e18708c56b6d7fa3c66" - integrity sha512-KLlJpgsJx88OZ0VLBH3gvUK4sfcXjr/nE0Qzyoe76dNqMzDzkSmmvILF3f2XviGgrzuP6Ie0ay/QX478Vrpn9A== - dependencies: - "@octokit/core" "^3.0.0" - "@octokit/plugin-paginate-rest" "^2.2.0" - "@octokit/plugin-request-log" "^1.0.0" - "@octokit/plugin-rest-endpoint-methods" "4.1.0" - -"@octokit/types@^5.0.0", "@octokit/types@^5.0.1", "@octokit/types@^5.1.0": - version "5.1.0" - resolved "https://registry.yarnpkg.com/@octokit/types/-/types-5.1.0.tgz#4377a3f39edad3e60753fb5c3c310756f1ded57f" - integrity sha512-OFxUBgrEllAbdEmWp/wNmKIu5EuumKHG4sgy56vjZ8lXPgMhF05c76hmulfOdFHHYRpPj49ygOZJ8wgVsPecuA== - dependencies: - "@types/node" ">= 8" - -"@sheerun/mutationobserver-shim@^0.3.2": - version "0.3.2" - resolved "https://registry.yarnpkg.com/@sheerun/mutationobserver-shim/-/mutationobserver-shim-0.3.2.tgz#8013f2af54a2b7d735f71560ff360d3a8176a87b" - integrity sha512-vTCdPp/T/Q3oSqwHmZ5Kpa9oI7iLtGl3RQaA/NyLHikvcrPxACkkKVr/XzkSPJWXHRhKGzVvb0urJsbMlRxi1Q== - -"@sinonjs/commons@^1": - version "1.4.0" - resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.4.0.tgz#7b3ec2d96af481d7a0321252e7b1c94724ec5a78" - integrity sha512-9jHK3YF/8HtJ9wCAbG+j8cD0i0+ATS9A7gXFqS36TblLPNy6rEEc+SB0imo91eCboGaBYGV/MT1/br/J+EE7Tw== - dependencies: - type-detect "4.0.8" - -"@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0": - version "1.7.1" - resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.7.1.tgz#da5fd19a5f71177a53778073978873964f49acf1" - integrity sha512-Debi3Baff1Qu1Unc3mjJ96MgpbwTn43S1+9yJ0llWygPwDNu2aaWBD6yc9y/Z8XDRNhx7U+u2UDg2OGQXkclUQ== - dependencies: - type-detect "4.0.8" - -"@sinonjs/fake-timers@^6.0.0": - version "6.0.0" - resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-6.0.0.tgz#b64b0faadfdd01a6dcf0c4dcdb78438d86fa7dbf" - integrity sha512-atR1J/jRXvQAb47gfzSK8zavXy7BcpnYq21ALon0U99etu99vsir0trzIO3wpeLtW+LLVY6X7EkfVTbjGSH8Ww== - dependencies: - "@sinonjs/commons" "^1.7.0" - -"@sinonjs/formatio@^5.0.0", "@sinonjs/formatio@^5.0.1": - version "5.0.1" - resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-5.0.1.tgz#f13e713cb3313b1ab965901b01b0828ea6b77089" - integrity sha512-KaiQ5pBf1MpS09MuA0kp6KBQt2JUOQycqVG1NZXvzeaXe5LGFqAKueIS0bw4w0P9r7KuBSVdUk5QjXsUdu2CxQ== - dependencies: - "@sinonjs/commons" "^1" - "@sinonjs/samsam" "^5.0.2" - -"@sinonjs/samsam@^5.0.1", "@sinonjs/samsam@^5.0.2": - version "5.0.2" - resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-5.0.2.tgz#2c4772ec4a3a9b00971be32e843dc1be27a02c89" - integrity sha512-p3yrEVB5F/1wI+835n+X8llOGRgV8+jw5BHQ/cJoLBUXXZ5U8Tr5ApwPc4L4av/vjla48kVPoN0t6dykQm+Rvg== - dependencies: - "@sinonjs/commons" "^1.6.0" - "@sinonjs/formatio" "^5.0.0" - lodash.get "^4.4.2" - type-detect "^4.0.8" - -"@sinonjs/text-encoding@^0.7.1": - version "0.7.1" - resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz#8da5c6530915653f3a1f38fd5f101d8c3f8079c5" - integrity sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ== - -"@types/anymatch@*": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@types/anymatch/-/anymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a" - integrity sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA== - -"@types/async@2.0.50": - version "2.0.50" - resolved "https://registry.yarnpkg.com/@types/async/-/async-2.0.50.tgz#117540e026d64e1846093abbd5adc7e27fda7bcb" - integrity sha512-VMhZMMQgV1zsR+lX/0IBfAk+8Eb7dPVMWiQGFAt3qjo5x7Ml6b77jUo0e1C3ToD+XRDXqtrfw+6AB0uUsPEr3Q== - -"@types/chai@^4.1.4": - version "4.1.4" - resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.1.4.tgz#5ca073b330d90b4066d6ce18f60d57f2084ce8ca" - integrity sha512-h6+VEw2Vr3ORiFCyyJmcho2zALnUq9cvdB/IO8Xs9itrJVCenC7o26A6+m7D0ihTTr65eS259H5/Ghl/VjYs6g== - -"@types/debounce@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-3.0.0.tgz#ea4290f6e2d7ab99464c93b4a69b843d0993b181" - integrity sha512-SbehVj+zL0QFTufW3HP/Xvwr/kFWw1sFMLCvmyJdp5Xegbo81sqA2tByWlyeryvSwWBrY4drpLOtGPh0fkxjCQ== - -"@types/events@*": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@types/events/-/events-1.2.0.tgz#81a6731ce4df43619e5c8c945383b3e62a89ea86" - integrity sha512-KEIlhXnIutzKwRbQkGWb/I4HFqBuUykAdHgDED6xqwXJfONCjF5VoE0cXEiurh3XauygxzeDzgtXUqvLkxFzzA== - -"@types/glob@7.1.1": - version "7.1.1" - resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575" - integrity sha512-1Bh06cbWJUHMC97acuD6UMG29nMt0Aqz1vF3guLfG+kHHJhy3AyohZFFxYk2f7Q1SQIrNwvncxAE0N/9s70F2w== - dependencies: - "@types/events" "*" - "@types/minimatch" "*" - "@types/node" "*" - -"@types/graphql@^14.0.5": - version "14.0.5" - resolved "https://registry.yarnpkg.com/@types/graphql/-/graphql-14.0.5.tgz#e696292fd2d77dc168b5b791710f83463aa39abd" - integrity sha512-bwGYLE0SRy5ZraC91dqI2bxbspfm10kyJ2Yjuvk4OjdGznh7fkoWW+xXZHfFydJaqu9syZi099cpiZw3GlPDiA== - -"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": - version "2.0.1" - resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff" - integrity sha512-hRJD2ahnnpLgsj6KWMYSrmXkM3rm2Dl1qkx6IOFD5FnuNPXJIG5L0dhgKXCYTRMGzU4n0wImQ/xfmRc4POUFlg== - -"@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" - integrity sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg== - dependencies: - "@types/istanbul-lib-coverage" "*" - -"@types/istanbul-reports@^1.1.1": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-1.1.1.tgz#7a8cbf6a406f36c8add871625b278eaf0b0d255a" - integrity sha512-UpYjBi8xefVChsCoBpKShdxTllC9pwISirfoZsUa2AAdQg/Jd2KQGtSbw+ya7GPo7x/wAPlH6JBhKhAsXUEZNA== - dependencies: - "@types/istanbul-lib-coverage" "*" - "@types/istanbul-lib-report" "*" - -"@types/keytar@^4.0.1": - version "4.0.1" - resolved "https://registry.yarnpkg.com/@types/keytar/-/keytar-4.0.1.tgz#e2cf6405dc33861424e59b67516c66d2cf7bc21b" - integrity sha512-loKBID6UL4QjhD2scuvv6oAPlQ/WAY7aYTDyKlKo7fIgriLS8EZExqT567cHL5CY6si51MRoX1+r3mitD3eYrA== - -"@types/lru-cache@^5.1.0": - version "5.1.0" - resolved "https://registry.yarnpkg.com/@types/lru-cache/-/lru-cache-5.1.0.tgz#57f228f2b80c046b4a1bd5cac031f81f207f4f03" - integrity sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w== - -"@types/marked@^0.7.2": - version "0.7.2" - resolved "https://registry.yarnpkg.com/@types/marked/-/marked-0.7.2.tgz#1393f076773b55cc7078c0fbeb86a497c69db97e" - integrity sha512-A3EDyNaq6OCcpaOia2HQ/tu2QYt8DKuj4ExP21VU3cU3HTo2FLslvbqa2T1vux910RHvuSVqpwKnnykSFcRWOA== - -"@types/minimatch@*": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" - integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== - -"@types/mocha@^5.2.2": - version "5.2.2" - resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-5.2.2.tgz#202d2b8fe1364c5b617b439b26a54f0e75eac0a7" - integrity sha512-tfg9rh2qQhBW6SBqpvfqTgU6lHWGhQURoTrn7NeDF+CEkO9JGYbkzU23EXu6p3bnvDxLeeSX8ohAA23urvWeNw== - -"@types/node-fetch@^2.1.4": - version "2.1.4" - resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.1.4.tgz#093d1beae11541aef25999d70aa09286fd025b1a" - integrity sha512-tR1ekaXUGpmzOcDXWU9BW73YfA2/VW1DF1FH+wlJ82BbCSnWTbdX+JkqWQXWKIGsFPnPsYadbXfNgz28g+ccWg== - dependencies: - "@types/node" "*" - -"@types/node@*": - version "10.3.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-10.3.2.tgz#3840ec6c12556fdda6e0e6d036df853101d732a4" - integrity sha512-9NfEUDp3tgRhmoxzTpTo+lq+KIVFxZahuRX0LHF/9IzKHaWuoWsIrrJ61zw5cnnlGINX8lqJzXYfQTOICS5Q+A== - -"@types/node@>= 8": - version "14.0.23" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.23.tgz#676fa0883450ed9da0bb24156213636290892806" - integrity sha512-Z4U8yDAl5TFkmYsZdFPdjeMa57NOvnaf1tljHzhouaPEp7LCj2JKkejpI1ODviIAQuW4CcQmxkQ77rnLsOOoKw== - -"@types/node@^12.0.0": - version "12.12.37" - resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.37.tgz#cb4782d847f801fa58316da5b4801ca3a59ae790" - integrity sha512-4mXKoDptrXAwZErQHrLzpe0FN/0Wmf5JRniSVIdwUrtDf9wnmEV1teCNLBo/TwuXhkK/bVegoEn/wmb+x0AuPg== - -"@types/prop-types@*": - version "15.5.9" - resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.5.9.tgz#f2d14df87b0739041bc53a7d75e3d77d726a3ec0" - integrity sha512-Nha5b+jmBI271jdTMwrHiNXM+DvThjHOfyZtMX9kj/c/LUj2xiLHsG/1L3tJ8DjAoQN48cHwUwtqBotjyXaSdQ== - -"@types/query-string@^6.1.1": - version "6.1.1" - resolved "https://registry.yarnpkg.com/@types/query-string/-/query-string-6.1.1.tgz#1cfd9209c8dbd91b940192c8a2b7005d2a743e6f" - integrity sha512-wRUeF7KN2yxCMw4VoXzPh3GWStbGaiyVjyX22fG7mpSzt6etLvsA2S0g0IuGeXGwVNIlztzVmGP6AxZMmYTQhw== - -"@types/react-dom@^16.8.2": - version "16.8.2" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.8.2.tgz#9bd7d33f908b243ff0692846ef36c81d4941ad12" - integrity sha512-MX7n1wq3G/De15RGAAqnmidzhr2Y9O/ClxPxyqaNg96pGyeXUYPSvujgzEVpLo9oIP4Wn1UETl+rxTN02KEpBw== - dependencies: - "@types/react" "*" - -"@types/react@*", "@types/react@^16.8.4": - version "16.8.4" - resolved "https://registry.yarnpkg.com/@types/react/-/react-16.8.4.tgz#134307f5266e866d5e7c25e47f31f9abd5b2ea34" - integrity sha512-Mpz1NNMJvrjf0GcDqiK8+YeOydXfD8Mgag3UtqQ5lXYTsMnOiHcKmO48LiSWMb1rSHB9MV/jlgyNzeAVxWMZRQ== - dependencies: - "@types/prop-types" "*" - csstype "^2.2.0" - -"@types/sinon@7.0.11": - version "7.0.11" - resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-7.0.11.tgz#6f28f005a36e779b7db0f1359b9fb9eef72aae88" - integrity sha512-6ee09Ugx6GyEr0opUIakmxIWFNmqYPjkqa3/BuxCBokA0klsOLPgMD5K4q40lH7/yZVuJVzOfQpd7pipwjngkQ== - -"@types/source-list-map@*": - version "0.1.2" - resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9" - integrity sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA== - -"@types/tapable@*": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.4.tgz#b4ffc7dc97b498c969b360a41eee247f82616370" - integrity sha512-78AdXtlhpCHT0K3EytMpn4JNxaf5tbqbLcbIRoQIHzpTIyjpxLQKRoxU55ujBXAtg3Nl2h/XWvfDa9dsMOd0pQ== - -"@types/temp@0.8.34": - version "0.8.34" - resolved "https://registry.yarnpkg.com/@types/temp/-/temp-0.8.34.tgz#03e4b3cb67cbb48c425bbf54b12230fef85540ac" - integrity sha512-oLa9c5LHXgS6UimpEVp08De7QvZ+Dfu5bMQuWyMhf92Z26Q10ubEMOWy9OEfUdzW7Y/sDWVHmUaLFtmnX/2j0w== - dependencies: - "@types/node" "*" - -"@types/uglify-js@*": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.0.3.tgz#801a5ca1dc642861f47c46d14b700ed2d610840b" - integrity sha512-MAT0BW2ruO0LhQKjvlipLGCF/Yx0y/cj+tT67tK3QIQDrM2+9R78HgJ54VlrE8AbfjYJJBCQCEPM5ZblPVTuww== - dependencies: - source-map "^0.6.1" - -"@types/uuid@^3.4.3": - version "3.4.5" - resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.5.tgz#d4dc10785b497a1474eae0ba7f0cb09c0ddfd6eb" - integrity sha512-MNL15wC3EKyw1VLF+RoVO4hJJdk9t/Hlv3rt1OL65Qvuadm4BYo6g9ZJQqoq7X8NBFSsQXgAujWciovh2lpVjA== - dependencies: - "@types/node" "*" - -"@types/webpack-sources@*": - version "0.1.6" - resolved "https://registry.yarnpkg.com/@types/webpack-sources/-/webpack-sources-0.1.6.tgz#3d21dfc2ec0ad0c77758e79362426a9ba7d7cbcb" - integrity sha512-FtAWR7wR5ocJ9+nP137DV81tveD/ZgB1sadnJ/axUGM3BUVfRPx8oQNMtv3JNfTeHx3VP7cXiyfR/jmtEsVHsQ== - dependencies: - "@types/node" "*" - "@types/source-list-map" "*" - source-map "^0.6.1" - -"@types/webpack@^4.41.6": - version "4.41.6" - resolved "https://registry.yarnpkg.com/@types/webpack/-/webpack-4.41.6.tgz#c76afbdef59159d12e3e1332dc264b75574722a2" - integrity sha512-iWRpV5Ej+8uKrgxp6jXz3v7ZTjgtuMXY+rsxQjFNU0hYCnHkpA7vtiNffgxjuxX4feFHBbz0IF76OzX2OqDYPw== - dependencies: - "@types/anymatch" "*" - "@types/node" "*" - "@types/tapable" "*" - "@types/uglify-js" "*" - "@types/webpack-sources" "*" - source-map "^0.6.0" - -"@types/yargs-parser@*": - version "15.0.0" - resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-15.0.0.tgz#cb3f9f741869e20cce330ffbeb9271590483882d" - integrity sha512-FA/BWv8t8ZWJ+gEOnLLd8ygxH/2UFbAvgEonyfN6yWGLKc7zVjbpl2Y4CTjid9h2RfgPP6SEt6uHwEOply00yw== - -"@types/yargs@^13.0.0": - version "13.0.8" - resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-13.0.8.tgz#a38c22def2f1c2068f8971acb3ea734eb3c64a99" - integrity sha512-XAvHLwG7UQ+8M4caKIH0ZozIOYay5fQkAgyIXegXT9jPtdIGdhga+sUEdAr1CiG46aB+c64xQEYyEzlwWVTNzA== - dependencies: - "@types/yargs-parser" "*" - -"@types/zen-observable@^0.8.0": - version "0.8.0" - resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.8.0.tgz#8b63ab7f1aa5321248aad5ac890a485656dcea4d" - integrity sha512-te5lMAWii1uEJ4FwLjzdlbw3+n0FZNOvFXHxQDKeT0dilh7HOzdMzV2TrJVUzq8ep7J4Na8OUYPRLSQkJHAlrg== - -"@webassemblyjs/ast@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.8.5.tgz#51b1c5fe6576a34953bf4b253df9f0d490d9e359" - integrity sha512-aJMfngIZ65+t71C3y2nBBg5FFG0Okt9m0XEgWZ7Ywgn1oMAT8cNwx00Uv1cQyHtidq0Xn94R4TAywO+LCQ+ZAQ== - dependencies: - "@webassemblyjs/helper-module-context" "1.8.5" - "@webassemblyjs/helper-wasm-bytecode" "1.8.5" - "@webassemblyjs/wast-parser" "1.8.5" - -"@webassemblyjs/floating-point-hex-parser@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.8.5.tgz#1ba926a2923613edce496fd5b02e8ce8a5f49721" - integrity sha512-9p+79WHru1oqBh9ewP9zW95E3XAo+90oth7S5Re3eQnECGq59ly1Ri5tsIipKGpiStHsUYmY3zMLqtk3gTcOtQ== - -"@webassemblyjs/helper-api-error@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.8.5.tgz#c49dad22f645227c5edb610bdb9697f1aab721f7" - integrity sha512-Za/tnzsvnqdaSPOUXHyKJ2XI7PDX64kWtURyGiJJZKVEdFOsdKUCPTNEVFZq3zJ2R0G5wc2PZ5gvdTRFgm81zA== - -"@webassemblyjs/helper-buffer@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.8.5.tgz#fea93e429863dd5e4338555f42292385a653f204" - integrity sha512-Ri2R8nOS0U6G49Q86goFIPNgjyl6+oE1abW1pS84BuhP1Qcr5JqMwRFT3Ah3ADDDYGEgGs1iyb1DGX+kAi/c/Q== - -"@webassemblyjs/helper-code-frame@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.8.5.tgz#9a740ff48e3faa3022b1dff54423df9aa293c25e" - integrity sha512-VQAadSubZIhNpH46IR3yWO4kZZjMxN1opDrzePLdVKAZ+DFjkGD/rf4v1jap744uPVU6yjL/smZbRIIJTOUnKQ== - dependencies: - "@webassemblyjs/wast-printer" "1.8.5" - -"@webassemblyjs/helper-fsm@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-fsm/-/helper-fsm-1.8.5.tgz#ba0b7d3b3f7e4733da6059c9332275d860702452" - integrity sha512-kRuX/saORcg8se/ft6Q2UbRpZwP4y7YrWsLXPbbmtepKr22i8Z4O3V5QE9DbZK908dh5Xya4Un57SDIKwB9eow== - -"@webassemblyjs/helper-module-context@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-module-context/-/helper-module-context-1.8.5.tgz#def4b9927b0101dc8cbbd8d1edb5b7b9c82eb245" - integrity sha512-/O1B236mN7UNEU4t9X7Pj38i4VoU8CcMHyy3l2cV/kIF4U5KoHXDVqcDuOs1ltkac90IM4vZdHc52t1x8Yfs3g== - dependencies: - "@webassemblyjs/ast" "1.8.5" - mamacro "^0.0.3" - -"@webassemblyjs/helper-wasm-bytecode@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.8.5.tgz#537a750eddf5c1e932f3744206551c91c1b93e61" - integrity sha512-Cu4YMYG3Ddl72CbmpjU/wbP6SACcOPVbHN1dI4VJNJVgFwaKf1ppeFJrwydOG3NDHxVGuCfPlLZNyEdIYlQ6QQ== - -"@webassemblyjs/helper-wasm-section@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.8.5.tgz#74ca6a6bcbe19e50a3b6b462847e69503e6bfcbf" - integrity sha512-VV083zwR+VTrIWWtgIUpqfvVdK4ff38loRmrdDBgBT8ADXYsEZ5mPQ4Nde90N3UYatHdYoDIFb7oHzMncI02tA== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-buffer" "1.8.5" - "@webassemblyjs/helper-wasm-bytecode" "1.8.5" - "@webassemblyjs/wasm-gen" "1.8.5" - -"@webassemblyjs/ieee754@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.8.5.tgz#712329dbef240f36bf57bd2f7b8fb9bf4154421e" - integrity sha512-aaCvQYrvKbY/n6wKHb/ylAJr27GglahUO89CcGXMItrOBqRarUMxWLJgxm9PJNuKULwN5n1csT9bYoMeZOGF3g== - dependencies: - "@xtuc/ieee754" "^1.2.0" - -"@webassemblyjs/leb128@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.8.5.tgz#044edeb34ea679f3e04cd4fd9824d5e35767ae10" - integrity sha512-plYUuUwleLIziknvlP8VpTgO4kqNaH57Y3JnNa6DLpu/sGcP6hbVdfdX5aHAV716pQBKrfuU26BJK29qY37J7A== - dependencies: - "@xtuc/long" "4.2.2" - -"@webassemblyjs/utf8@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.8.5.tgz#a8bf3b5d8ffe986c7c1e373ccbdc2a0915f0cedc" - integrity sha512-U7zgftmQriw37tfD934UNInokz6yTmn29inT2cAetAsaU9YeVCveWEwhKL1Mg4yS7q//NGdzy79nlXh3bT8Kjw== - -"@webassemblyjs/wasm-edit@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.8.5.tgz#962da12aa5acc1c131c81c4232991c82ce56e01a" - integrity sha512-A41EMy8MWw5yvqj7MQzkDjU29K7UJq1VrX2vWLzfpRHt3ISftOXqrtojn7nlPsZ9Ijhp5NwuODuycSvfAO/26Q== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-buffer" "1.8.5" - "@webassemblyjs/helper-wasm-bytecode" "1.8.5" - "@webassemblyjs/helper-wasm-section" "1.8.5" - "@webassemblyjs/wasm-gen" "1.8.5" - "@webassemblyjs/wasm-opt" "1.8.5" - "@webassemblyjs/wasm-parser" "1.8.5" - "@webassemblyjs/wast-printer" "1.8.5" - -"@webassemblyjs/wasm-gen@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.8.5.tgz#54840766c2c1002eb64ed1abe720aded714f98bc" - integrity sha512-BCZBT0LURC0CXDzj5FXSc2FPTsxwp3nWcqXQdOZE4U7h7i8FqtFK5Egia6f9raQLpEKT1VL7zr4r3+QX6zArWg== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-wasm-bytecode" "1.8.5" - "@webassemblyjs/ieee754" "1.8.5" - "@webassemblyjs/leb128" "1.8.5" - "@webassemblyjs/utf8" "1.8.5" - -"@webassemblyjs/wasm-opt@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.8.5.tgz#b24d9f6ba50394af1349f510afa8ffcb8a63d264" - integrity sha512-HKo2mO/Uh9A6ojzu7cjslGaHaUU14LdLbGEKqTR7PBKwT6LdPtLLh9fPY33rmr5wcOMrsWDbbdCHq4hQUdd37Q== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-buffer" "1.8.5" - "@webassemblyjs/wasm-gen" "1.8.5" - "@webassemblyjs/wasm-parser" "1.8.5" - -"@webassemblyjs/wasm-parser@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.8.5.tgz#21576f0ec88b91427357b8536383668ef7c66b8d" - integrity sha512-pi0SYE9T6tfcMkthwcgCpL0cM9nRYr6/6fjgDtL6q/ZqKHdMWvxitRi5JcZ7RI4SNJJYnYNaWy5UUrHQy998lw== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-api-error" "1.8.5" - "@webassemblyjs/helper-wasm-bytecode" "1.8.5" - "@webassemblyjs/ieee754" "1.8.5" - "@webassemblyjs/leb128" "1.8.5" - "@webassemblyjs/utf8" "1.8.5" - -"@webassemblyjs/wast-parser@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-parser/-/wast-parser-1.8.5.tgz#e10eecd542d0e7bd394f6827c49f3df6d4eefb8c" - integrity sha512-daXC1FyKWHF1i11obK086QRlsMsY4+tIOKgBqI1lxAnkp9xe9YMcgOxm9kLe+ttjs5aWV2KKE1TWJCN57/Btsg== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/floating-point-hex-parser" "1.8.5" - "@webassemblyjs/helper-api-error" "1.8.5" - "@webassemblyjs/helper-code-frame" "1.8.5" - "@webassemblyjs/helper-fsm" "1.8.5" - "@xtuc/long" "4.2.2" - -"@webassemblyjs/wast-printer@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.8.5.tgz#114bbc481fd10ca0e23b3560fa812748b0bae5bc" - integrity sha512-w0U0pD4EhlnvRyeJzBqaVSJAo9w/ce7/WPogeXLzGkO6hzhr4GnQIZ4W4uUt5b9ooAaXPtnXlj0gzsXEOUNYMg== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/wast-parser" "1.8.5" - "@xtuc/long" "4.2.2" - -"@xtuc/ieee754@^1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" - integrity sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA== - -"@xtuc/long@4.2.2": - version "4.2.2" - resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" - integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== - -abab@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.3.tgz#623e2075e02eb2d3f2475e49f99c91846467907a" - integrity sha512-tsFzPpcttalNjFBCFMqsKYQcWxxen1pgJR56by//QwvJc4/OUS3kPOOttx2tSIfjsylB0pYu7f5D3K1RCxUnUg== - -abbrev@1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" - integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== - -acorn-globals@^4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-4.3.4.tgz#9fa1926addc11c97308c4e66d7add0d40c3272e7" - integrity sha512-clfQEh21R+D0leSbUdWf3OcfqyaCSAQ8Ryq00bofSekfr9W8u1jyYZo6ir0xu9Gtcf7BjcHJpnbZH7JOCpP60A== - dependencies: - acorn "^6.0.1" - acorn-walk "^6.0.1" - -acorn-walk@^6.0.1: - version "6.1.1" - resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-6.1.1.tgz#d363b66f5fac5f018ff9c3a1e7b6f8e310cc3913" - integrity sha512-OtUw6JUTgxA2QoqqmrmQ7F2NYqiBPi/L2jqHyFtllhOUvXYQXf0Z1CYUinIfyT4bTCGmrA7gX9FvHA81uzCoVw== - -acorn@^6.0.1, acorn@^6.2.1: - version "6.4.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.1.tgz#531e58ba3f51b9dacb9a6646ca4debf5b14ca474" - integrity sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA== - -acorn@^7.1.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.0.tgz#949d36f2c292535da602283586c2477c57eb2d6c" - integrity sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ== - -agent-base@4, agent-base@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee" - integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg== - dependencies: - es6-promisify "^5.0.0" - -ajv-errors@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.1.tgz#f35986aceb91afadec4102fbd85014950cefa64d" - integrity sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ== - -ajv-keywords@^3.1.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.2.0.tgz#e86b819c602cf8821ad637413698f1dec021847a" - integrity sha1-6GuBnGAs+IIa1jdBNpjx3sAhhHo= - -ajv-keywords@^3.4.1: - version "3.4.1" - resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.4.1.tgz#ef916e271c64ac12171fd8384eaae6b2345854da" - integrity sha512-RO1ibKvd27e6FEShVFfPALuHI3WjSVNeK5FIsmme/LYRNxjKuNj+Dt7bucLa6NdSv3JcVTyMlm9kGR84z1XpaQ== - -ajv@^5.3.0: - version "5.5.2" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.5.2.tgz#73b5eeca3fab653e3d3f9422b341ad42205dc965" - integrity sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU= - dependencies: - co "^4.6.0" - fast-deep-equal "^1.0.0" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.3.0" - -ajv@^6.1.0: - version "6.5.0" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.5.0.tgz#4c8affdf80887d8f132c9c52ab8a2dc4d0b7b24c" - integrity sha512-VDUX1oSajablmiyFyED9L1DFndg0P9h7p1F+NO8FkIzei6EPrR6Zu1n18rd5P8PqaSRd/FrWv3G1TVBqpM83gA== - dependencies: - fast-deep-equal "^2.0.1" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.3.0" - uri-js "^4.2.1" - -ajv@^6.10.2: - version "6.10.2" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.2.tgz#d3cea04d6b017b2894ad69040fec8b623eb4bd52" - integrity sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw== - dependencies: - fast-deep-equal "^2.0.1" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.4.1" - uri-js "^4.2.2" - -ansi-colors@3.2.3: - version "3.2.3" - resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-3.2.3.tgz#57d35b8686e851e2cc04c403f1c00203976a1813" - integrity sha512-LEHHyuhlPY3TmuUYMh2oz89lTShfvgbmzaBcxve9t/9Wuy7Dwf4yoAKcND7KFT1HAQfqZ12qtc+DUrBMeKF9nw== - -ansi-regex@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" - integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= - -ansi-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" - integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= - -ansi-regex@^4.0.0, ansi-regex@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" - integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== - -ansi-styles@^3.2.0, ansi-styles@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" - integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== - dependencies: - color-convert "^1.9.0" - -anymatch@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" - integrity sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw== - dependencies: - micromatch "^3.1.4" - normalize-path "^2.1.1" - -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" - -apollo-boost@^0.1.22: - version "0.1.22" - resolved "https://registry.yarnpkg.com/apollo-boost/-/apollo-boost-0.1.22.tgz#0b6c607fcb02d06894e925010560b3237d1bce86" - integrity sha512-JOU2MIhJISOfL/2hWkuBqH7sqNs28ZS8wRVjmcMEJ+DjmF5otVsDBtx+IMp9PEReoznrbSfjo3p4ix4AgtbooA== - dependencies: - apollo-cache "^1.1.21" - apollo-cache-inmemory "^1.3.11" - apollo-client "^2.4.7" - apollo-link "^1.0.6" - apollo-link-error "^1.0.3" - apollo-link-http "^1.3.1" - apollo-link-state "^0.4.0" - graphql-tag "^2.4.2" - -apollo-cache-inmemory@^1.3.11: - version "1.3.11" - resolved "https://registry.yarnpkg.com/apollo-cache-inmemory/-/apollo-cache-inmemory-1.3.11.tgz#6cb8f24ec812715169f9acbb0b67833f9a19ec90" - integrity sha512-fSoyjBV5RV57J3i/VHDDB74ZgXc0PFiogheNFHEhC0mL6rg5e/DjTx0Vg+csIBk23gvlzTvV+eypx7Q2NJ+dYg== - dependencies: - apollo-cache "^1.1.21" - apollo-utilities "^1.0.26" - optimism "^0.6.6" - -apollo-cache@1.1.21, apollo-cache@^1.1.21: - version "1.1.21" - resolved "https://registry.yarnpkg.com/apollo-cache/-/apollo-cache-1.1.21.tgz#950025e2272741ba8e5064419906e3ac4072925d" - integrity sha512-5ErNb78KHtrJNimkDBTEigcvHkIqUmS7QJIk4lpZZ+XLVVgvk2fD+GhD1PLP+s8vHfAKVbO6vdbRxCCjGGrh5w== - dependencies: - apollo-utilities "^1.0.26" - -apollo-client@^2.4.7: - version "2.4.7" - resolved "https://registry.yarnpkg.com/apollo-client/-/apollo-client-2.4.7.tgz#b6712fd4c9ba346e3c44cfec7e6868e532b6a957" - integrity sha512-6aAm+16AFBYZhJF8eKxrup6AbYni01InDiwTfZhMMTP2xaXQWjsQnfaHbI2oE+hd3+AZFy1drkse8RZKghR/WQ== - dependencies: - "@types/zen-observable" "^0.8.0" - apollo-cache "1.1.21" - apollo-link "^1.0.0" - apollo-link-dedup "^1.0.0" - apollo-utilities "1.0.26" - symbol-observable "^1.0.2" - zen-observable "^0.8.0" - optionalDependencies: - "@types/async" "2.0.50" - -apollo-link-context@^1.0.12: - version "1.0.12" - resolved "https://registry.yarnpkg.com/apollo-link-context/-/apollo-link-context-1.0.12.tgz#02d72e621a3b75239d4419a9bfd38d8bd9bb0d3f" - integrity sha512-gb4UptV9O6Kp3i5b2TlDEfPSL2LG//mTSb3zyuR5U2cAzu/huw98f1CCxcjUKTrlIMsQuE6G/hbaThDxnoIThQ== - dependencies: - apollo-link "^1.2.6" - -apollo-link-dedup@^1.0.0: - version "1.0.13" - resolved "https://registry.yarnpkg.com/apollo-link-dedup/-/apollo-link-dedup-1.0.13.tgz#bb22957e18b6125ae8bfb46cab6bda8d33ba8046" - integrity sha512-i4NuqT3DSFczFcC7NMUzmnYjKX7NggLY+rqYVf+kE9JjqKOQhT6wqhaWsVIABfIUGE/N0DTgYJBCMu/18aXmYA== - dependencies: - apollo-link "^1.2.6" - -apollo-link-error@^1.0.3: - version "1.1.5" - resolved "https://registry.yarnpkg.com/apollo-link-error/-/apollo-link-error-1.1.5.tgz#1d600dfa75c4e4bf017f50d60da7b375b53047ab" - integrity sha512-gE0P711K+rI3QcTzfYhzRI9axXaiuq/emu8x8Y5NHK9jl9wxh7qmEc3ZTyGpnGFDDTXfhalmX17X5lp3RCVHDQ== - dependencies: - apollo-link "^1.2.6" - apollo-link-http-common "^0.2.8" - -apollo-link-http-common@^0.2.8: - version "0.2.8" - resolved "https://registry.yarnpkg.com/apollo-link-http-common/-/apollo-link-http-common-0.2.8.tgz#c6deedfc2739db8b11013c3c2d2ccd657152941f" - integrity sha512-gGmXZN8mr7e9zjopzKQfZ7IKnh8H12NxBDzvp9nXI3U82aCVb72p+plgoYLcpMY8w6krvoYjgicFmf8LO20TCQ== - dependencies: - apollo-link "^1.2.6" - -apollo-link-http@^1.3.1: - version "1.5.9" - resolved "https://registry.yarnpkg.com/apollo-link-http/-/apollo-link-http-1.5.9.tgz#9046f5640a94c8a8b508a39e0f2c628b781baecc" - integrity sha512-9tJy2zGm4Cm/1ycScDNZJe51dgnTSfKx7pKIgPZmcxkdDpgUY2DZitDH6ZBv4yp9z8MC9Xr9wgwc29s6hcadUQ== - dependencies: - apollo-link "^1.2.6" - apollo-link-http-common "^0.2.8" - -apollo-link-state@^0.4.0: - version "0.4.2" - resolved "https://registry.yarnpkg.com/apollo-link-state/-/apollo-link-state-0.4.2.tgz#ac00e9be9b0ca89eae0be6ba31fe904b80bbe2e8" - integrity sha512-xMPcAfuiPVYXaLwC6oJFIZrKgV3GmdO31Ag2eufRoXpvT0AfJZjdaPB4450Nu9TslHRePN9A3quxNueILlQxlw== - dependencies: - apollo-utilities "^1.0.8" - graphql-anywhere "^4.1.0-alpha.0" - -apollo-link@^1.0.0, apollo-link@^1.0.6, apollo-link@^1.2.6: - version "1.2.6" - resolved "https://registry.yarnpkg.com/apollo-link/-/apollo-link-1.2.6.tgz#d9b5676d79c01eb4e424b95c7171697f6ad2b8da" - integrity sha512-sUNlA20nqIF3gG3F8eyMD+mO80fmf3dPZX+GUOs3MI9oZR8ug09H3F0UsWJMcpEg6h55Yy5wZ+BMmAjrbenF/Q== - dependencies: - apollo-utilities "^1.0.0" - zen-observable-ts "^0.8.13" - -apollo-utilities@1.0.26, apollo-utilities@^1.0.0, apollo-utilities@^1.0.26, apollo-utilities@^1.0.8: - version "1.0.26" - resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.0.26.tgz#589c66bf4d16223531351cf667a230c787def1da" - integrity sha512-URw7o3phymliqYCYatcird2YRPUU2eWCNvip64U9gQrX56mEfK4m99yBIDCMTpmcvOFsKLii1sIEZsHIs/bvnw== - dependencies: - fast-json-stable-stringify "^2.0.0" - -applicationinsights@1.7.4: - version "1.7.4" - resolved "https://registry.yarnpkg.com/applicationinsights/-/applicationinsights-1.7.4.tgz#e7d96435594d893b00cf49f70a5927105dbb8749" - integrity sha512-XFLsNlcanpjFhHNvVWEfcm6hr7lu9znnb6Le1Lk5RE03YUV9X2B2n2MfM4kJZRrUdV+C0hdHxvWyv+vWoLfY7A== - dependencies: - cls-hooked "^4.2.2" - continuation-local-storage "^3.2.1" - diagnostic-channel "0.2.0" - diagnostic-channel-publishers "^0.3.3" - -aproba@^1.0.3, aproba@^1.1.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" - integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== - -are-we-there-yet@~1.1.2: - version "1.1.4" - resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.4.tgz#bb5dca382bb94f05e15194373d16fd3ba1ca110d" - integrity sha1-u13KOCu5TwXhUZQ3PRb9O6HKEQ0= - dependencies: - delegates "^1.0.0" - readable-stream "^2.0.6" - -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" - -arr-diff@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" - integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA= - -arr-flatten@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" - integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg== - -arr-union@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" - integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ= - -array-unique@^0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" - integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= - -asn1.js@^4.0.0: - version "4.10.1" - resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.10.1.tgz#b9c2bf5805f1e64aadeed6df3a2bfafb5a73f5a0" - integrity sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw== - dependencies: - bn.js "^4.0.0" - inherits "^2.0.1" - minimalistic-assert "^1.0.0" - -asn1@~0.2.3: - version "0.2.4" - resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" - integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg== - dependencies: - safer-buffer "~2.1.0" - -assert-plus@1.0.0, assert-plus@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" - integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= - -assert@^1.1.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/assert/-/assert-1.4.1.tgz#99912d591836b5a6f5b345c0f07eefc08fc65d91" - integrity sha1-mZEtWRg2tab1s0XA8H7vwI/GXZE= - dependencies: - util "0.10.3" - -assign-symbols@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" - integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= - -async-each@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d" - integrity sha1-GdOGodntxufByF04iu28xW0zYC0= - -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= - -atob@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.1.tgz#ae2d5a729477f289d60dd7f96a6314a22dd6c22a" - integrity sha1-ri1acpR38onWDdf5amMUoi3Wwio= - -aws-sign2@~0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" - integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= - -aws4@^1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f" - integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ== - -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= - -base64-js@^1.0.2: - version "1.3.0" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.0.tgz#cab1e6118f051095e58b5281aea8c1cd22bfc0e3" - integrity sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw== - -base@^0.11.1: - version "0.11.2" - resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" - integrity sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg== - dependencies: - cache-base "^1.0.1" - class-utils "^0.3.5" - component-emitter "^1.2.1" - define-property "^1.0.0" - isobject "^3.0.1" - mixin-deep "^1.2.0" - pascalcase "^0.1.1" - -bcrypt-pbkdf@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" - integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= - dependencies: - tweetnacl "^0.14.3" - -before-after-hook@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.1.0.tgz#b6c03487f44e24200dd30ca5e6a1979c5d2fb635" - integrity sha512-IWIbu7pMqyw3EAJHzzHbWa85b6oud/yfKYg5rqB5hNE8CeMi3nX+2C2sj0HswfblST86hpVEOAb9x34NZd6P7A== - -big.js@^3.1.3: - version "3.2.0" - resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.2.0.tgz#a5fc298b81b9e0dca2e458824784b65c52ba588e" - integrity sha512-+hN/Zh2D08Mx65pZ/4g5bsmNiZUuChDiQfTUQ7qJr4/kuopCr88xZsAXv6mBoZEsUI4OuGHlX59qE94K2mMW8Q== - -big.js@^5.2.2: - version "5.2.2" - resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" - integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== - -binary-extensions@^1.0.0: - version "1.11.0" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.11.0.tgz#46aa1751fb6a2f93ee5e689bb1087d4b14c6c205" - integrity sha1-RqoXUftqL5PuXmibsQh9SxTGwgU= - -binary-extensions@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.0.0.tgz#23c0df14f6a88077f5f986c0d167ec03c3d5537c" - integrity sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow== - -bluebird@^3.5.5: - version "3.5.5" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.5.tgz#a8d0afd73251effbbd5fe384a77d73003c17a71f" - integrity sha512-5am6HnnfN+urzt4yfg7IgTbotDjIT/u8AJpEt0sIU9FtXfVeezXAPKswrG+xKUCOYAINpSdgZVDU6QFh+cuH3w== - -bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0: - version "4.11.9" - resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828" - integrity sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw== - -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== - dependencies: - balanced-match "^1.0.0" - concat-map "0.0.1" - -braces@^2.3.0, braces@^2.3.1: - version "2.3.2" - resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" - integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w== - dependencies: - arr-flatten "^1.1.0" - array-unique "^0.3.2" - extend-shallow "^2.0.1" - fill-range "^4.0.0" - isobject "^3.0.1" - repeat-element "^1.1.2" - snapdragon "^0.8.1" - snapdragon-node "^2.0.1" - split-string "^3.0.2" - to-regex "^3.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== - dependencies: - fill-range "^7.0.1" - -brorand@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" - integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8= - -browser-process-hrtime@^0.1.2: - version "0.1.3" - resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-0.1.3.tgz#616f00faef1df7ec1b5bf9cfe2bdc3170f26c7b4" - integrity sha512-bRFnI4NnjO6cnyLmOV/7PVoDEMJChlcfN0z4s1YMBY989/SvlfMI1lgCnkFUs53e9gQF+w7qu7XdllSTiSl8Aw== - -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== - -browserify-aes@^1.0.0, browserify-aes@^1.0.4: - version "1.2.0" - resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.2.0.tgz#326734642f403dabc3003209853bb70ad428ef48" - integrity sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA== - dependencies: - buffer-xor "^1.0.3" - cipher-base "^1.0.0" - create-hash "^1.1.0" - evp_bytestokey "^1.0.3" - inherits "^2.0.1" - safe-buffer "^5.0.1" - -browserify-cipher@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/browserify-cipher/-/browserify-cipher-1.0.1.tgz#8d6474c1b870bfdabcd3bcfcc1934a10e94f15f0" - integrity sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w== - dependencies: - browserify-aes "^1.0.4" - browserify-des "^1.0.0" - evp_bytestokey "^1.0.0" - -browserify-des@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/browserify-des/-/browserify-des-1.0.1.tgz#3343124db6d7ad53e26a8826318712bdc8450f9c" - integrity sha512-zy0Cobe3hhgpiOM32Tj7KQ3Vl91m0njwsjzZQK1L+JDf11dzP9qIvjreVinsvXrgfjhStXwUWAEpB9D7Gwmayw== - dependencies: - cipher-base "^1.0.1" - des.js "^1.0.0" - inherits "^2.0.1" - -browserify-rsa@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.0.1.tgz#21e0abfaf6f2029cf2fafb133567a701d4135524" - integrity sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ= - dependencies: - bn.js "^4.1.0" - randombytes "^2.0.1" - -browserify-sign@^4.0.0: - version "4.0.4" - resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.0.4.tgz#aa4eb68e5d7b658baa6bf6a57e630cbd7a93d298" - integrity sha1-qk62jl17ZYuqa/alfmMMvXqT0pg= - dependencies: - bn.js "^4.1.1" - browserify-rsa "^4.0.0" - create-hash "^1.1.0" - create-hmac "^1.1.2" - elliptic "^6.0.0" - inherits "^2.0.1" - parse-asn1 "^5.0.0" - -browserify-zlib@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.2.0.tgz#2869459d9aa3be245fe8fe2ca1f46e2e7f54d73f" - integrity sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA== - dependencies: - pako "~1.0.5" - -buffer-from@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" - integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== - -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@^4.3.0: - version "4.9.1" - resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.1.tgz#6d1bb601b07a4efced97094132093027c95bc298" - integrity sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg= - dependencies: - base64-js "^1.0.2" - ieee754 "^1.1.4" - isarray "^1.0.0" - -builtin-modules@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" - integrity sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8= - -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 sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug= - -cacache@^12.0.2: - version "12.0.2" - resolved "https://registry.yarnpkg.com/cacache/-/cacache-12.0.2.tgz#8db03205e36089a3df6954c66ce92541441ac46c" - integrity sha512-ifKgxH2CKhJEg6tNdAwziu6Q33EvuG26tYcda6PT3WKisZcYDXsnEdnRv67Po3yCzFfaSoMjGZzJyD2c3DT1dg== - dependencies: - bluebird "^3.5.5" - chownr "^1.1.1" - figgy-pudding "^3.5.1" - glob "^7.1.4" - graceful-fs "^4.1.15" - infer-owner "^1.0.3" - lru-cache "^5.1.1" - mississippi "^3.0.0" - mkdirp "^0.5.1" - move-concurrently "^1.0.1" - promise-inflight "^1.0.1" - rimraf "^2.6.3" - ssri "^6.0.1" - unique-filename "^1.1.1" - y18n "^4.0.0" - -cache-base@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" - integrity sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ== - dependencies: - collection-visit "^1.0.0" - component-emitter "^1.2.1" - get-value "^2.0.6" - has-value "^1.0.0" - isobject "^3.0.1" - set-value "^2.0.0" - to-object-path "^0.3.0" - union-value "^1.0.0" - unset-value "^1.0.0" - -camelcase@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.0.0.tgz#03295527d58bd3cd4aa75363f35b2e8d97be2f42" - integrity sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA== - -camelcase@^5.3.1: - version "5.3.1" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" - integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== - -caseless@~0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" - integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= - -chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, 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== - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - -chalk@^2.1.0, chalk@^2.3.0: - version "2.4.1" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.1.tgz#18c49ab16a037b6eb0152cc83e3471338215b66e" - integrity sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ== - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - -charenc@~0.0.1: - version "0.0.2" - resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" - integrity sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc= - -chokidar@3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.3.0.tgz#12c0714668c55800f659e262d4962a97faf554a6" - integrity sha512-dGmKLDdT3Gdl7fBUe8XK+gAtGmzy5Fn0XkkWQuYxGIgWVPPse2CxFA5mtrlD0TOHaHjEUqkWNyP1XdHoJES/4A== - dependencies: - anymatch "~3.1.1" - braces "~3.0.2" - glob-parent "~5.1.0" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.2.0" - optionalDependencies: - fsevents "~2.1.1" - -chokidar@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.0.3.tgz#dcbd4f6cbb2a55b4799ba8a840ac527e5f4b1176" - integrity sha512-zW8iXYZtXMx4kux/nuZVXjkLP+CyIK5Al5FHnj1OgTKGZfp4Oy6/ymtMSKFv3GD8DviEmUPmJg9eFdJ/JzudMg== - dependencies: - anymatch "^2.0.0" - async-each "^1.0.0" - braces "^2.3.0" - glob-parent "^3.1.0" - inherits "^2.0.1" - is-binary-path "^1.0.0" - is-glob "^4.0.0" - normalize-path "^2.1.1" - path-is-absolute "^1.0.0" - readdirp "^2.0.0" - upath "^1.0.0" - optionalDependencies: - fsevents "^1.1.2" - -chownr@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.2.tgz#a18f1e0b269c8a6a5d3c86eb298beb14c3dd7bf6" - integrity sha512-GkfeAQh+QNy3wquu9oIZr6SS5x7wGdSgNQvD10X3r+AZr1Oys22HW8kAmDMvNg2+Dm0TeGaEuO8gFwdBXxwO8A== - -chrome-trace-event@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.2.tgz#234090ee97c7d4ad1a2c4beae27505deffc608a4" - integrity sha512-9e/zx1jw7B4CO+c/RXoCsfg/x1AfUBioy4owYH0bJprEYAx5hRFLRhWBqHAG57D0ZM4H7vxbP7bPe0VwhQRYDQ== - dependencies: - 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== - dependencies: - inherits "^2.0.1" - safe-buffer "^5.0.1" - -class-utils@^0.3.5: - version "0.3.6" - resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" - integrity sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg== - dependencies: - arr-union "^3.1.0" - define-property "^0.2.5" - isobject "^3.0.0" - static-extend "^0.1.1" - -cliui@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" - integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA== - dependencies: - string-width "^3.1.0" - strip-ansi "^5.2.0" - wrap-ansi "^5.1.0" - -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" - -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= - -code-point-at@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" - integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= - -collection-visit@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" - integrity sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA= - dependencies: - map-visit "^1.0.0" - object-visit "^1.0.0" - -color-convert@^1.9.0: - version "1.9.1" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.1.tgz#c1261107aeb2f294ebffec9ed9ecad529a6097ed" - integrity sha512-mjGanIiwQJskCC18rPR6OmrZ6fm2Lc7PeGFYwCmy5J34wC6F1PzdGL6xeMfmgicfYcNLGuVFA3WzXtIDCQSZxQ== - dependencies: - color-name "^1.1.1" - -color-name@^1.1.1: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" - integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= - -combined-stream@1.0.6, combined-stream@~1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.6.tgz#723e7df6e801ac5613113a7e445a9b69cb632818" - integrity sha1-cj599ugBrFYTETp+RFqbactjKBg= - dependencies: - delayed-stream "~1.0.0" - -commander@^2.12.1: - version "2.17.1" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf" - integrity sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg== - -commander@^2.20.0: - version "2.20.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.0.tgz#d58bb2b5c1ee8f87b0d340027e9e94e222c5a422" - integrity sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ== - -commondir@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" - integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs= - -component-emitter@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" - integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY= - -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= - -concat-stream@^1.5.0: - version "1.6.2" - resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" - integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== - dependencies: - buffer-from "^1.0.0" - inherits "^2.0.3" - readable-stream "^2.2.2" - typedarray "^0.0.6" - -console-browserify@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.1.0.tgz#f0241c45730a9fc6323b206dbf38edc741d0bb10" - integrity sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA= - dependencies: - date-now "^0.1.4" - -console-control-strings@^1.0.0, console-control-strings@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" - integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= - -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= - -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" - -copy-concurrently@^1.0.0: - version "1.0.5" - resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0" - integrity sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A== - dependencies: - aproba "^1.1.1" - fs-write-stream-atomic "^1.0.8" - iferr "^0.1.5" - mkdirp "^0.5.1" - rimraf "^2.5.4" - run-queue "^1.0.0" - -copy-descriptor@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" - integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= - -core-util-is@1.0.2, 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= - -create-ecdh@^4.0.0: - version "4.0.3" - resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.3.tgz#c9111b6f33045c4697f144787f9254cdc77c45ff" - integrity sha512-GbEHQPMOswGpKXM9kCWVrremUcBmjteUaQ01T9rkKCPDXfUHX0IoP9LpHYo2NPFampa4e+/pFDc3jQdxrxQLaw== - dependencies: - bn.js "^4.1.0" - elliptic "^6.0.0" - -create-hash@^1.1.0, create-hash@^1.1.2: - version "1.2.0" - resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" - integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== - dependencies: - cipher-base "^1.0.1" - inherits "^2.0.1" - md5.js "^1.3.4" - ripemd160 "^2.0.1" - sha.js "^2.4.0" - -create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4: - 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== - dependencies: - cipher-base "^1.0.3" - create-hash "^1.1.0" - inherits "^2.0.1" - ripemd160 "^2.0.0" - safe-buffer "^5.0.1" - sha.js "^2.4.8" - -cross-spawn@6.0.5, cross-spawn@^6.0.0: - version "6.0.5" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" - integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== - dependencies: - nice-try "^1.0.4" - path-key "^2.0.1" - semver "^5.5.0" - shebang-command "^1.2.0" - which "^1.2.9" - -cross-spawn@^5.0.1: - version "5.1.0" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" - integrity sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk= - dependencies: - lru-cache "^4.0.1" - shebang-command "^1.2.0" - which "^1.2.9" - -crypt@~0.0.1: - version "0.0.2" - resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" - integrity sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs= - -crypto-browserify@^3.11.0: - version "3.12.0" - resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.12.0.tgz#396cf9f3137f03e4b8e532c58f698254e00f80ec" - integrity sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg== - dependencies: - browserify-cipher "^1.0.0" - browserify-sign "^4.0.0" - create-ecdh "^4.0.0" - create-hash "^1.1.0" - create-hmac "^1.1.0" - diffie-hellman "^5.0.0" - inherits "^2.0.1" - pbkdf2 "^3.0.3" - public-encrypt "^4.0.0" - randombytes "^2.0.0" - randomfill "^1.0.3" - -css-loader@^3.4.2: - version "3.4.2" - resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-3.4.2.tgz#d3fdb3358b43f233b78501c5ed7b1c6da6133202" - integrity sha512-jYq4zdZT0oS0Iykt+fqnzVLRIeiPWhka+7BqPn+oSIpWJAHak5tmB/WZrJ2a21JhCeFyNnnlroSl8c+MtVndzA== - dependencies: - camelcase "^5.3.1" - cssesc "^3.0.0" - icss-utils "^4.1.1" - loader-utils "^1.2.3" - normalize-path "^3.0.0" - postcss "^7.0.23" - postcss-modules-extract-imports "^2.0.0" - postcss-modules-local-by-default "^3.0.2" - postcss-modules-scope "^2.1.1" - postcss-modules-values "^3.0.0" - postcss-value-parser "^4.0.2" - schema-utils "^2.6.0" - -cssesc@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" - integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== - -cssom@^0.4.4: - version "0.4.4" - resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.4.4.tgz#5a66cf93d2d0b661d80bf6a44fb65f5c2e4e0a10" - integrity sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw== - -cssom@~0.3.6: - version "0.3.8" - resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a" - integrity sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg== - -cssstyle@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-2.2.0.tgz#e4c44debccd6b7911ed617a4395e5754bba59992" - integrity sha512-sEb3XFPx3jNnCAMtqrXPDeSgQr+jojtCeNf8cvMNMh1cG970+lljssvQDzPq6lmmJu2Vhqood/gtEomBiHOGnA== - dependencies: - cssom "~0.3.6" - -csstype@^2.2.0: - version "2.6.2" - resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.2.tgz#3043d5e065454579afc7478a18de41909c8a2f01" - integrity sha512-Rl7PvTae0pflc1YtxtKbiSqq20Ts6vpIYOD5WBafl4y123DyHUeLrRdQP66sQW8/6gmX8jrYJLXwNeMqYVJcow== - -cyclist@~0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640" - integrity sha1-GzN5LhHpFKL9bW7WRHRkRE5fpkA= - -dashdash@^1.12.0: - version "1.14.1" - resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" - integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA= - dependencies: - assert-plus "^1.0.0" - -data-uri-to-buffer@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-3.0.1.tgz#594b8973938c5bc2c33046535785341abc4f3636" - integrity sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og== - -data-urls@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-2.0.0.tgz#156485a72963a970f5d5821aaf642bef2bf2db9b" - integrity sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ== - dependencies: - abab "^2.0.3" - whatwg-mimetype "^2.3.0" - whatwg-url "^8.0.0" - -date-now@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" - integrity sha1-6vQ5/U1ISK105cx9vvIAZyueNFs= - -debounce@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.0.tgz#44a540abc0ea9943018dc0eaa95cce87f65cd131" - integrity sha512-mYtLl1xfZLi1m4RtQYlZgJUNQjl4ZxVnHzIR8nLLgi4q1YT8o/WM+MK/f8yfcc9s5Ir5zRaPZyZU6xs1Syoocg== - -debug@3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" - integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== - dependencies: - ms "2.0.0" - -debug@3.2.6, debug@^3.1.0: - version "3.2.6" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" - integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== - dependencies: - ms "^2.1.1" - -debug@^2.1.2, debug@^2.2.0, debug@^2.3.3: - 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" - -decamelize@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" - integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= - -decimal.js@^10.2.0: - version "10.2.0" - resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.2.0.tgz#39466113a9e036111d02f82489b5fd6b0b5ed231" - integrity sha512-vDPw+rDgn3bZe1+F/pyEwb1oMG2XTlRVgAa6B4KccTEpYgF8w6eQllVbQcfIJnZyvzFtFpxnpGtx8dd7DJp/Rw== - -decode-uri-component@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" - integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= - -deep-extend@^0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.5.1.tgz#b894a9dd90d3023fbf1c55a394fb858eb2066f1f" - integrity sha512-N8vBdOa+DF7zkRrDCsaOXoCs/E2fJfx9B9MrKnnSiHNh4ws7eSys6YQE4KvT1cecKmOASYQBhbKjeuDD9lT81w== - -deep-is@~0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" - integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= - -define-properties@^1.1.2, define-properties@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" - integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ== - dependencies: - object-keys "^1.0.12" - -define-property@^0.2.5: - version "0.2.5" - resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116" - integrity sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY= - dependencies: - is-descriptor "^0.1.0" - -define-property@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6" - integrity sha1-dp66rz9KY6rTr56NMEybvnm/sOY= - dependencies: - is-descriptor "^1.0.0" - -define-property@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d" - integrity sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ== - dependencies: - is-descriptor "^1.0.2" - isobject "^3.0.1" - -delayed-stream@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= - -delegates@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" - integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= - -deprecation@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.0.0.tgz#dd0427cd920c78bc575ec39dab2f22e7c304fb9d" - integrity sha512-lbQN037mB3VfA2JFuguM5GCJ+zPinMeCrFe+AfSZ6eqrnJA/Fs+EYMnd6Nb2mn9lf2jO9xwEd9o9lic+D4vkcw== - -deprecation@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" - integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ== - -des.js@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.0.tgz#c074d2e2aa6a8a9a07dbd61f9a15c2cd83ec8ecc" - integrity sha1-wHTS4qpqipoH29YfmhXCzYPsjsw= - dependencies: - inherits "^2.0.1" - minimalistic-assert "^1.0.0" - -detect-file@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7" - integrity sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc= - -detect-libc@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" - integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= - -diagnostic-channel-publishers@^0.3.3: - version "0.3.3" - resolved "https://registry.yarnpkg.com/diagnostic-channel-publishers/-/diagnostic-channel-publishers-0.3.3.tgz#376b7798f4fa90f37eb4f94d2caca611b0e9c330" - integrity sha512-qIocRYU5TrGUkBlDDxaziAK1+squ8Yf2Ls4HldL3xxb/jzmWO2Enux7CvevNKYmF2kDXZ9HiRqwjPsjk8L+i2Q== - -diagnostic-channel@0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/diagnostic-channel/-/diagnostic-channel-0.2.0.tgz#cc99af9612c23fb1fff13612c72f2cbfaa8d5a17" - integrity sha1-zJmvlhLCP7H/8TYSxy8sv6qNWhc= - dependencies: - semver "^5.3.0" - -diff@3.5.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" - integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA== - -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== - -diffie-hellman@^5.0.0: - version "5.0.3" - resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" - integrity sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg== - dependencies: - bn.js "^4.1.0" - miller-rabin "^4.0.0" - randombytes "^2.0.0" - -dom-testing-library@^4.1.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/dom-testing-library/-/dom-testing-library-4.1.1.tgz#615af61bee06db51bd8ecea60c113eba7cb49dda" - integrity sha512-PUsG7aY5BJxzulDrOtkksqudRRypcVQF6d4RGAyj9xNwallOFqrNLOyg2QW2mCpFaNVPELX8hBX/wbHQtOto/A== - dependencies: - "@babel/runtime" "^7.4.3" - "@sheerun/mutationobserver-shim" "^0.3.2" - pretty-format "^24.7.0" - wait-for-expect "^1.1.1" - -domain-browser@^1.1.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" - integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA== - -domexception@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/domexception/-/domexception-2.0.1.tgz#fb44aefba793e1574b0af6aed2801d057529f304" - integrity sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg== - dependencies: - webidl-conversions "^5.0.0" - -duplexify@^3.4.2, duplexify@^3.6.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.6.0.tgz#592903f5d80b38d037220541264d69a198fb3410" - integrity sha512-fO3Di4tBKJpYTFHAxTU00BcfWMY9w24r/x21a6rZRbsD/ToUgGxsMbiGRmB7uVAXeGKXD9MwiLZa5E97EVgIRQ== - dependencies: - end-of-stream "^1.0.0" - inherits "^2.0.1" - readable-stream "^2.0.0" - stream-shift "^1.0.0" - -ecc-jsbn@~0.1.1: - version "0.1.2" - resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" - integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk= - dependencies: - jsbn "~0.1.0" - safer-buffer "^2.1.0" - -elliptic@^6.0.0: - version "6.5.3" - resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.3.tgz#cb59eb2efdaf73a0bd78ccd7015a62ad6e0f93d6" - integrity sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw== - dependencies: - bn.js "^4.4.0" - brorand "^1.0.1" - hash.js "^1.0.0" - hmac-drbg "^1.0.0" - inherits "^2.0.1" - minimalistic-assert "^1.0.0" - minimalistic-crypto-utils "^1.0.0" - -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@^7.0.1: - version "7.0.3" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" - integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== - -emojis-list@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" - integrity sha1-TapNnbAPmBmIDHn6RXrlsJof04k= - -end-of-stream@^1.0.0, end-of-stream@^1.1.0: - version "1.4.1" - resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43" - integrity sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q== - dependencies: - once "^1.4.0" - -enhanced-resolve@4.1.0, enhanced-resolve@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.1.0.tgz#41c7e0bfdfe74ac1ffe1e57ad6a5c6c9f3742a7f" - integrity sha512-F/7vkyTtyc/llOIn8oWclcB25KdRaiPBpZYDgJHgh/UHtpgT2p2eldQgtQnLtUvfMKPKxbRaQM/hHkvLHt1Vng== - dependencies: - graceful-fs "^4.1.2" - memory-fs "^0.4.0" - tapable "^1.0.0" - -enhanced-resolve@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.0.0.tgz#e34a6eaa790f62fccd71d93959f56b2b432db10a" - integrity sha512-jox/62b2GofV1qTUQTMPEJSDIGycS43evqYzD/KVtEb9OCoki9cnacUPxCrZa7JfPzZSYOCZhu9O9luaMxAX8g== - dependencies: - graceful-fs "^4.1.2" - memory-fs "^0.4.0" - tapable "^1.0.0" - -errno@^0.1.3, errno@~0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618" - integrity sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg== - dependencies: - prr "~1.0.1" - -es-abstract@^1.17.0-next.1: - version "1.17.4" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.4.tgz#e3aedf19706b20e7c2594c35fc0d57605a79e184" - integrity sha512-Ae3um/gb8F0mui/jPL+QiqmglkUsaQf7FwBEHYIFkztkneosu9imhqHpBzQ3h1vit8t5iQ74t6PEVvphBZiuiQ== - dependencies: - es-to-primitive "^1.2.1" - function-bind "^1.1.1" - has "^1.0.3" - has-symbols "^1.0.1" - is-callable "^1.1.5" - is-regex "^1.0.5" - object-inspect "^1.7.0" - object-keys "^1.1.1" - object.assign "^4.1.0" - string.prototype.trimleft "^2.1.1" - string.prototype.trimright "^2.1.1" - -es-to-primitive@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" - integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== - dependencies: - is-callable "^1.1.4" - is-date-object "^1.0.1" - is-symbol "^1.0.2" - -es6-promise@^4.0.3: - version "4.2.8" - resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a" - integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w== - -es6-promise@^4.2.5: - version "4.2.5" - resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.5.tgz#da6d0d5692efb461e082c14817fe2427d8f5d054" - integrity sha512-n6wvpdE43VFtJq+lUDYDBFUwV8TZbuGXLV4D6wKafg13ldznKsyEvatubnmUe31zcvelSzOHF+XbaT+Bl9ObDg== - -es6-promisify@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203" - integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM= - dependencies: - es6-promise "^4.0.3" - -escape-string-regexp@1.0.5, escape-string-regexp@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= - -escodegen@^1.13.0: - version "1.14.1" - resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.14.1.tgz#ba01d0c8278b5e95a9a45350142026659027a457" - integrity sha512-Bmt7NcRySdIfNPfU2ZoXDrrXsG9ZjvDxcAlMfDUgRBjLOWTuIACXPBFJH7Z+cLb40JeQco5toikyc9t9P8E9SQ== - dependencies: - esprima "^4.0.1" - estraverse "^4.2.0" - esutils "^2.0.2" - optionator "^0.8.1" - optionalDependencies: - source-map "~0.6.1" - -eslint-scope@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848" - integrity sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg== - dependencies: - esrecurse "^4.1.0" - estraverse "^4.1.1" - -esprima@^4.0.0, 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== - -esrecurse@^4.1.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.2.1.tgz#007a3b9fdbc2b3bb87e4879ea19c92fdbd3942cf" - integrity sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ== - dependencies: - estraverse "^4.1.0" - -estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13" - integrity sha1-De4/7TH81GlhjOc0IJn8GvoL2xM= - -esutils@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" - integrity sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs= - -events@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/events/-/events-3.0.0.tgz#9a0a0dfaf62893d92b875b8f2698ca4114973e88" - integrity sha512-Dc381HFWJzEOhQ+d8pkNon++bk9h6cdAoAj4iE6Q4y6xgTzySWXlKn05/TVNpjnfRqi/X0EpJEJohPjNI3zpVA== - -evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02" - integrity sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA== - dependencies: - md5.js "^1.3.4" - safe-buffer "^5.1.1" - -execa@^0.6.0: - version "0.6.3" - resolved "https://registry.yarnpkg.com/execa/-/execa-0.6.3.tgz#57b69a594f081759c69e5370f0d17b9cb11658fe" - integrity sha1-V7aaWU8IF1nGnlNw8NF7nLEWWP4= - dependencies: - cross-spawn "^5.0.1" - get-stream "^3.0.0" - is-stream "^1.1.0" - npm-run-path "^2.0.0" - p-finally "^1.0.0" - signal-exit "^3.0.0" - strip-eof "^1.0.0" - -execa@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" - integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA== - dependencies: - cross-spawn "^6.0.0" - get-stream "^4.0.0" - is-stream "^1.1.0" - npm-run-path "^2.0.0" - p-finally "^1.0.0" - signal-exit "^3.0.0" - strip-eof "^1.0.0" - -expand-brackets@^2.1.4: - version "2.1.4" - resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" - integrity sha1-t3c14xXOMPa27/D4OwQVGiJEliI= - dependencies: - debug "^2.3.3" - define-property "^0.2.5" - extend-shallow "^2.0.1" - posix-character-classes "^0.1.0" - regex-not "^1.0.0" - snapdragon "^0.8.1" - to-regex "^3.0.1" - -expand-tilde@^2.0.0, expand-tilde@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502" - integrity sha1-l+gBqgUt8CRU3kawK/YhZCzchQI= - dependencies: - homedir-polyfill "^1.0.1" - -extend-shallow@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" - integrity sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8= - dependencies: - is-extendable "^0.1.0" - -extend-shallow@^3.0.0, extend-shallow@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" - integrity sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg= - dependencies: - assign-symbols "^1.0.0" - is-extendable "^1.0.1" - -extend@~3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" - integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== - -extglob@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543" - integrity sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw== - dependencies: - array-unique "^0.3.2" - define-property "^1.0.0" - expand-brackets "^2.1.4" - extend-shallow "^2.0.1" - fragment-cache "^0.2.1" - regex-not "^1.0.0" - snapdragon "^0.8.1" - to-regex "^3.0.1" - -extsprintf@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" - integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU= - -extsprintf@^1.2.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" - integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= - -fast-deep-equal@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz#c053477817c86b51daa853c81e059b733d023614" - integrity sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ= - -fast-deep-equal@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" - integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk= - -fast-json-stable-stringify@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2" - integrity sha1-1RQsDK7msRifh9OnYREGT4bIu/I= - -fast-levenshtein@~2.0.4: - version "2.0.6" - resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" - integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= - -fetch-blob@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-2.0.1.tgz#64b583cd2bd21685b17706818b388af08cb40b9b" - integrity sha512-1jFpa68M4EzObtFa7XOKZoN1unsaeJ6hGSbxaWaVO+TkHmVvnyzRu1ktZAFbUvTZ9NC/qMKGKJ79dK4MzuSBiw== - -figgy-pudding@^3.5.1: - version "3.5.1" - resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.1.tgz#862470112901c727a0e495a80744bd5baa1d6790" - integrity sha512-vNKxJHTEKNThjfrdJwHc7brvM6eVevuO5nTj6ez8ZQ1qbXTvGthucRF7S4vf2cr71QVnT70V34v0S1DyQsti0w== - -fill-range@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" - integrity sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc= - dependencies: - extend-shallow "^2.0.1" - is-number "^3.0.0" - repeat-string "^1.6.1" - to-regex-range "^2.1.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== - dependencies: - to-regex-range "^5.0.1" - -find-cache-dir@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-2.1.0.tgz#8d0f94cd13fe43c6c7c261a0d86115ca918c05f7" - integrity sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ== - dependencies: - commondir "^1.0.1" - make-dir "^2.0.0" - pkg-dir "^3.0.0" - -find-up@3.0.0, find-up@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" - integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== - dependencies: - locate-path "^3.0.0" - -findup-sync@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-3.0.0.tgz#17b108f9ee512dfb7a5c7f3c8b27ea9e1a9c08d1" - integrity sha512-YbffarhcicEhOrm4CtrwdKBdCuz576RLdhJDsIfvNtxUuhdRet1qZcsMjqbePtAseKdAnDyM/IyXbu7PRPRLYg== - dependencies: - detect-file "^1.0.0" - is-glob "^4.0.0" - micromatch "^3.0.4" - resolve-dir "^1.0.1" - -flat@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/flat/-/flat-4.1.0.tgz#090bec8b05e39cba309747f1d588f04dbaf98db2" - integrity sha512-Px/TiLIznH7gEDlPXcUD4KnBusa6kR6ayRUVcnEAbreRIuhkqow/mun59BuRXwoYk7ZQOLW1ZM05ilIvK38hFw== - dependencies: - is-buffer "~2.0.3" - -flush-write-stream@^1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.0.3.tgz#c5d586ef38af6097650b49bc41b55fabb19f35bd" - integrity sha512-calZMC10u0FMUqoiunI2AiGIIUtUIvifNwkHhNupZH4cbNnW1Itkoh/Nf5HFYmDrwWPjrUxpkZT0KhuCq0jmGw== - dependencies: - inherits "^2.0.1" - readable-stream "^2.0.4" - -for-in@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" - integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= - -forever-agent@~0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" - integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= - -form-data@~2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.2.tgz#4970498be604c20c005d4f5c23aecd21d6b49099" - integrity sha1-SXBJi+YEwgwAXU9cI67NIda0kJk= - dependencies: - asynckit "^0.4.0" - combined-stream "1.0.6" - mime-types "^2.1.12" - -fragment-cache@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" - integrity sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk= - dependencies: - map-cache "^0.2.2" - -from2@^2.1.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af" - integrity sha1-i/tVAr3kpNNs/e6gB/zKIdfjgq8= - dependencies: - inherits "^2.0.1" - readable-stream "^2.0.0" - -fs-minipass@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.5.tgz#06c277218454ec288df77ada54a03b8702aacb9d" - integrity sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ== - dependencies: - minipass "^2.2.1" - -fs-write-stream-atomic@^1.0.8: - version "1.0.10" - resolved "https://registry.yarnpkg.com/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz#b47df53493ef911df75731e70a9ded0189db40c9" - integrity sha1-tH31NJPvkR33VzHnCp3tAYnbQMk= - dependencies: - graceful-fs "^4.1.2" - iferr "^0.1.5" - imurmurhash "^0.1.4" - readable-stream "1 || 2" - -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= - -fsevents@^1.1.2: - version "1.2.4" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.4.tgz#f41dcb1af2582af3692da36fc55cbd8e1041c426" - integrity sha512-z8H8/diyk76B7q5wg+Ud0+CqzcAF3mBBI/bA5ne5zrRUUIvNkJY//D3BqyH571KuAC4Nr7Rw7CjWX4r0y9DvNg== - dependencies: - nan "^2.9.2" - node-pre-gyp "^0.10.0" - -fsevents@~2.1.1: - version "2.1.2" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.2.tgz#4c0a1fb34bc68e543b4b82a9ec392bfbda840805" - integrity sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA== - -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== - -gauge@~2.7.3: - version "2.7.4" - resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" - integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c= - dependencies: - aproba "^1.0.3" - console-control-strings "^1.0.0" - has-unicode "^2.0.0" - object-assign "^4.1.0" - signal-exit "^3.0.0" - string-width "^1.0.1" - strip-ansi "^3.0.1" - wide-align "^1.1.0" - -get-caller-file@^2.0.1: - 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-stream@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" - integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ= - -get-stream@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" - integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== - dependencies: - pump "^3.0.0" - -get-value@^2.0.3, get-value@^2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" - integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= - -getpass@^0.1.1: - version "0.1.7" - resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" - integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= - dependencies: - assert-plus "^1.0.0" - -git-credential-node@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/git-credential-node/-/git-credential-node-1.1.0.tgz#2507544ac031d0082cd9e0f833aac758f15fd80d" - integrity sha1-JQdUSsAx0Ags2eD4M6rHWPFf2A0= - dependencies: - execa "^0.6.0" - -glob-parent@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" - integrity sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4= - dependencies: - is-glob "^3.1.0" - path-dirname "^1.0.0" - -glob-parent@~5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.0.tgz#5f4c1d1e748d30cd73ad2944b3577a81b081e8c2" - integrity sha512-qjtRgnIVmOfnKUE3NJAQEdk+lKrxfw8t5ke7SXtfMTHcjsBfOfWXCQfdb30zfDoZQ2IRSIiidmjtbHZPZ++Ihw== - dependencies: - is-glob "^4.0.1" - -glob@7.1.3: - version "7.1.3" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1" - integrity sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - -glob@7.1.6: - version "7.1.6" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" - integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - -glob@^7.0.5, glob@^7.1.1: - version "7.1.2" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" - integrity sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - -glob@^7.1.3, glob@^7.1.4: - version "7.1.4" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255" - integrity sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - -global-modules@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780" - integrity sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A== - dependencies: - global-prefix "^3.0.0" - -global-modules@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea" - integrity sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg== - dependencies: - global-prefix "^1.0.1" - is-windows "^1.0.1" - resolve-dir "^1.0.0" - -global-prefix@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-1.0.2.tgz#dbf743c6c14992593c655568cb66ed32c0122ebe" - integrity sha1-2/dDxsFJklk8ZVVoy2btMsASLr4= - dependencies: - expand-tilde "^2.0.2" - homedir-polyfill "^1.0.1" - ini "^1.3.4" - is-windows "^1.0.1" - which "^1.2.14" - -global-prefix@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-3.0.0.tgz#fc85f73064df69f50421f47f883fe5b913ba9b97" - integrity sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg== - dependencies: - ini "^1.3.5" - kind-of "^6.0.2" - which "^1.3.1" - -graceful-fs@^4.1.15: - version "4.2.0" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.0.tgz#8d8fdc73977cb04104721cb53666c1ca64cd328b" - integrity sha512-jpSvDPV4Cq/bgtpndIWbI5hmYxhQGHPC4d4cqBPb4DLniCfhJokdXhwhaDuLBGLQdvvRum/UiX6ECVIPvDXqdg== - -graceful-fs@^4.1.2: - version "4.1.11" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658" - integrity sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg= - -graphql-anywhere@^4.1.0-alpha.0: - version "4.1.23" - resolved "https://registry.yarnpkg.com/graphql-anywhere/-/graphql-anywhere-4.1.23.tgz#4275b161432b7c60a36970a1d9840d8c5cc2516e" - integrity sha512-8wtmwxWmLzAy52Z4WAw9UiYZ4ViiNXM+2DMOSlg2F7WsVstD0v75uOjCLJQUvbld5kHrgzTxter/THFVkGL+Yw== - dependencies: - apollo-utilities "^1.0.26" - -graphql-tag@^2.4.2: - version "2.10.0" - resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.10.0.tgz#87da024be863e357551b2b8700e496ee2d4353ae" - integrity sha512-9FD6cw976TLLf9WYIUPCaaTpniawIjHWZSwIRZSjrfufJamcXbVVYfN2TWvJYbw0Xf2JjYbl1/f2+wDnBVw3/w== - -graphql@^14.0.2: - version "14.0.2" - resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.0.2.tgz#7dded337a4c3fd2d075692323384034b357f5650" - integrity sha512-gUC4YYsaiSJT1h40krG3J+USGlwhzNTXSb4IOZljn9ag5Tj+RkoXrWp+Kh7WyE3t1NCfab5kzCuxBIvOMERMXw== - dependencies: - iterall "^1.2.2" - -growl@1.10.5: - version "1.10.5" - resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" - integrity sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA== - -har-schema@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" - integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= - -har-validator@~5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.0.tgz#44657f5688a22cfd4b72486e81b3a3fb11742c29" - integrity sha512-+qnmNjI4OfH2ipQ9VQOw23bBd/ibtfbVdK2fYbY4acTDqKTW/YDp9McimZdDbG8iV9fZizUqQMD5xvriB146TA== - dependencies: - ajv "^5.3.0" - har-schema "^2.0.0" - -has-flag@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" - integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= - -has-flag@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" - integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== - -has-symbols@^1.0.0, has-symbols@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" - integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg== - -has-unicode@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" - integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk= - -has-value@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" - integrity sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8= - dependencies: - get-value "^2.0.3" - has-values "^0.1.4" - isobject "^2.0.0" - -has-value@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177" - integrity sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc= - dependencies: - get-value "^2.0.6" - has-values "^1.0.0" - isobject "^3.0.0" - -has-values@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771" - integrity sha1-bWHeldkd/Km5oCCJrThL/49it3E= - -has-values@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f" - integrity sha1-lbC2P+whRmGab+V/51Yo1aOe/k8= - dependencies: - is-number "^3.0.0" - kind-of "^4.0.0" - -has@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" - integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== - dependencies: - function-bind "^1.1.1" - -hash-base@^3.0.0: - version "3.0.4" - resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.0.4.tgz#5fc8686847ecd73499403319a6b0a3f3f6ae4918" - integrity sha1-X8hoaEfs1zSZQDMZprCj8/auSRg= - dependencies: - inherits "^2.0.1" - safe-buffer "^5.0.1" - -hash.js@^1.0.0, hash.js@^1.0.3: - version "1.1.7" - resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42" - integrity sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA== - dependencies: - inherits "^2.0.3" - minimalistic-assert "^1.0.1" - -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== - -hmac-drbg@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" - integrity sha1-0nRXAQJabHdabFRXk+1QL8DGSaE= - dependencies: - hash.js "^1.0.3" - minimalistic-assert "^1.0.0" - minimalistic-crypto-utils "^1.0.1" - -homedir-polyfill@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.1.tgz#4c2bbc8a758998feebf5ed68580f76d46768b4bc" - integrity sha1-TCu8inWJmP7r9e1oWA921GdotLw= - dependencies: - parse-passwd "^1.0.0" - -html-encoding-sniffer@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.0.tgz#70b3b69bb5999f35d0d4495d79079f35630e71ae" - integrity sha512-Y9prnPKkM7FXxQevZ5UH8Z6aVTY0ede1tHquck5UxGmKWDshxXh95gSa2xXYjS8AsGO5iOvrCI5+GttRKnLdNA== - dependencies: - whatwg-encoding "^1.0.5" - -http-proxy-agent@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-2.1.0.tgz#e4821beef5b2142a2026bd73926fe537631c5405" - integrity sha512-qwHbBLV7WviBl0rQsOzH6o5lwyOIvwp/BdFnvVxXORldu5TmjFfjzBcWUWS5kWAZhmv+JtiDhSuQCp4sBfbIgg== - dependencies: - agent-base "4" - debug "3.1.0" - -http-signature@~1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" - integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE= - dependencies: - assert-plus "^1.0.0" - jsprim "^1.2.2" - sshpk "^1.7.0" - -https-browserify@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" - integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= - -https-proxy-agent@^2.2.4: - version "2.2.4" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz#4ee7a737abd92678a293d9b34a1af4d0d08c787b" - integrity sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg== - dependencies: - agent-base "^4.3.0" - debug "^3.1.0" - -iconv-lite@0.4.24: - version "0.4.24" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" - integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== - dependencies: - safer-buffer ">= 2.1.2 < 3" - -iconv-lite@^0.4.4: - version "0.4.21" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.21.tgz#c47f8733d02171189ebc4a400f3218d348094798" - integrity sha512-En5V9za5mBt2oUA03WGD3TwDv0MKAruqsuxstbMUZaj9W9k/m1CV/9py3l0L5kw9Bln8fdHQmzHSYtvpvTLpKw== - dependencies: - safer-buffer "^2.1.0" - -icss-utils@^4.0.0, icss-utils@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-4.1.1.tgz#21170b53789ee27447c2f47dd683081403f9a467" - integrity sha512-4aFq7wvWyMHKgxsH8QQtGpvbASCf+eM3wPRLI6R+MgAnTCZ6STYsRvttLvRWK0Nfif5piF394St3HeJDaljGPA== - dependencies: - postcss "^7.0.14" - -ieee754@^1.1.4: - version "1.1.11" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.11.tgz#c16384ffe00f5b7835824e67b6f2bd44a5229455" - integrity sha512-VhDzCKN7K8ufStx/CLj5/PDTMgph+qwN5Pkd5i0sGnVwk56zJ0lkT8Qzi1xqWLS0Wp29DgDtNeS7v8/wMoZeHg== - -iferr@^0.1.5: - version "0.1.5" - resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" - integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE= - -ignore-walk@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8" - integrity sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ== - dependencies: - minimatch "^3.0.4" - -immutable-tuple@^0.4.9: - version "0.4.9" - resolved "https://registry.yarnpkg.com/immutable-tuple/-/immutable-tuple-0.4.9.tgz#473ebdd6c169c461913a454bf87ef8f601a20ff0" - integrity sha512-LWbJPZnidF8eczu7XmcnLBsumuyRBkpwIRPCZxlojouhBo5jEBO4toj6n7hMy6IxHU/c+MqDSWkvaTpPlMQcyA== - -import-local@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/import-local/-/import-local-2.0.0.tgz#55070be38a5993cf18ef6db7e961f5bee5c5a09d" - integrity sha512-b6s04m3O+s3CGSbqDIyP4R6aAwAeYlVq9+WUWep6iHa8ETRf9yei1U48C5MmfJmV9AiLYYBKPMq/W+/WRpQmCQ== - dependencies: - pkg-dir "^3.0.0" - resolve-cwd "^2.0.0" - -imurmurhash@^0.1.4: - version "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= - -infer-owner@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/infer-owner/-/infer-owner-1.0.4.tgz#c4cefcaa8e51051c2a40ba2ce8a3d27295af9467" - integrity sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A== - -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3: - version "2.0.4" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" - integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== - -inherits@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" - integrity sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE= - -inherits@2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" - integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= - -ini@^1.3.4, ini@^1.3.5, ini@~1.3.0: - version "1.3.5" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" - integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== - -interpret@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296" - integrity sha512-mT34yGKMNceBQUoVn7iCDKDntA7SC6gycMAWzGx1z/CMCTV7b2AAtXlo3nRyHZ1FelRkQbQjprHSYGwzLtkVbw== - -invert-kv@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02" - integrity sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA== - -ip-regex@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" - integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk= - -is-accessor-descriptor@^0.1.6: - version "0.1.6" - resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" - integrity sha1-qeEss66Nh2cn7u84Q/igiXtcmNY= - dependencies: - kind-of "^3.0.2" - -is-accessor-descriptor@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656" - integrity sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ== - dependencies: - kind-of "^6.0.0" - -is-binary-path@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" - integrity sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg= - dependencies: - binary-extensions "^1.0.0" - -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" - integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== - dependencies: - binary-extensions "^2.0.0" - -is-buffer@^1.1.5, is-buffer@~1.1.1: - version "1.1.6" - resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" - integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== - -is-buffer@~2.0.3: - version "2.0.4" - resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.4.tgz#3e572f23c8411a5cfd9557c849e3665e0b290623" - integrity sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A== - -is-callable@^1.1.4, is-callable@^1.1.5: - version "1.1.5" - resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.5.tgz#f7e46b596890456db74e7f6e976cb3273d06faab" - integrity sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q== - -is-data-descriptor@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" - integrity sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y= - dependencies: - kind-of "^3.0.2" - -is-data-descriptor@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7" - integrity sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ== - dependencies: - kind-of "^6.0.0" - -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-descriptor@^0.1.0: - version "0.1.6" - resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca" - integrity sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg== - dependencies: - is-accessor-descriptor "^0.1.6" - is-data-descriptor "^0.1.4" - kind-of "^5.0.0" - -is-descriptor@^1.0.0, is-descriptor@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec" - integrity sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg== - dependencies: - is-accessor-descriptor "^1.0.0" - is-data-descriptor "^1.0.0" - kind-of "^6.0.2" - -is-extendable@^0.1.0, is-extendable@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" - integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik= - -is-extendable@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" - integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA== - dependencies: - is-plain-object "^2.0.4" - -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= - -is-fullwidth-code-point@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" - integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs= - dependencies: - number-is-nan "^1.0.0" - -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-glob@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a" - integrity sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo= - dependencies: - is-extglob "^2.1.0" - -is-glob@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.0.tgz#9521c76845cc2610a85203ddf080a958c2ffabc0" - integrity sha1-lSHHaEXMJhCoUgPd8ICpWML/q8A= - dependencies: - is-extglob "^2.1.1" - -is-glob@^4.0.1, is-glob@~4.0.1: - 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-number@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" - integrity sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU= - dependencies: - kind-of "^3.0.2" - -is-number@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff" - integrity sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ== - -is-number@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" - integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== - -is-odd@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-odd/-/is-odd-2.0.0.tgz#7646624671fd7ea558ccd9a2795182f2958f1b24" - integrity sha512-OTiixgpZAT1M4NHgS5IguFp/Vz2VI3U7Goh4/HA1adtwyLtSBrxYlcSYkhpAE07s4fKEcjrFxyvtQBND4vFQyQ== - dependencies: - is-number "^4.0.0" - -is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" - integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== - dependencies: - isobject "^3.0.1" - -is-plain-object@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-3.0.0.tgz#47bfc5da1b5d50d64110806c199359482e75a928" - integrity sha512-tZIpofR+P05k8Aocp7UI/2UTa9lTJSebCXpFFoR9aibpokDj/uXBsJ8luUu0tTVYKkMU6URDUuOfJZ7koewXvg== - dependencies: - isobject "^4.0.0" - -is-potential-custom-element-name@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.0.tgz#0c52e54bcca391bb2c494b21e8626d7336c6e397" - integrity sha1-DFLlS8yjkbssSUsh6GJtczbG45c= - -is-regex@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.5.tgz#39d589a358bf18967f726967120b8fc1aed74eae" - integrity sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ== - dependencies: - has "^1.0.3" - -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-symbol@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937" - integrity sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ== - dependencies: - has-symbols "^1.0.1" - -is-typedarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" - integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= - -is-windows@^1.0.1, is-windows@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" - integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== - -is-wsl@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" - integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0= - -isarray@0.0.1: - version "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@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" - integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= - -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= - -isobject@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" - integrity sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk= - dependencies: - isarray "1.0.0" - -isobject@^3.0.0, isobject@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" - integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= - -isobject@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/isobject/-/isobject-4.0.0.tgz#3f1c9155e73b192022a80819bacd0343711697b0" - integrity sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA== - -isstream@~0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" - integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= - -iterall@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.2.2.tgz#92d70deb8028e0c39ff3164fdbf4d8b088130cd7" - integrity sha512-yynBb1g+RFUPY64fTrFv7nsjRrENBQJaX2UL+2Szc9REFrSNm1rpSXHGzhmAy7a9uv3vlvgBlXnf9RqmPH1/DA== - -js-base64@^2.5.2: - version "2.5.2" - resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.5.2.tgz#313b6274dda718f714d00b3330bbae6e38e90209" - integrity sha512-Vg8czh0Q7sFBSUMWWArX/miJeBWYBPpdU/3M/DKSaekLMqrqVPaedp+5mZhie/r0lgrcaYBfwXatEew6gwgiQQ== - -"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@3.13.1, js-yaml@^3.13.1: - version "3.13.1" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847" - integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw== - dependencies: - argparse "^1.0.7" - esprima "^4.0.0" - -jsbn@~0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" - integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= - -jsdom-global@3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/jsdom-global/-/jsdom-global-3.0.2.tgz#6bd299c13b0c4626b2da2c0393cd4385d606acb9" - integrity sha1-a9KZwTsMRiay2iwDk81DhdYGrLk= - -jsdom@16.2.0: - version "16.2.0" - resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-16.2.0.tgz#fc171c305635046c100708b1c7ea7820ba015164" - integrity sha512-6VaW3UWyKbm9DFVIAgTfhuwnvqiqlRYNg5Rk6dINTVoZT0eKz+N86vQZr+nqt1ny1lSB1TWZJWSEWQAfu8oTpA== - dependencies: - abab "^2.0.3" - acorn "^7.1.0" - acorn-globals "^4.3.4" - cssom "^0.4.4" - cssstyle "^2.2.0" - data-urls "^2.0.0" - decimal.js "^10.2.0" - domexception "^2.0.1" - escodegen "^1.13.0" - html-encoding-sniffer "^2.0.0" - is-potential-custom-element-name "^1.0.0" - nwsapi "^2.2.0" - parse5 "5.1.1" - request "^2.88.0" - request-promise-native "^1.0.8" - saxes "^4.0.2" - symbol-tree "^3.2.4" - tough-cookie "^3.0.1" - w3c-hr-time "^1.0.1" - w3c-xmlserializer "^2.0.0" - webidl-conversions "^5.0.0" - whatwg-encoding "^1.0.5" - whatwg-mimetype "^2.3.0" - whatwg-url "^8.0.0" - ws "^7.2.1" - xml-name-validator "^3.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-schema-traverse@^0.3.0: - version "0.3.1" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340" - integrity sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A= - -json-schema-traverse@^0.4.1: - version "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@0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" - integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= - -json-stringify-safe@~5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" - integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= - -json5@^0.5.0: - version "0.5.1" - resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821" - integrity sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE= - -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== - dependencies: - minimist "^1.2.0" - -jsprim@^1.2.2: - version "1.4.1" - resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" - integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI= - dependencies: - assert-plus "1.0.0" - extsprintf "1.3.0" - json-schema "0.2.3" - verror "1.10.0" - -just-extend@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.0.2.tgz#f3f47f7dfca0f989c55410a7ebc8854b07108afc" - integrity sha512-FrLwOgm+iXrPV+5zDU6Jqu4gCRXbWEQg2O3SKONsWE4w7AXFRkryS53bpWdaL9cNol+AmR3AEYz6kn+o0fCPnw== - -kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: - version "3.2.2" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" - integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ= - dependencies: - is-buffer "^1.1.5" - -kind-of@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57" - integrity sha1-IIE989cSkosgc3hpGkUGb65y3Vc= - dependencies: - is-buffer "^1.1.5" - -kind-of@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d" - integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw== - -kind-of@^6.0.0, kind-of@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051" - integrity sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA== - -lcid@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/lcid/-/lcid-2.0.0.tgz#6ef5d2df60e52f82eb228a4c373e8d1f397253cf" - integrity sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA== - dependencies: - invert-kv "^2.0.0" - -levn@~0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" - integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4= - dependencies: - prelude-ls "~1.1.2" - type-check "~0.3.2" - -loader-runner@^2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.4.0.tgz#ed47066bfe534d7e84c4c7b9998c2a75607d9357" - integrity sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw== - -loader-utils@1.2.3, loader-utils@^1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.2.3.tgz#1ff5dc6911c9f0a062531a4c04b609406108c2c7" - integrity sha512-fkpz8ejdnEMG3s37wGL07iSBDg99O9D5yflE9RGNH3hRdx9SOwYfnGYdZOUIZitN8E+E2vkq3MUMYMvPYl5ZZA== - dependencies: - big.js "^5.2.2" - emojis-list "^2.0.0" - json5 "^1.0.1" - -loader-utils@^1.0.2, loader-utils@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd" - integrity sha1-yYrvSIvM7aL/teLeZG1qdUQp9c0= - dependencies: - big.js "^3.1.3" - emojis-list "^2.0.0" - json5 "^0.5.0" - -locate-path@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" - integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== - dependencies: - p-locate "^3.0.0" - path-exists "^3.0.0" - -lodash.get@^4.4.2: - version "4.4.2" - resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" - integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= - -lodash.sortby@^4.7.0: - version "4.7.0" - resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" - integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= - -lodash.toarray@^4.4.0: - version "4.4.0" - resolved "https://registry.yarnpkg.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz#24c4bfcd6b2fba38bfd0594db1179d8e9b656561" - integrity sha1-JMS/zWsvuji/0FlNsRedjptlZWE= - -lodash@^4.16.4, lodash@^4.17.15: - version "4.17.19" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" - integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== - -log-symbols@2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a" - integrity sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg== - dependencies: - chalk "^2.0.1" - -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" - integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== - dependencies: - js-tokens "^3.0.0 || ^4.0.0" - -lru-cache@^4.0.1: - version "4.1.3" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.3.tgz#a1175cf3496dfc8436c156c334b4955992bce69c" - integrity sha512-fFEhvcgzuIoJVUF8fYr5KR0YqxD238zgObTps31YdADwPPAp82a4M8TrckkWyx7ekNlf9aBcVn81cFwwXngrJA== - dependencies: - pseudomap "^1.0.2" - yallist "^2.1.2" - -lru-cache@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" - integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== - dependencies: - yallist "^3.0.2" - -make-dir@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5" - integrity sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA== - dependencies: - pify "^4.0.1" - semver "^5.6.0" - -mamacro@^0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/mamacro/-/mamacro-0.0.3.tgz#ad2c9576197c9f1abf308d0787865bd975a3f3e4" - integrity sha512-qMEwh+UujcQ+kbz3T6V+wAmO2U8veoq2w+3wY8MquqwVA3jChfwY+Tk52GZKDfACEPjuZ7r2oJLejwpt8jtwTA== - -map-age-cleaner@^0.1.1: - version "0.1.3" - resolved "https://registry.yarnpkg.com/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz#7d583a7306434c055fe474b0f45078e6e1b4b92a" - integrity sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w== - dependencies: - p-defer "^1.0.0" - -map-cache@^0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" - integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8= - -map-visit@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f" - integrity sha1-7Nyo8TFE5mDxtb1B8S80edmN+48= - dependencies: - object-visit "^1.0.0" - -marked@^0.8.0: - version "0.8.0" - resolved "https://registry.yarnpkg.com/marked/-/marked-0.8.0.tgz#ec5c0c9b93878dc52dd54be8d0e524097bd81a99" - integrity sha512-MyUe+T/Pw4TZufHkzAfDj6HarCBWia2y27/bhuYkTaiUnfDYFnCP3KUN+9oM7Wi6JA2rymtVYbQu3spE0GCmxQ== - -md5.js@^1.3.4: - version "1.3.4" - resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.4.tgz#e9bdbde94a20a5ac18b04340fc5764d5b09d901d" - integrity sha1-6b296UogpawYsENA/Fdk1bCdkB0= - dependencies: - hash-base "^3.0.0" - inherits "^2.0.1" - -md5@^2.1.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/md5/-/md5-2.2.1.tgz#53ab38d5fe3c8891ba465329ea23fac0540126f9" - integrity sha1-U6s41f48iJG6RlMp6iP6wFQBJvk= - dependencies: - charenc "~0.0.1" - crypt "~0.0.1" - is-buffer "~1.1.1" - -mem@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/mem/-/mem-4.0.0.tgz#6437690d9471678f6cc83659c00cbafcd6b0cdaf" - integrity sha512-WQxG/5xYc3tMbYLXoXPm81ET2WDULiU5FxbuIoNbJqLOOI8zehXFdZuiUEgfdrU2mVB1pxBZUGlYORSrpuJreA== - dependencies: - map-age-cleaner "^0.1.1" - mimic-fn "^1.0.0" - p-is-promise "^1.1.0" - -memory-fs@^0.4.0, memory-fs@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552" - integrity sha1-OpoguEYlI+RHz7x+i7gO1me/xVI= - dependencies: - errno "^0.1.3" - readable-stream "^2.0.1" - -micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4: - version "3.1.10" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" - integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== - dependencies: - arr-diff "^4.0.0" - array-unique "^0.3.2" - braces "^2.3.1" - define-property "^2.0.2" - extend-shallow "^3.0.2" - extglob "^2.0.4" - fragment-cache "^0.2.1" - kind-of "^6.0.2" - nanomatch "^1.2.9" - object.pick "^1.3.0" - regex-not "^1.0.0" - snapdragon "^0.8.1" - to-regex "^3.0.2" - -micromatch@^4.0.0: - version "4.0.2" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259" - integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q== - dependencies: - braces "^3.0.1" - picomatch "^2.0.5" - -miller-rabin@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d" - integrity sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA== - dependencies: - bn.js "^4.0.0" - brorand "^1.0.1" - -mime-db@~1.35.0: - version "1.35.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.35.0.tgz#0569d657466491283709663ad379a99b90d9ab47" - integrity sha512-JWT/IcCTsB0Io3AhWUMjRqucrHSPsSf2xKLaRldJVULioggvkJvggZ3VXNNSRkCddE6D+BUI4HEIZIA2OjwIvg== - -mime-types@^2.1.12, mime-types@~2.1.19: - version "2.1.19" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.19.tgz#71e464537a7ef81c15f2db9d97e913fc0ff606f0" - integrity sha512-P1tKYHVSZ6uFo26mtnve4HQFE3koh1UWVkp8YUC+ESBHe945xWSoXuHHiGarDqcEZ+whpCDnlNw5LON0kLo+sw== - dependencies: - mime-db "~1.35.0" - -mimic-fn@^1.0.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" - integrity sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ== - -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" - integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== - -minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: - version "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.2, 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== - dependencies: - brace-expansion "^1.1.7" - -minimist@0.0.8: - version "0.0.8" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" - integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= - -minimist@^1.2.0, minimist@^1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.3.tgz#3db5c0765545ab8637be71f333a104a965a9ca3f" - integrity sha512-+bMdgqjMN/Z77a6NlY/I3U5LlRDbnmaAk6lDveAPKwSpcPM4tKAuYsvYF8xjhOPXhOYGe/73vVLVez5PW+jqhw== - -minipass@^2.2.1: - version "2.2.4" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.2.4.tgz#03c824d84551ec38a8d1bb5bc350a5a30a354a40" - integrity sha512-hzXIWWet/BzWhYs2b+u7dRHlruXhwdgvlTMDKC6Cb1U7ps6Ac6yQlR39xsbjWJE377YTCtKwIXIpJ5oP+j5y8g== - dependencies: - safe-buffer "^5.1.1" - yallist "^3.0.0" - -minipass@^2.3.5: - version "2.3.5" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.3.5.tgz#cacebe492022497f656b0f0f51e2682a9ed2d848" - integrity sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA== - dependencies: - safe-buffer "^5.1.2" - yallist "^3.0.0" - -minizlib@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.2.1.tgz#dd27ea6136243c7c880684e8672bb3a45fd9b614" - integrity sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA== - dependencies: - minipass "^2.2.1" - -mississippi@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-3.0.0.tgz#ea0a3291f97e0b5e8776b363d5f0a12d94c67022" - integrity sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA== - dependencies: - concat-stream "^1.5.0" - duplexify "^3.4.2" - end-of-stream "^1.1.0" - flush-write-stream "^1.0.0" - from2 "^2.1.0" - parallel-transform "^1.1.0" - pump "^3.0.0" - pumpify "^1.3.3" - stream-each "^1.1.0" - through2 "^2.0.0" - -mixin-deep@^1.2.0: - version "1.3.2" - resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" - integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA== - dependencies: - for-in "^1.0.2" - is-extendable "^1.0.1" - -mkdirp@0.5.1, mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" - integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM= - dependencies: - minimist "0.0.8" - -mocha-junit-reporter@1.23.0: - version "1.23.0" - resolved "https://registry.yarnpkg.com/mocha-junit-reporter/-/mocha-junit-reporter-1.23.0.tgz#c5ad7f10b5aa9a7cc6e169b6bf15baf2700266ca" - integrity sha512-pmpnEO4iDTmLfrT2RKqPsc5relG4crnDSGmXPuGogdda27A7kLujDNJV4EbTbXlVBCZXggN9rQYPEWMkOv4AAA== - dependencies: - debug "^2.2.0" - md5 "^2.1.0" - mkdirp "~0.5.1" - strip-ansi "^4.0.0" - xml "^1.0.0" - -mocha-multi-reporters@1.1.7: - version "1.1.7" - resolved "https://registry.yarnpkg.com/mocha-multi-reporters/-/mocha-multi-reporters-1.1.7.tgz#cc7f3f4d32f478520941d852abb64d9988587d82" - integrity sha1-zH8/TTL0eFIJQdhSq7ZNmYhYfYI= - dependencies: - debug "^3.1.0" - lodash "^4.16.4" - -mocha@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/mocha/-/mocha-7.0.1.tgz#276186d35a4852f6249808c6dd4a1376cbf6c6ce" - integrity sha512-9eWmWTdHLXh72rGrdZjNbG3aa1/3NRPpul1z0D979QpEnFdCG0Q5tv834N+94QEN2cysfV72YocQ3fn87s70fg== - dependencies: - ansi-colors "3.2.3" - browser-stdout "1.3.1" - chokidar "3.3.0" - debug "3.2.6" - diff "3.5.0" - escape-string-regexp "1.0.5" - find-up "3.0.0" - glob "7.1.3" - growl "1.10.5" - he "1.2.0" - js-yaml "3.13.1" - log-symbols "2.2.0" - minimatch "3.0.4" - mkdirp "0.5.1" - ms "2.1.1" - node-environment-flags "1.0.6" - object.assign "4.1.0" - strip-json-comments "2.0.1" - supports-color "6.0.0" - which "1.3.1" - wide-align "1.1.3" - yargs "13.3.0" - yargs-parser "13.1.1" - yargs-unparser "1.6.0" - -moment@^2.22.1: - version "2.22.2" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66" - integrity sha1-PCV/mDn8DpP/UxSWMiOeuQeD/2Y= - -move-concurrently@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92" - integrity sha1-viwAX9oy4LKa8fBdfEszIUxwH5I= - dependencies: - aproba "^1.1.1" - copy-concurrently "^1.0.0" - fs-write-stream-atomic "^1.0.8" - mkdirp "^0.5.1" - rimraf "^2.5.4" - run-queue "^1.0.3" - -ms@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= - -ms@2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" - integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== - -ms@^2.1.1: - version "2.1.2" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" - integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== - -nan@^2.9.2: - version "2.10.0" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.10.0.tgz#96d0cd610ebd58d4b4de9cc0c6828cda99c7548f" - integrity sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA== - -nanomatch@^1.2.9: - version "1.2.9" - resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.9.tgz#879f7150cb2dab7a471259066c104eee6e0fa7c2" - integrity sha512-n8R9bS8yQ6eSXaV6jHUpKzD8gLsin02w1HSFiegwrs9E098Ylhw5jdyKPaYqvHknHaSCKTPp7C8dGCQ0q9koXA== - dependencies: - arr-diff "^4.0.0" - array-unique "^0.3.2" - define-property "^2.0.2" - extend-shallow "^3.0.2" - fragment-cache "^0.2.1" - is-odd "^2.0.0" - is-windows "^1.0.2" - kind-of "^6.0.2" - object.pick "^1.3.0" - regex-not "^1.0.0" - snapdragon "^0.8.1" - to-regex "^3.0.1" - -needle@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/needle/-/needle-2.2.0.tgz#f14efc69cee1024b72c8b21c7bdf94a731dc12fa" - integrity sha512-eFagy6c+TYayorXw/qtAdSvaUpEbBsDwDyxYFgLZ0lTojfH7K+OdBqAF7TAFwDokJaGpubpSGG0wO3iC0XPi8w== - dependencies: - debug "^2.1.2" - iconv-lite "^0.4.4" - sax "^1.2.4" - -neo-async@^2.5.0: - version "2.5.1" - resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.5.1.tgz#acb909e327b1e87ec9ef15f41b8a269512ad41ee" - integrity sha512-3KL3fvuRkZ7s4IFOMfztb7zJp3QaVWnBeGoJlgB38XnCRPj/0tLzzLG5IB8NYOHbJ8g8UGrgZv44GLDk6CxTxA== - -neo-async@^2.6.1: - version "2.6.1" - resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c" - integrity sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw== - -nice-try@^1.0.4: - version "1.0.5" - resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" - integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== - -nise@^4.0.1: - version "4.0.2" - resolved "https://registry.yarnpkg.com/nise/-/nise-4.0.2.tgz#727167d9392446a0238b28183b374600ddca42f4" - integrity sha512-ALDnm0pTTyeGdbg5FCpWGd58Nmp3qO8d8x+dU2Fw8lApeJTEBSjkBZZM4S8t6GpKh+czxkfM/TKxpRMroZzwOg== - dependencies: - "@sinonjs/commons" "^1.7.0" - "@sinonjs/fake-timers" "^6.0.0" - "@sinonjs/formatio" "^5.0.1" - "@sinonjs/text-encoding" "^0.7.1" - just-extend "^4.0.2" - path-to-regexp "^1.7.0" - -node-emoji@^1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.8.1.tgz#6eec6bfb07421e2148c75c6bba72421f8530a826" - integrity sha512-+ktMAh1Jwas+TnGodfCfjUbJKoANqPaJFN0z0iqh41eqD8dvguNzcitVSBSVK1pidz0AqGbLKcoVuVLRVZ/aVg== - dependencies: - lodash.toarray "^4.4.0" - -node-environment-flags@1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/node-environment-flags/-/node-environment-flags-1.0.6.tgz#a30ac13621f6f7d674260a54dede048c3982c088" - integrity sha512-5Evy2epuL+6TM0lCQGpFIj6KwiEsGh1SrHUhTbNX+sLbBtjidPZFAnVK9y5yU1+h//RitLbRHTIMyxQPtxMdHw== - dependencies: - object.getownpropertydescriptors "^2.0.3" - semver "^5.7.0" - -node-fetch@3.0.0-beta.7: - version "3.0.0-beta.7" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.0.0-beta.7.tgz#28e122943aa8a3e4e7447d525fc138c207faa834" - integrity sha512-UTmmxR2RCLiGL0q61p8DgMgw1UXd10+XVB77IHG55flJ/tHqQQXloNTm5dd/mB3RNXP3+CJPf++t0nb3whKNkw== - dependencies: - data-uri-to-buffer "^3.0.1" - fetch-blob "^2.0.0" - -node-fetch@^2.3.0: - version "2.6.0" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd" - integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA== - -node-libs-browser@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.2.1.tgz#b64f513d18338625f90346d27b0d235e631f6425" - integrity sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q== - dependencies: - assert "^1.1.1" - browserify-zlib "^0.2.0" - buffer "^4.3.0" - console-browserify "^1.1.0" - constants-browserify "^1.0.0" - crypto-browserify "^3.11.0" - domain-browser "^1.1.1" - events "^3.0.0" - https-browserify "^1.0.0" - os-browserify "^0.3.0" - path-browserify "0.0.1" - process "^0.11.10" - punycode "^1.2.4" - querystring-es3 "^0.2.0" - readable-stream "^2.3.3" - stream-browserify "^2.0.1" - stream-http "^2.7.2" - string_decoder "^1.0.0" - timers-browserify "^2.0.4" - tty-browserify "0.0.0" - url "^0.11.0" - util "^0.11.0" - vm-browserify "^1.0.1" - -node-pre-gyp@^0.10.0: - version "0.10.0" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.10.0.tgz#6e4ef5bb5c5203c6552448828c852c40111aac46" - integrity sha512-G7kEonQLRbcA/mOoFoxvlMrw6Q6dPf92+t/l0DFSMuSlDoWaI9JWIyPwK0jyE1bph//CUEL65/Fz1m2vJbmjQQ== - dependencies: - detect-libc "^1.0.2" - mkdirp "^0.5.1" - needle "^2.2.0" - nopt "^4.0.1" - npm-packlist "^1.1.6" - npmlog "^4.0.2" - rc "^1.1.7" - rimraf "^2.6.1" - semver "^5.3.0" - tar "^4" - -nopt@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" - integrity sha1-0NRoWv1UFRk8jHUFYC0NF81kR00= - dependencies: - abbrev "1" - osenv "^0.1.4" - -normalize-path@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" - integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk= - dependencies: - remove-trailing-separator "^1.0.1" - -normalize-path@^3.0.0, normalize-path@~3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" - integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== - -npm-bundled@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.3.tgz#7e71703d973af3370a9591bafe3a63aca0be2308" - integrity sha512-ByQ3oJ/5ETLyglU2+8dBObvhfWXX8dtPZDMePCahptliFX2iIuhyEszyFk401PZUNQH20vvdW5MLjJxkwU80Ow== - -npm-packlist@^1.1.6: - version "1.1.10" - resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.1.10.tgz#1039db9e985727e464df066f4cf0ab6ef85c398a" - integrity sha512-AQC0Dyhzn4EiYEfIUjCdMl0JJ61I2ER9ukf/sLxJUcZHfo+VyEfz2rMJgLZSS1v30OxPQe1cN0LZA1xbcaVfWA== - dependencies: - ignore-walk "^3.0.1" - npm-bundled "^1.0.1" - -npm-run-path@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" - integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8= - dependencies: - path-key "^2.0.0" - -npmlog@^4.0.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" - integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== - dependencies: - are-we-there-yet "~1.1.2" - console-control-strings "~1.1.0" - gauge "~2.7.3" - set-blocking "~2.0.0" - -number-is-nan@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" - integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= - -nwsapi@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.0.tgz#204879a9e3d068ff2a55139c2c772780681a38b7" - integrity sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ== - -oauth-sign@~0.9.0: - version "0.9.0" - resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" - integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== - -object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" - integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= - -object-copy@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" - integrity sha1-fn2Fi3gb18mRpBupde04EnVOmYw= - dependencies: - copy-descriptor "^0.1.0" - define-property "^0.2.5" - kind-of "^3.0.3" - -object-inspect@^1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.7.0.tgz#f4f6bd181ad77f006b5ece60bd0b6f398ff74a67" - integrity sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw== - -object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" - integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== - -object-visit@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" - integrity sha1-95xEk68MU3e1n+OdOV5BBC3QRbs= - dependencies: - isobject "^3.0.0" - -object.assign@4.1.0, object.assign@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da" - integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w== - dependencies: - define-properties "^1.1.2" - function-bind "^1.1.1" - has-symbols "^1.0.0" - object-keys "^1.0.11" - -object.getownpropertydescriptors@^2.0.3: - version "2.1.0" - resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz#369bf1f9592d8ab89d712dced5cb81c7c5352649" - integrity sha512-Z53Oah9A3TdLoblT7VKJaTDdXdT+lQO+cNpKVnya5JDe9uLvzu1YyY1yFDFrcxrlRgWrEFH0jJtD/IbuwjcEVg== - dependencies: - define-properties "^1.1.3" - es-abstract "^1.17.0-next.1" - -object.pick@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" - integrity sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c= - dependencies: - isobject "^3.0.1" - -once@^1.3.0, once@^1.3.1, once@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= - dependencies: - wrappy "1" - -optimism@^0.6.6: - version "0.6.8" - resolved "https://registry.yarnpkg.com/optimism/-/optimism-0.6.8.tgz#0780b546da8cd0a72e5207e0c3706c990c8673a6" - integrity sha512-bN5n1KCxSqwBDnmgDnzMtQTHdL+uea2HYFx1smvtE+w2AMl0Uy31g0aXnP/Nt85OINnMJPRpJyfRQLTCqn5Weg== - dependencies: - immutable-tuple "^0.4.9" - -optionator@^0.8.1: - version "0.8.2" - resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz#364c5e409d3f4d6301d6c0b4c05bba50180aeb64" - integrity sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q= - dependencies: - deep-is "~0.1.3" - fast-levenshtein "~2.0.4" - levn "~0.3.0" - prelude-ls "~1.1.2" - type-check "~0.3.2" - wordwrap "~1.0.0" - -os-browserify@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" - integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc= - -os-homedir@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" - integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= - -os-locale@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-3.1.0.tgz#a802a6ee17f24c10483ab9935719cef4ed16bf1a" - integrity sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q== - dependencies: - execa "^1.0.0" - lcid "^2.0.0" - mem "^4.0.0" - -os-tmpdir@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" - integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= - -osenv@^0.1.4: - version "0.1.5" - resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410" - integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g== - dependencies: - os-homedir "^1.0.0" - os-tmpdir "^1.0.0" - -p-defer@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c" - integrity sha1-n26xgvbJqozXQwBKfU+WsZaw+ww= - -p-finally@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" - integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= - -p-is-promise@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-1.1.0.tgz#9c9456989e9f6588017b0434d56097675c3da05e" - integrity sha1-nJRWmJ6fZYgBewQ01WCXZ1w9oF4= - -p-limit@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.1.0.tgz#1d5a0d20fb12707c758a655f6bbc4386b5930d68" - integrity sha512-NhURkNcrVB+8hNfLuysU8enY5xn2KXphsHBaC2YmRNTZRc7RWusw6apSpdEj3jo4CMb6W9nrF6tTnsJsJeyu6g== - dependencies: - p-try "^2.0.0" - -p-locate@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" - integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== - dependencies: - p-limit "^2.0.0" - -p-try@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.0.0.tgz#85080bb87c64688fa47996fe8f7dfbe8211760b1" - integrity sha512-hMp0onDKIajHfIkdRk3P4CdCmErkYAxxDtP3Wx/4nZ3aGlau2VKh3mZpcuFkH27WQkL/3WBCPOktzA9ZOAnMQQ== - -pako@~1.0.5: - version "1.0.6" - resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.6.tgz#0101211baa70c4bca4a0f63f2206e97b7dfaf258" - integrity sha512-lQe48YPsMJAig+yngZ87Lus+NF+3mtu7DVOBu6b/gHO1YpKwIj5AWjZ/TOS7i46HD/UixzWb1zeWDZfGZ3iYcg== - -parallel-transform@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.1.0.tgz#d410f065b05da23081fcd10f28854c29bda33b06" - integrity sha1-1BDwZbBdojCB/NEPKIVMKb2jOwY= - dependencies: - cyclist "~0.2.2" - inherits "^2.0.3" - readable-stream "^2.1.5" - -parse-asn1@^5.0.0: - version "5.1.1" - resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.1.tgz#f6bf293818332bd0dab54efb16087724745e6ca8" - integrity sha512-KPx7flKXg775zZpnp9SxJlz00gTd4BmJ2yJufSc44gMCRrRQ7NSzAcSJQfifuOLgW6bEi+ftrALtsgALeB2Adw== - dependencies: - asn1.js "^4.0.0" - browserify-aes "^1.0.0" - create-hash "^1.1.0" - evp_bytestokey "^1.0.0" - pbkdf2 "^3.0.3" - -parse-passwd@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" - integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY= - -parse5@5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178" - integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug== - -pascalcase@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" - integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= - -path-browserify@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.1.tgz#e6c4ddd7ed3aa27c68a20cc4e50e1a4ee83bbc4a" - integrity sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ== - -path-dirname@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" - integrity sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA= - -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-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= - -path-key@^2.0.0, path-key@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" - integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= - -path-parse@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.5.tgz#3c1adf871ea9cd6c9431b6ea2bd74a0ff055c4c1" - integrity sha1-PBrfhx6pzWyUMbbqK9dKD/BVxME= - -path-to-regexp@^1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.7.0.tgz#59fde0f435badacba103a84e9d3bc64e96b9937d" - integrity sha1-Wf3g9DW62suhA6hOnTvGTpa5k30= - dependencies: - isarray "0.0.1" - -pbkdf2@^3.0.3: - version "3.0.16" - resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.16.tgz#7404208ec6b01b62d85bf83853a8064f8d9c2a5c" - integrity sha512-y4CXP3thSxqf7c0qmOF+9UeOTrifiVTIM+u7NWlq+PRsHbr7r7dpCmvzrZxa96JJUNi0Y5w9VqG5ZNeCVMoDcA== - 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" - -performance-now@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" - integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= - -picomatch@^2.0.4: - version "2.2.1" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.1.tgz#21bac888b6ed8601f831ce7816e335bc779f0a4a" - integrity sha512-ISBaA8xQNmwELC7eOjqFKMESB2VIqt4PPDD0nsS95b/9dZXvVKOlz9keMSnoGGKcOHXfTvDD6WMaRoSc9UuhRA== - -picomatch@^2.0.5: - version "2.0.7" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.0.7.tgz#514169d8c7cd0bdbeecc8a2609e34a7163de69f6" - integrity sha512-oLHIdio3tZ0qH76NybpeneBhYVj0QFTfXEFTc/B3zKQspYfYYkWYgFsmzo+4kvId/bQRcNkVeguI3y+CD22BtA== - -pify@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231" - integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== - -pkg-dir@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-3.0.0.tgz#2749020f239ed990881b1f71210d51eb6523bea3" - integrity sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw== - dependencies: - find-up "^3.0.0" - -posix-character-classes@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" - integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= - -postcss-modules-extract-imports@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-2.0.0.tgz#818719a1ae1da325f9832446b01136eeb493cd7e" - integrity sha512-LaYLDNS4SG8Q5WAWqIJgdHPJrDDr/Lv775rMBFUbgjTz6j34lUznACHcdRWroPvXANP2Vj7yNK57vp9eFqzLWQ== - dependencies: - postcss "^7.0.5" - -postcss-modules-local-by-default@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-3.0.2.tgz#e8a6561be914aaf3c052876377524ca90dbb7915" - integrity sha512-jM/V8eqM4oJ/22j0gx4jrp63GSvDH6v86OqyTHHUvk4/k1vceipZsaymiZ5PvocqZOl5SFHiFJqjs3la0wnfIQ== - dependencies: - icss-utils "^4.1.1" - postcss "^7.0.16" - postcss-selector-parser "^6.0.2" - postcss-value-parser "^4.0.0" - -postcss-modules-scope@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-2.1.1.tgz#33d4fc946602eb5e9355c4165d68a10727689dba" - integrity sha512-OXRUPecnHCg8b9xWvldG/jUpRIGPNRka0r4D4j0ESUU2/5IOnpsjfPPmDprM3Ih8CgZ8FXjWqaniK5v4rWt3oQ== - dependencies: - postcss "^7.0.6" - postcss-selector-parser "^6.0.0" - -postcss-modules-values@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-3.0.0.tgz#5b5000d6ebae29b4255301b4a3a54574423e7f10" - integrity sha512-1//E5jCBrZ9DmRX+zCtmQtRSV6PV42Ix7Bzj9GbwJceduuf7IqP8MgeTXuRDHOWj2m0VzZD5+roFWDuU8RQjcg== - dependencies: - icss-utils "^4.0.0" - postcss "^7.0.6" - -postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.2.tgz#934cf799d016c83411859e09dcecade01286ec5c" - integrity sha512-36P2QR59jDTOAiIkqEprfJDsoNrvwFei3eCqKd1Y0tUsBimsq39BLp7RD+JWny3WgB1zGhJX8XVePwm9k4wdBg== - dependencies: - cssesc "^3.0.0" - indexes-of "^1.0.1" - uniq "^1.0.1" - -postcss-value-parser@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.0.0.tgz#99a983d365f7b2ad8d0f9b8c3094926eab4b936d" - integrity sha512-ESPktioptiSUchCKgggAkzdmkgzKfmp0EU8jXH+5kbIUB+unr0Y4CY9SRMvibuvYUBjNh1ACLbxqYNpdTQOteQ== - -postcss-value-parser@^4.0.2: - version "4.0.3" - resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.0.3.tgz#651ff4593aa9eda8d5d0d66593a2417aeaeb325d" - integrity sha512-N7h4pG+Nnu5BEIzyeaaIYWs0LI5XC40OrRh5L60z0QjFsqGWcHcbkBvpe1WYpcIS9yQ8sOi/vIPt1ejQCrMVrg== - -postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.5, postcss@^7.0.6: - version "7.0.17" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.17.tgz#4da1bdff5322d4a0acaab4d87f3e782436bad31f" - integrity sha512-546ZowA+KZ3OasvQZHsbuEpysvwTZNGJv9EfyCQdsIDltPSWHAeTQ5fQy/Npi2ZDtLI3zs7Ps/p6wThErhm9fQ== - dependencies: - chalk "^2.4.2" - source-map "^0.6.1" - supports-color "^6.1.0" - -postcss@^7.0.23: - version "7.0.27" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.27.tgz#cc67cdc6b0daa375105b7c424a85567345fc54d9" - integrity sha512-WuQETPMcW9Uf1/22HWUWP9lgsIC+KEHg2kozMflKjbeUtw9ujvFX6QmIfozaErDkmLWS9WEnEdEe6Uo9/BNTdQ== - dependencies: - chalk "^2.4.2" - source-map "^0.6.1" - supports-color "^6.1.0" - -prelude-ls@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" - integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= - -pretty-format@^24.7.0: - version "24.9.0" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-24.9.0.tgz#12fac31b37019a4eea3c11aa9a959eb7628aa7c9" - integrity sha512-00ZMZUiHaJrNfk33guavqgvfJS30sLYf0f8+Srklv0AMPodGGHcoHgksZ3OThYnIvOd+8yMCn0YiEOogjlgsnA== - dependencies: - "@jest/types" "^24.9.0" - ansi-regex "^4.0.0" - ansi-styles "^3.2.0" - react-is "^16.8.4" - -process-nextick-args@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz#a37d732f4271b4ab1ad070d35508e8290788ffaa" - integrity sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw== - -process@^0.11.10: - version "0.11.10" - resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" - integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= - -promise-inflight@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" - integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= - -prop-types@^15.6.2: - version "15.7.2" - resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" - integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== - dependencies: - loose-envify "^1.4.0" - 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= - -pseudomap@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" - integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= - -psl@^1.1.24: - version "1.1.29" - resolved "https://registry.yarnpkg.com/psl/-/psl-1.1.29.tgz#60f580d360170bb722a797cc704411e6da850c67" - integrity sha512-AeUmQ0oLN02flVHXWh9sSJF7mcdFq0ppid/JkErufc3hGIV/AMa8Fo9VgDo/cT2jFdOWoFvHp90qqBH54W+gjQ== - -psl@^1.1.28: - version "1.1.32" - resolved "https://registry.yarnpkg.com/psl/-/psl-1.1.32.tgz#3f132717cf2f9c169724b2b6caf373cf694198db" - integrity sha512-MHACAkHpihU/REGGPLj4sEfc/XKW2bheigvHO1dUqjaKigMp1C8+WLQYRGgeKFMsw5PMfegZcaN8IDXK/cD0+g== - -public-encrypt@^4.0.0: - version "4.0.2" - resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.2.tgz#46eb9107206bf73489f8b85b69d91334c6610994" - integrity sha512-4kJ5Esocg8X3h8YgJsKAuoesBgB7mqH3eowiDzMUPKiRDDE7E/BqqZD1hnTByIaAFiwAw246YEltSq7tdrOH0Q== - dependencies: - bn.js "^4.1.0" - browserify-rsa "^4.0.0" - create-hash "^1.1.0" - parse-asn1 "^5.0.0" - randombytes "^2.0.1" - -pump@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909" - integrity sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA== - dependencies: - end-of-stream "^1.1.0" - once "^1.3.1" - -pump@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" - integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== - dependencies: - end-of-stream "^1.1.0" - once "^1.3.1" - -pumpify@^1.3.3: - version "1.5.1" - resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.5.1.tgz#36513be246ab27570b1a374a5ce278bfd74370ce" - integrity sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ== - dependencies: - duplexify "^3.6.0" - inherits "^2.0.3" - pump "^2.0.0" - -punycode@1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" - integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= - -punycode@^1.2.4, punycode@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" - integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= - -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== - -qs@~6.5.2: - version "6.5.2" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" - integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== - -query-string@^6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.2.0.tgz#468edeb542b7e0538f9f9b1aeb26f034f19c86e1" - integrity sha512-5wupExkIt8RYL4h/FE+WTg3JHk62e6fFPWtAZA9J5IWK1PfTfKkMS93HBUHcFpeYi9KsY5pFbh+ldvEyaz5MyA== - dependencies: - decode-uri-component "^0.2.0" - strict-uri-encode "^2.0.0" - -querystring-es3@^0.2.0: - version "0.2.1" - resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" - integrity sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM= - -querystring@0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" - integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= - -randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5: - version "2.0.6" - resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.0.6.tgz#d302c522948588848a8d300c932b44c24231da80" - integrity sha512-CIQ5OFxf4Jou6uOKe9t1AOgqpeU5fd70A8NPdHSGeYXqXsPe6peOwI0cUl88RWZ6sP1vPMV3avd/R6cZ5/sP1A== - dependencies: - safe-buffer "^5.1.0" - -randomfill@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/randomfill/-/randomfill-1.0.4.tgz#c92196fc86ab42be983f1bf31778224931d61458" - integrity sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw== - dependencies: - randombytes "^2.0.5" - safe-buffer "^5.1.0" - -rc@^1.1.7: - version "1.2.7" - resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.7.tgz#8a10ca30d588d00464360372b890d06dacd02297" - integrity sha512-LdLD8xD4zzLsAT5xyushXDNscEjB7+2ulnl8+r1pnESlYtlJtVSoCMBGr30eDRJ3+2Gq89jK9P9e4tCEH1+ywA== - dependencies: - deep-extend "^0.5.1" - ini "~1.3.0" - minimist "^1.2.0" - strip-json-comments "~2.0.1" - -react-dom@^16.12.0: - version "16.12.0" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.12.0.tgz#0da4b714b8d13c2038c9396b54a92baea633fe11" - integrity sha512-LMxFfAGrcS3kETtQaCkTKjMiifahaMySFDn71fZUNpPHZQEzmk/GiAeIT8JSOrHB23fnuCOMruL2a8NYlw+8Gw== - dependencies: - loose-envify "^1.1.0" - object-assign "^4.1.1" - prop-types "^15.6.2" - scheduler "^0.18.0" - -react-is@^16.8.1: - version "16.8.2" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.2.tgz#09891d324cad1cb0c1f2d91f70a71a4bee34df0f" - integrity sha512-D+NxhSR2HUCjYky1q1DwpNUD44cDpUXzSmmFyC3ug1bClcU/iDNy0YNn1iwme28fn+NFhpA13IndOd42CrFb+Q== - -react-is@^16.8.4: - version "16.12.0" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c" - integrity sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q== - -react-testing-library@7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/react-testing-library/-/react-testing-library-7.0.1.tgz#0cf113bb53a78599f018378f6854e91a52dbf205" - integrity sha512-doQkM3/xPcIm22x9jgTkGxU8xqXg4iWvM1WwbbQ7CI5/EMk3DhloYBwMyk+Ywtta3dIAIh9sC7llXoKovf3L+w== - dependencies: - "@babel/runtime" "^7.4.3" - dom-testing-library "^4.1.0" - -react@^16.12.0: - version "16.12.0" - resolved "https://registry.yarnpkg.com/react/-/react-16.12.0.tgz#0c0a9c6a142429e3614834d5a778e18aa78a0b83" - integrity sha512-fglqy3k5E+81pA8s+7K0/T3DBCF0ZDOher1elBFzF7O6arXJgzyu/FW+COxFvAWXJoJN9KIZbT2LXlukwphYTA== - dependencies: - loose-envify "^1.1.0" - object-assign "^4.1.1" - prop-types "^15.6.2" - -"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6: - version "2.3.6" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" - integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw== - 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" - -readdirp@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.1.0.tgz#4ed0ad060df3073300c48440373f72d1cc642d78" - integrity sha1-TtCtBg3zBzMAxIRANz9y0cxkLXg= - dependencies: - graceful-fs "^4.1.2" - minimatch "^3.0.2" - readable-stream "^2.0.2" - set-immediate-shim "^1.0.1" - -readdirp@~3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.2.0.tgz#c30c33352b12c96dfb4b895421a49fd5a9593839" - integrity sha512-crk4Qu3pmXwgxdSgGhgA/eXiJAPQiX4GMOZZMXnqKxHX7TaoL+3gQVo/WeuAiogr07DpnfjIMpXXa+PAIvwPGQ== - dependencies: - picomatch "^2.0.4" - -regenerator-runtime@^0.13.2: - version "0.13.3" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz#7cf6a77d8f5c6f60eb73c5fc1955b2ceb01e6bf5" - integrity sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw== - -regex-not@^1.0.0, regex-not@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" - integrity sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A== - dependencies: - extend-shallow "^3.0.2" - safe-regex "^1.1.0" - -remove-trailing-separator@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" - integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8= - -repeat-element@^1.1.2: - version "1.1.3" - resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce" - integrity sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g== - -repeat-string@^1.6.1: - version "1.6.1" - resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" - integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= - -request-promise-core@1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.3.tgz#e9a3c081b51380dfea677336061fea879a829ee9" - integrity sha512-QIs2+ArIGQVp5ZYbWD5ZLCY29D5CfWizP8eWnm8FoGD1TX61veauETVQbrV60662V0oFBkrDOuaBI8XgtuyYAQ== - dependencies: - lodash "^4.17.15" - -request-promise-native@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/request-promise-native/-/request-promise-native-1.0.8.tgz#a455b960b826e44e2bf8999af64dff2bfe58cb36" - integrity sha512-dapwLGqkHtwL5AEbfenuzjTYg35Jd6KPytsC2/TLkVMz8rm+tNt72MGUWT1RP/aYawMpN6HqbNGBQaRcBtjQMQ== - dependencies: - request-promise-core "1.1.3" - stealthy-require "^1.1.1" - tough-cookie "^2.3.3" - -request@^2.88.0: - version "2.88.0" - resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef" - integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg== - dependencies: - aws-sign2 "~0.7.0" - aws4 "^1.8.0" - caseless "~0.12.0" - combined-stream "~1.0.6" - extend "~3.0.2" - forever-agent "~0.6.1" - form-data "~2.3.2" - har-validator "~5.1.0" - http-signature "~1.2.0" - is-typedarray "~1.0.0" - isstream "~0.1.2" - json-stringify-safe "~5.0.1" - mime-types "~2.1.19" - oauth-sign "~0.9.0" - performance-now "^2.1.0" - qs "~6.5.2" - safe-buffer "^5.1.2" - tough-cookie "~2.4.3" - tunnel-agent "^0.6.0" - uuid "^3.3.2" - -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= - -require-main-filename@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" - integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== - -resolve-cwd@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a" - integrity sha1-AKn3OHVW4nA46uIyyqNypqWbZlo= - dependencies: - resolve-from "^3.0.0" - -resolve-dir@^1.0.0, resolve-dir@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-1.0.1.tgz#79a40644c362be82f26effe739c9bb5382046f43" - integrity sha1-eaQGRMNivoLybv/nOcm7U4IEb0M= - dependencies: - expand-tilde "^2.0.0" - global-modules "^1.0.0" - -resolve-from@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" - integrity sha1-six699nWiBvItuZTM17rywoYh0g= - -resolve-url@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" - integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= - -resolve@^1.3.2: - version "1.8.1" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.8.1.tgz#82f1ec19a423ac1fbd080b0bab06ba36e84a7a26" - integrity sha512-AicPrAC7Qu1JxPCZ9ZgCZlY35QgFnNqc+0LtbRNxnVw4TXvjQ72wnuL9JQcEBgXkI9JM8MsT9kaQoHcpCRJOYA== - dependencies: - path-parse "^1.0.5" - -ret@~0.1.10: - version "0.1.15" - resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" - integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== - -rimraf@^2.5.4, rimraf@^2.6.1: - version "2.6.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.2.tgz#2ed8150d24a16ea8651e6d6ef0f47c4158ce7a36" - integrity sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w== - dependencies: - glob "^7.0.5" - -rimraf@^2.6.3, rimraf@~2.6.2: - version "2.6.3" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" - integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA== - dependencies: - glob "^7.1.3" - -ripemd160@^2.0.0, ripemd160@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.2.tgz#a1c1a6f624751577ba5d07914cbc92850585890c" - integrity sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA== - dependencies: - hash-base "^3.0.0" - inherits "^2.0.1" - -run-queue@^1.0.0, run-queue@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/run-queue/-/run-queue-1.0.3.tgz#e848396f057d223f24386924618e25694161ec47" - integrity sha1-6Eg5bwV9Ij8kOGkkYY4laUFh7Ec= - dependencies: - aproba "^1.1.1" - -safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.1.0, 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-regex@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" - integrity sha1-QKNmnzsHfR6UPURinhV91IAjvy4= - dependencies: - ret "~0.1.10" - -"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== - -sax@^1.2.4: - version "1.2.4" - resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" - integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== - -saxes@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/saxes/-/saxes-4.0.2.tgz#76f8e762efc96ec4af5f885d8151c50426103165" - integrity sha512-EZOTeQ4bgkOaGCDaTKux+LaRNcLNbdbvMH7R3/yjEEULPEmqvkFbFub6DJhJTub2iGMT93CfpZ5LTdKZmAbVeQ== - dependencies: - xmlchars "^2.2.0" - -scheduler@^0.18.0: - version "0.18.0" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.18.0.tgz#5901ad6659bc1d8f3fdaf36eb7a67b0d6746b1c4" - integrity sha512-agTSHR1Nbfi6ulI0kYNK0203joW2Y5W4po4l+v03tOoiJKpTBbxpNhWDvqc/4IcOw+KLmSiQLTasZ4cab2/UWQ== - dependencies: - loose-envify "^1.1.0" - object-assign "^4.1.1" - -schema-utils@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-1.0.0.tgz#0b79a93204d7b600d4b2850d1f66c2a34951c770" - integrity sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g== - dependencies: - ajv "^6.1.0" - ajv-errors "^1.0.0" - ajv-keywords "^3.1.0" - -schema-utils@^2.6.0, schema-utils@^2.6.4: - version "2.6.4" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.6.4.tgz#a27efbf6e4e78689d91872ee3ccfa57d7bdd0f53" - integrity sha512-VNjcaUxVnEeun6B2fiiUDjXXBtD4ZSH7pdbfIu1pOFwgptDPLMo/z9jr4sUfsjFVPqDCEin/F7IYlq7/E6yDbQ== - dependencies: - ajv "^6.10.2" - ajv-keywords "^3.4.1" - -semver@^5.3.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.0.tgz#dc4bbc7a6ca9d916dee5d43516f0092b58f7b8ab" - integrity sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA== - -semver@^5.4.1: - version "5.5.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.1.tgz#7dfdd8814bdb7cabc7be0fb1d734cfb66c940477" - integrity sha512-PqpAxfrEhlSUWge8dwIp4tZnQ25DIOthpiaHNIthsjEFQD6EvqUKUDM7L8O2rShkFccYo1VjJR0coWfNkCubRw== - -semver@^5.5.0, semver@^5.6.0: - version "5.7.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.0.tgz#790a7cf6fea5459bac96110b29b60412dc8ff96b" - integrity sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA== - -semver@^5.7.0: - version "5.7.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" - integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== - -semver@^6.0.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.2.0.tgz#4d813d9590aaf8a9192693d6c85b9344de5901db" - integrity sha512-jdFC1VdUGT/2Scgbimf7FSx9iJLXoqfglSF+gJeuNWVpiE37OIbc1jywR/GJyFdz3mnkz2/id0L0J/cr0izR5A== - -serialize-javascript@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61" - integrity sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ== - -set-blocking@^2.0.0, set-blocking@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" - integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= - -set-immediate-shim@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61" - integrity sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E= - -set-value@^0.4.3: - version "0.4.3" - resolved "https://registry.yarnpkg.com/set-value/-/set-value-0.4.3.tgz#7db08f9d3d22dc7f78e53af3c3bf4666ecdfccf1" - integrity sha1-fbCPnT0i3H945Trzw79GZuzfzPE= - dependencies: - extend-shallow "^2.0.1" - is-extendable "^0.1.1" - is-plain-object "^2.0.1" - to-object-path "^0.3.0" - -set-value@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.0.tgz#71ae4a88f0feefbbf52d1ea604f3fb315ebb6274" - integrity sha512-hw0yxk9GT/Hr5yJEYnHNKYXkIA8mVJgd9ditYZCe16ZczcaELYYcfvaXesNACk2O8O0nTiPQcQhGUQj8JLzeeg== - dependencies: - extend-shallow "^2.0.1" - is-extendable "^0.1.1" - is-plain-object "^2.0.3" - split-string "^3.0.1" - -setimmediate@^1.0.4: - version "1.0.5" - resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" - integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= - -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== - dependencies: - inherits "^2.0.1" - safe-buffer "^5.0.1" - -shebang-command@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" - integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo= - dependencies: - shebang-regex "^1.0.0" - -shebang-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" - integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= - -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== - -signal-exit@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" - integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= - -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" - integrity sha1-BcLuxXn//+FFoDCsJs/qYbmA+r4= - -sinon@9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/sinon/-/sinon-9.0.0.tgz#9f1ed502fa2e287e65220de08f6a44f33e314006" - integrity sha512-c4bREcvuK5VuEGyMW/Oim9I3Rq49Vzb0aMdxouFaA44QCFpilc5LJOugrX+mkrvikbqCimxuK+4cnHVNnLR41g== - dependencies: - "@sinonjs/commons" "^1.7.0" - "@sinonjs/fake-timers" "^6.0.0" - "@sinonjs/formatio" "^5.0.0" - "@sinonjs/samsam" "^5.0.1" - diff "^4.0.2" - nise "^4.0.1" - supports-color "^7.1.0" - -snapdragon-node@^2.0.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" - integrity sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw== - dependencies: - define-property "^1.0.0" - isobject "^3.0.0" - snapdragon-util "^3.0.1" - -snapdragon-util@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2" - integrity sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ== - dependencies: - kind-of "^3.2.0" - -snapdragon@^0.8.1: - version "0.8.2" - resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d" - integrity sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg== - dependencies: - base "^0.11.1" - debug "^2.2.0" - define-property "^0.2.5" - extend-shallow "^2.0.1" - map-cache "^0.2.2" - source-map "^0.5.6" - source-map-resolve "^0.5.0" - use "^3.1.0" - -source-list-map@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.0.tgz#aaa47403f7b245a92fbc97ea08f250d6087ed085" - integrity sha512-I2UmuJSRr/T8jisiROLU3A3ltr+swpniSmNPI4Ml3ZCX6tVnDsuZzK7F2hl5jTqbZBWCEKlj5HRQiPExXLgE8A== - -source-map-resolve@^0.5.0: - version "0.5.2" - resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.2.tgz#72e2cc34095543e43b2c62b2c4c10d4a9054f259" - integrity sha512-MjqsvNwyz1s0k81Goz/9vRBe9SZdB09Bdw+/zYyO+3CuPk6fouTaxscHkgtE8jKvf01kVfl8riHzERQ/kefaSA== - dependencies: - atob "^2.1.1" - decode-uri-component "^0.2.0" - resolve-url "^0.2.1" - source-map-url "^0.4.0" - urix "^0.1.0" - -source-map-support@0.5.16: - version "0.5.16" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.16.tgz#0ae069e7fe3ba7538c64c98515e35339eac5a042" - integrity sha512-efyLRJDr68D9hBBNIPWFjhpFzURh+KJykQwvMyW5UiZzYwoF6l4YMMDIJJEyFWxWCqfyxLzz6tSfUFR+kXXsVQ== - dependencies: - buffer-from "^1.0.0" - source-map "^0.6.0" - -source-map-support@~0.5.12: - version "0.5.12" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.12.tgz#b4f3b10d51857a5af0138d3ce8003b201613d599" - integrity sha512-4h2Pbvyy15EE02G+JOZpUCmqWJuqrs+sEkzewTm++BPi7Hvn/HwcqLAcNxYAyI0x13CpPPn+kMjl+hplXMHITQ== - dependencies: - buffer-from "^1.0.0" - source-map "^0.6.0" - -source-map-url@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" - integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM= - -source-map@^0.5.6: - version "0.5.7" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" - integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= - -source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" - integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== - -spawn-sync@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/spawn-sync/-/spawn-sync-2.0.0.tgz#3af5ba4b73cc5dc8a41d3747eede71e98d949555" - integrity sha512-AGXIhH/XZVinFewojYTsG8uapHX2e7MjtFbmibvK9qqG4qGd9b6jelU1sTkCA0RVGHvN9exJYTBVbF1Ls2f69g== - -split-string@^3.0.1, split-string@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" - integrity sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw== - dependencies: - extend-shallow "^3.0.0" - -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@^2.0.0-alpha.3: - version "2.0.0-alpha.3" - resolved "https://registry.yarnpkg.com/ssh-config/-/ssh-config-2.0.0-alpha.3.tgz#c174df6e42b16e4e5ece731d655f2af7181f2bf4" - integrity sha512-7Hgynvg/mkOb8clmYp4/BZUmWw4bfjkZiafg8clSQkQYviOE8X5DnIcOIDK90qY4O76MdhYKT2CPdAxZkev4FA== - -sshpk@^1.7.0: - version "1.14.2" - resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.14.2.tgz#c6fc61648a3d9c4e764fd3fcdf4ea105e492ba98" - integrity sha1-xvxhZIo9nE52T9P8306hBeSSupg= - dependencies: - asn1 "~0.2.3" - assert-plus "^1.0.0" - dashdash "^1.12.0" - getpass "^0.1.1" - safer-buffer "^2.0.2" - optionalDependencies: - bcrypt-pbkdf "^1.0.0" - ecc-jsbn "~0.1.1" - jsbn "~0.1.0" - tweetnacl "~0.14.0" - -ssri@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.1.tgz#2a3c41b28dd45b62b63676ecb74001265ae9edd8" - integrity sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA== - dependencies: - figgy-pudding "^3.5.1" - -stack-chain@^1.3.7: - version "1.3.7" - resolved "https://registry.yarnpkg.com/stack-chain/-/stack-chain-1.3.7.tgz#d192c9ff4ea6a22c94c4dd459171e3f00cea1285" - integrity sha1-0ZLJ/06moiyUxN1FkXHj8AzqEoU= - -static-extend@^0.1.1: - version "0.1.2" - resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" - integrity sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY= - dependencies: - define-property "^0.2.5" - object-copy "^0.1.0" - -stealthy-require@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" - integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks= - -stream-browserify@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.1.tgz#66266ee5f9bdb9940a4e4514cafb43bb71e5c9db" - integrity sha1-ZiZu5fm9uZQKTkUUyvtDu3Hlyds= - dependencies: - inherits "~2.0.1" - readable-stream "^2.0.2" - -stream-each@^1.1.0: - version "1.2.2" - resolved "https://registry.yarnpkg.com/stream-each/-/stream-each-1.2.2.tgz#8e8c463f91da8991778765873fe4d960d8f616bd" - integrity sha512-mc1dbFhGBxvTM3bIWmAAINbqiuAk9TATcfIQC8P+/+HJefgaiTlMn2dHvkX8qlI12KeYKSQ1Ua9RrIqrn1VPoA== - dependencies: - end-of-stream "^1.1.0" - stream-shift "^1.0.0" - -stream-http@^2.7.2: - version "2.8.3" - resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-2.8.3.tgz#b2d242469288a5a27ec4fe8933acf623de6514fc" - integrity sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw== - dependencies: - builtin-status-codes "^3.0.0" - inherits "^2.0.1" - readable-stream "^2.3.6" - to-arraybuffer "^1.0.0" - xtend "^4.0.0" - -stream-shift@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952" - integrity sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI= - -strict-uri-encode@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" - integrity sha1-ucczDHBChi9rFC3CdLvMWGbONUY= - -string-width@^1.0.1, string-width@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" - integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= - dependencies: - code-point-at "^1.0.0" - is-fullwidth-code-point "^1.0.0" - strip-ansi "^3.0.0" - -"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== - dependencies: - is-fullwidth-code-point "^2.0.0" - strip-ansi "^4.0.0" - -string-width@^3.0.0, string-width@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" - integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== - dependencies: - emoji-regex "^7.0.1" - is-fullwidth-code-point "^2.0.0" - strip-ansi "^5.1.0" - -string.prototype.trimleft@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz#9bdb8ac6abd6d602b17a4ed321870d2f8dcefc74" - integrity sha512-iu2AGd3PuP5Rp7x2kEZCrB2Nf41ehzh+goo8TV7z8/XDBbsvc6HQIlUl9RjkZ4oyrW1XM5UwlGl1oVEaDjg6Ag== - dependencies: - define-properties "^1.1.3" - function-bind "^1.1.1" - -string.prototype.trimright@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/string.prototype.trimright/-/string.prototype.trimright-2.1.1.tgz#440314b15996c866ce8a0341894d45186200c5d9" - integrity sha512-qFvWL3/+QIgZXVmJBfpHmxLB7xsUXz6HsUmP8+5dRaC3Q7oKUv9Vo6aMCRZC1smrtyECFsIT30PqBJ1gTjAs+g== - dependencies: - define-properties "^1.1.3" - function-bind "^1.1.1" - -string_decoder@^1.0.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== - dependencies: - safe-buffer "~5.1.0" - -strip-ansi@^3.0.0, strip-ansi@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" - integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= - dependencies: - ansi-regex "^2.0.0" - -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= - dependencies: - ansi-regex "^3.0.0" - -strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" - integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== - dependencies: - ansi-regex "^4.1.0" - -strip-eof@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" - integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= - -strip-json-comments@2.0.1, strip-json-comments@~2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" - integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= - -style-loader@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-1.1.3.tgz#9e826e69c683c4d9bf9db924f85e9abb30d5e200" - integrity sha512-rlkH7X/22yuwFYK357fMN/BxYOorfnfq0eD7+vqlemSK4wEcejFF1dg4zxP0euBW8NrYx2WZzZ8PPFevr7D+Kw== - dependencies: - loader-utils "^1.2.3" - schema-utils "^2.6.4" - -supports-color@6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.0.0.tgz#76cfe742cf1f41bb9b1c29ad03068c05b4c0e40a" - integrity sha512-on9Kwidc1IUQo+bQdhi8+Tijpo0e1SS6RoGo2guUwn5vdaxw8RXOF9Vb2ws+ihWOmh4JnCJOvaziZWP1VABaLg== - dependencies: - has-flag "^3.0.0" - -supports-color@6.1.0, supports-color@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3" - integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ== - dependencies: - has-flag "^3.0.0" - -supports-color@^5.3.0: - version "5.4.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.4.0.tgz#1c6b337402c2137605efe19f10fec390f6faab54" - integrity sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w== - dependencies: - has-flag "^3.0.0" - -supports-color@^7.1.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1" - integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g== - dependencies: - has-flag "^4.0.0" - -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" - integrity sha512-kbrcEh5n5JkypaSC152eGfGcnT4lkR0eSfvefaUJkLqgGjRQJyKDvvEE/CCv5aTSdfXuc+N98w16iAojhShI3g== - dependencies: - loader-utils "^1.1.0" - object-assign "^4.0.1" - simple-html-tokenizer "^0.1.1" - -symbol-observable@^1.0.2: - version "1.2.0" - resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" - integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== - -symbol-tree@^3.2.4: - version "3.2.4" - resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" - integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== - -tapable@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.0.0.tgz#cbb639d9002eed9c6b5975eb20598d7936f1f9f2" - integrity sha512-dQRhbNQkRnaqauC7WqSJ21EEksgT0fYZX2lqXzGkpo8JNig9zGZTYoMGvyI2nWmXlE2VSVXVDu7wLVGu/mQEsg== - -tapable@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" - integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== - -tar@^4, tar@^4.4.2: - version "4.4.10" - resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.10.tgz#946b2810b9a5e0b26140cf78bea6b0b0d689eba1" - integrity sha512-g2SVs5QIxvo6OLp0GudTqEf05maawKUxXru104iaayWA09551tFCTI8f1Asb4lPfkBr91k07iL4c11XO3/b0tA== - dependencies: - chownr "^1.1.1" - fs-minipass "^1.2.5" - minipass "^2.3.5" - minizlib "^1.2.1" - mkdirp "^0.5.0" - safe-buffer "^5.1.2" - yallist "^3.0.3" - -temp@0.9.1: - version "0.9.1" - resolved "https://registry.yarnpkg.com/temp/-/temp-0.9.1.tgz#2d666114fafa26966cd4065996d7ceedd4dd4697" - integrity sha512-WMuOgiua1xb5R56lE0eH6ivpVmg/lq2OHm4+LtT/xtEtPQ+sz6N3bBM6WZ5FvO1lO4IKIOb43qnhoc4qxP5OeA== - dependencies: - rimraf "~2.6.2" - -terser-webpack-plugin@^1.4.3: - version "1.4.3" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.4.3.tgz#5ecaf2dbdc5fb99745fd06791f46fc9ddb1c9a7c" - integrity sha512-QMxecFz/gHQwteWwSo5nTc6UaICqN1bMedC5sMtUc7y3Ha3Q8y6ZO0iCR8pq4RJC8Hjf0FEPEHZqcMB/+DFCrA== - dependencies: - cacache "^12.0.2" - find-cache-dir "^2.1.0" - is-wsl "^1.1.0" - schema-utils "^1.0.0" - serialize-javascript "^2.1.2" - source-map "^0.6.1" - terser "^4.1.2" - webpack-sources "^1.4.0" - worker-farm "^1.7.0" - -terser@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/terser/-/terser-4.1.2.tgz#b2656c8a506f7ce805a3f300a2ff48db022fa391" - integrity sha512-jvNoEQSPXJdssFwqPSgWjsOrb+ELoE+ILpHPKXC83tIxOlh2U75F1KuB2luLD/3a6/7K3Vw5pDn+hvu0C4AzSw== - dependencies: - commander "^2.20.0" - source-map "~0.6.1" - source-map-support "~0.5.12" - -through2@^2.0.0: - version "2.0.3" - resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.3.tgz#0004569b37c7c74ba39c43f3ced78d1ad94140be" - integrity sha1-AARWmzfHx0ujnEPzzteNGtlBQL4= - dependencies: - readable-stream "^2.1.5" - xtend "~4.0.1" - -timers-browserify@^2.0.4: - version "2.0.10" - resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-2.0.10.tgz#1d28e3d2aadf1d5a5996c4e9f95601cd053480ae" - integrity sha512-YvC1SV1XdOUaL6gx5CoGroT3Gu49pK9+TZ38ErPldOWW4j49GI1HKs9DV+KGq/w6y+LZ72W1c8cKz2vzY+qpzg== - dependencies: - setimmediate "^1.0.4" - -to-arraybuffer@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43" - integrity sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M= - -to-object-path@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" - integrity sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68= - dependencies: - kind-of "^3.0.2" - -to-regex-range@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" - integrity sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg= - dependencies: - is-number "^3.0.0" - repeat-string "^1.6.1" - -to-regex-range@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" - integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== - dependencies: - is-number "^7.0.0" - -to-regex@^3.0.1, to-regex@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce" - integrity sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw== - dependencies: - define-property "^2.0.2" - extend-shallow "^3.0.2" - regex-not "^1.0.2" - safe-regex "^1.1.0" - -tough-cookie@^2.3.3: - version "2.5.0" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" - integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== - dependencies: - psl "^1.1.28" - punycode "^2.1.1" - -tough-cookie@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-3.0.1.tgz#9df4f57e739c26930a018184887f4adb7dca73b2" - integrity sha512-yQyJ0u4pZsv9D4clxO69OEjLWYw+jbgspjTue4lTQZLfV0c5l1VmK2y1JK8E9ahdpltPOaAThPcp5nKPUgSnsg== - dependencies: - ip-regex "^2.1.0" - psl "^1.1.28" - punycode "^2.1.1" - -tough-cookie@~2.4.3: - version "2.4.3" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" - integrity sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ== - dependencies: - psl "^1.1.24" - punycode "^1.4.1" - -tr46@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.0.2.tgz#03273586def1595ae08fedb38d7733cee91d2479" - integrity sha512-3n1qG+/5kg+jrbTzwAykB5yRYtQCTqOGKq5U5PE3b0a1/mzo6snDhjGS0zJVJunO0NrT3Dg1MLy5TjWP/UJppg== - dependencies: - punycode "^2.1.1" - -ts-loader@^6.2.1: - version "6.2.1" - resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-6.2.1.tgz#67939d5772e8a8c6bdaf6277ca023a4812da02ef" - integrity sha512-Dd9FekWuABGgjE1g0TlQJ+4dFUfYGbYcs52/HQObE0ZmUNjQlmLAS7xXsSzy23AMaMwipsx5sNHvoEpT2CZq1g== - dependencies: - chalk "^2.3.0" - enhanced-resolve "^4.0.0" - loader-utils "^1.0.2" - micromatch "^4.0.0" - semver "^6.0.0" - -tslib@^1.10.0: - version "1.11.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.0.tgz#f1f3528301621a53220d58373ae510ff747a66bc" - integrity sha512-BmndXUtiTn/VDDrJzQE7Mm22Ix3PxgLltW9bSNLoeCY31gnG2OPx0QqJnuc9oMIKioYrz487i6K9o4Pdn0j+Kg== - -tslib@^1.8.1: - version "1.9.3" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286" - integrity sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ== - -tslib@^1.9.0: - version "1.9.2" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.2.tgz#8be0cc9a1f6dc7727c38deb16c2ebd1a2892988e" - integrity sha512-AVP5Xol3WivEr7hnssHDsaM+lVrVXWUvd1cfXTRkTj80b//6g2wIFEH6hZG0muGZRnHGrfttpdzRk3YlBkWjKw== - -tslint-webpack-plugin@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/tslint-webpack-plugin/-/tslint-webpack-plugin-2.1.0.tgz#c8d50ed10539c403759112fdb2ee512e2d35a01b" - integrity sha512-subYgmwihOGftPZS59looqPWdbqMIvsoTy8MeQPeZ7bOdwZfR3AAnVG8/VzpSRly8l/xbPosrX2QKtJEZPt71A== - dependencies: - chalk "^2.1.0" - -tslint@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/tslint/-/tslint-6.0.0.tgz#1c0148beac4779924216302f192cdaa153618310" - integrity sha512-9nLya8GBtlFmmFMW7oXXwoXS1NkrccqTqAtwXzdPV9e2mqSEvCki6iHL/Fbzi5oqbugshzgGPk7KBb2qNP1DSA== - dependencies: - "@babel/code-frame" "^7.0.0" - builtin-modules "^1.1.1" - chalk "^2.3.0" - commander "^2.12.1" - diff "^4.0.1" - glob "^7.1.1" - js-yaml "^3.13.1" - minimatch "^3.0.4" - mkdirp "^0.5.1" - resolve "^1.3.2" - semver "^5.3.0" - tslib "^1.10.0" - tsutils "^2.29.0" - -tsutils@^2.29.0: - version "2.29.0" - resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-2.29.0.tgz#32b488501467acbedd4b85498673a0812aca0b99" - integrity sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA== - dependencies: - tslib "^1.8.1" - -tty-browserify@0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" - integrity sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY= - -tty@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/tty/-/tty-1.0.1.tgz#e4409ac98b0dd1c50b59ff38e86eac3f0764ee45" - integrity sha1-5ECayYsN0cULWf846G6sPwdk7kU= - -tunnel-agent@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" - integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0= - dependencies: - safe-buffer "^5.0.1" - -tunnel@0.0.6: - version "0.0.6" - resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c" - integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg== - -tweetnacl@^0.14.3, tweetnacl@~0.14.0: - version "0.14.5" - resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" - integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= - -type-check@~0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" - integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I= - dependencies: - prelude-ls "~1.1.2" - -type-detect@4.0.8, type-detect@^4.0.8: - version "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== - -typedarray@^0.0.6: - version "0.0.6" - resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" - integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= - -typescript@^3.8.3: - version "3.8.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.3.tgz#409eb8544ea0335711205869ec458ab109ee1061" - integrity sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w== - -union-value@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.0.tgz#5c71c34cb5bad5dcebe3ea0cd08207ba5aa1aea4" - integrity sha1-XHHDTLW61dzr4+oM0IIHulqhrqQ= - dependencies: - arr-union "^3.1.0" - get-value "^2.0.6" - is-extendable "^0.1.1" - set-value "^0.4.3" - -uniq@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" - integrity sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8= - -unique-filename@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230" - integrity sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ== - dependencies: - unique-slug "^2.0.0" - -unique-slug@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-2.0.0.tgz#db6676e7c7cc0629878ff196097c78855ae9f4ab" - integrity sha1-22Z258fMBimHj/GWCXx4hVrp9Ks= - dependencies: - imurmurhash "^0.1.4" - -universal-user-agent@^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== - -unset-value@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559" - integrity sha1-g3aHP30jNRef+x5vw6jtDfyKtVk= - dependencies: - has-value "^0.3.1" - isobject "^3.0.0" - -upath@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/upath/-/upath-1.1.0.tgz#35256597e46a581db4793d0ce47fa9aebfc9fabd" - integrity sha512-bzpH/oBhoS/QI/YtbkqCg6VEiPYjSZtrHQM6/QnJS6OL9pKUFLqb3aFh4Scvwm45+7iAgiMkLhSbaZxUqmrprw== - -uri-js@^4.2.1, uri-js@^4.2.2: - version "4.2.2" - resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0" - integrity sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ== - dependencies: - punycode "^2.1.0" - -urix@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" - integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= - -url@^0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" - integrity sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE= - dependencies: - punycode "1.3.2" - querystring "0.2.0" - -use@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/use/-/use-3.1.0.tgz#14716bf03fdfefd03040aef58d8b4b85f3a7c544" - integrity sha512-6UJEQM/L+mzC3ZJNM56Q4DFGLX/evKGRg15UJHGB9X5j5Z3AFbgZvjUh2yq/UJUY4U5dh7Fal++XbNg1uzpRAw== - dependencies: - kind-of "^6.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= - -util@0.10.3: - version "0.10.3" - resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9" - integrity sha1-evsa/lCAUkZInj23/g7TeTNqwPk= - dependencies: - inherits "2.0.1" - -util@^0.11.0: - version "0.11.1" - resolved "https://registry.yarnpkg.com/util/-/util-0.11.1.tgz#3236733720ec64bb27f6e26f421aaa2e1b588d61" - integrity sha512-HShAsny+zS2TZfaXxD9tYj4HQGlBezXZMZuM/S5PKLLoZkShZiGk9o5CzukI1LVHZvjdvZ2Sj1aW/Ndn2NB/HQ== - dependencies: - inherits "2.0.3" - -uuid@^3.3.2: - version "3.3.2" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" - integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== - -uuid@^3.3.3: - version "3.3.3" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.3.tgz#4568f0216e78760ee1dbf3a4d2cf53e224112866" - integrity sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ== - -v8-compile-cache@2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.0.3.tgz#00f7494d2ae2b688cfe2899df6ed2c54bef91dbe" - integrity sha512-CNmdbwQMBjwr9Gsmohvm0pbL954tJrNzf6gWL3K+QMQf00PF7ERGrEiLgjuU3mKreLC2MeGhUsNV9ybTbLgd3w== - -verror@1.10.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" - integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA= - dependencies: - assert-plus "^1.0.0" - core-util-is "1.0.2" - extsprintf "^1.2.0" - -vm-browserify@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.0.tgz#bd76d6a23323e2ca8ffa12028dc04559c75f9019" - integrity sha512-iq+S7vZJE60yejDYM0ek6zg308+UZsdtPExWP9VZoCFCz1zkJoXFnAX7aZfd/ZwrkidzdUZL0C/ryW+JwAiIGw== - -vscode-extension-telemetry@^0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/vscode-extension-telemetry/-/vscode-extension-telemetry-0.1.3.tgz#b407699b37830faea519753ed817f38de2d2187e" - integrity sha512-2P4/TrxwMRQJPpcsSpreI7JVftmy+kbatONGVY65x4fJfbaHTBTm6jNgkG0Xifv6Th1o25KvaVZUTjN7VWlxBA== - dependencies: - applicationinsights "1.7.4" - -vscode-test@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/vscode-test/-/vscode-test-1.3.0.tgz#3310ab385d9b887b4c82e8f52be1030e7cf9493d" - integrity sha512-LddukcBiSU2FVTDr3c1D8lwkiOvwlJdDL2hqVbn6gIz+rpTqUCkMZSKYm94Y1v0WXlHSDQBsXyY+tchWQgGVsw== - dependencies: - http-proxy-agent "^2.1.0" - https-proxy-agent "^2.2.4" - rimraf "^2.6.3" - -vsls@^0.3.967: - version "0.3.967" - resolved "https://registry.yarnpkg.com/vsls/-/vsls-0.3.967.tgz#d762f1b10287c2a4ca8a54edb2f6b0f074a52583" - integrity sha512-FFaRZz4RBo/QmUHvQophkzMzrTrsV8g169jUPEaL7UWak3FdwGdGvm2DlSZIZl36MzLNb/43BPq6WDzoKDwR4g== - -w3c-hr-time@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz#82ac2bff63d950ea9e3189a58a65625fedf19045" - integrity sha1-gqwr/2PZUOqeMYmlimViX+3xkEU= - dependencies: - browser-process-hrtime "^0.1.2" - -w3c-xmlserializer@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz#3e7104a05b75146cc60f564380b7f683acf1020a" - integrity sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA== - dependencies: - xml-name-validator "^3.0.0" - -wait-for-expect@^1.1.1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/wait-for-expect/-/wait-for-expect-1.3.0.tgz#65241ce355425f907f5d127bdb5e72c412ff830c" - integrity sha512-8fJU7jiA96HfGPt+P/UilelSAZfhMBJ52YhKzlmZQvKEZU2EcD1GQ0yqGB6liLdHjYtYAoGVigYwdxr5rktvzA== - -watchpack@^1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz#4bc12c2ebe8aa277a71f1d3f14d685c7b446cd00" - integrity sha512-i6dHe3EyLjMmDlU1/bGQpEw25XSjkJULPuAVKCbNRefQVq48yXKUpwg538F7AZTf9kyr57zj++pQFltUa5H7yA== - dependencies: - chokidar "^2.0.2" - graceful-fs "^4.1.2" - neo-async "^2.5.0" - -webidl-conversions@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff" - integrity sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA== - -webpack-cli@^3.3.11: - version "3.3.11" - resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.11.tgz#3bf21889bf597b5d82c38f215135a411edfdc631" - integrity sha512-dXlfuml7xvAFwYUPsrtQAA9e4DOe58gnzSxhgrO/ZM/gyXTBowrsYeubyN4mqGhYdpXMFNyQ6emjJS9M7OBd4g== - dependencies: - chalk "2.4.2" - cross-spawn "6.0.5" - enhanced-resolve "4.1.0" - findup-sync "3.0.0" - global-modules "2.0.0" - import-local "2.0.0" - interpret "1.2.0" - loader-utils "1.2.3" - supports-color "6.1.0" - v8-compile-cache "2.0.3" - yargs "13.2.4" - -webpack-sources@^1.4.0, webpack-sources@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.1.tgz#b91b2c5b1c4e890ff50d1d35b7fa3657040da1da" - integrity sha512-XSz38193PTo/1csJabKaV4b53uRVotlMgqJXm3s3eje0Bu6gQTxYDqpD38CmQfDBA+gN+QqaGjasuC8I/7eW3Q== - dependencies: - source-list-map "^2.0.0" - source-map "~0.6.1" - -webpack@^4.41.6: - version "4.41.6" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.41.6.tgz#12f2f804bf6542ef166755050d4afbc8f66ba7e1" - integrity sha512-yxXfV0Zv9WMGRD+QexkZzmGIh54bsvEs+9aRWxnN8erLWEOehAKUTeNBoUbA6HPEZPlRo7KDi2ZcNveoZgK9MA== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-module-context" "1.8.5" - "@webassemblyjs/wasm-edit" "1.8.5" - "@webassemblyjs/wasm-parser" "1.8.5" - acorn "^6.2.1" - ajv "^6.10.2" - ajv-keywords "^3.4.1" - chrome-trace-event "^1.0.2" - enhanced-resolve "^4.1.0" - eslint-scope "^4.0.3" - json-parse-better-errors "^1.0.2" - loader-runner "^2.4.0" - loader-utils "^1.2.3" - memory-fs "^0.4.1" - micromatch "^3.1.10" - mkdirp "^0.5.1" - neo-async "^2.6.1" - node-libs-browser "^2.2.1" - schema-utils "^1.0.0" - tapable "^1.1.3" - terser-webpack-plugin "^1.4.3" - watchpack "^1.6.0" - webpack-sources "^1.4.1" - -whatwg-encoding@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0" - integrity sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw== - dependencies: - iconv-lite "0.4.24" - -whatwg-mimetype@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" - integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g== - -whatwg-url@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.0.0.tgz#37f256cb746398e19b107bd6ef820b4ae2d15871" - integrity sha512-41ou2Dugpij8/LPO5Pq64K5q++MnRCBpEHvQr26/mArEKTkCV5aoXIqyhuYtE0pkqScXwhf2JP57rkRTYM29lQ== - dependencies: - lodash.sortby "^4.7.0" - tr46 "^2.0.0" - webidl-conversions "^5.0.0" - -which-module@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" - integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= - -which@1.3.1, which@^1.2.14, which@^1.2.9, which@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" - integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== - 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" - -wide-align@^1.1.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.2.tgz#571e0f1b0604636ebc0dfc21b0339bbe31341710" - integrity sha512-ijDLlyQ7s6x1JgCLur53osjm/UXUYD9+0PbYKrBsYisYXzCxN+HC3mYDNy/dWdmf3AwqwU3CXwDCvsNgGK1S0w== - dependencies: - string-width "^1.0.2" - -wordwrap@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" - integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus= - -worker-farm@^1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.7.0.tgz#26a94c5391bbca926152002f69b84a4bf772e5a8" - integrity sha512-rvw3QTZc8lAxyVrqcSGVm5yP/IJ2UcB3U0graE3LCFoZ0Yn2x4EoVSqJKdB/T5M+FLcRPjz4TDacRf3OCfNUzw== - dependencies: - errno "~0.1.7" - -wrap-ansi@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09" - integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q== - dependencies: - ansi-styles "^3.2.0" - string-width "^3.0.0" - strip-ansi "^5.0.0" - -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= - -ws@^7.2.1: - version "7.2.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.2.1.tgz#03ed52423cd744084b2cf42ed197c8b65a936b8e" - integrity sha512-sucePNSafamSKoOqoNfBd8V0StlkzJKL2ZAhGQinCfNQ+oacw+Pk7lcdAElecBF2VkLNZRiIb5Oi1Q5lVUVt2A== - -xml-name-validator@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" - integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw== - -xml@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/xml/-/xml-1.0.1.tgz#78ba72020029c5bc87b8a81a3cfcd74b4a2fc1e5" - integrity sha1-eLpyAgApxbyHuKgaPPzXS0ovweU= - -xmlchars@^2.2.0: - version "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.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" - integrity sha1-pcbVMr5lbiPbgg77lDofBJmNY68= - -y18n@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" - integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w== - -yallist@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" - integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI= - -yallist@^3.0.0, yallist@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.2.tgz#8452b4bb7e83c7c188d8041c1a837c773d6d8bb9" - integrity sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k= - -yallist@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.3.tgz#b4b049e314be545e3ce802236d6cd22cd91c3de9" - integrity sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A== - -yargs-parser@13.1.1, yargs-parser@^13.1.0, yargs-parser@^13.1.1: - version "13.1.1" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.1.tgz#d26058532aa06d365fe091f6a1fc06b2f7e5eca0" - integrity sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ== - dependencies: - camelcase "^5.0.0" - decamelize "^1.2.0" - -yargs-unparser@1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-1.6.0.tgz#ef25c2c769ff6bd09e4b0f9d7c605fb27846ea9f" - integrity sha512-W9tKgmSn0DpSatfri0nx52Joq5hVXgeLiqR/5G0sZNDoLZFOr/xjBUDcShCOGNsBnEMNo1KAMBkTej1Hm62HTw== - dependencies: - flat "^4.1.0" - lodash "^4.17.15" - yargs "^13.3.0" - -yargs@13.2.4: - version "13.2.4" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.2.4.tgz#0b562b794016eb9651b98bd37acf364aa5d6dc83" - integrity sha512-HG/DWAJa1PAnHT9JAhNa8AbAv3FPaiLzioSjCcmuXXhP8MlpHO5vwls4g4j6n30Z74GVQj8Xa62dWVx1QCGklg== - dependencies: - cliui "^5.0.0" - find-up "^3.0.0" - get-caller-file "^2.0.1" - os-locale "^3.1.0" - require-directory "^2.1.1" - require-main-filename "^2.0.0" - set-blocking "^2.0.0" - string-width "^3.0.0" - which-module "^2.0.0" - y18n "^4.0.0" - yargs-parser "^13.1.0" - -yargs@13.3.0, yargs@^13.3.0: - version "13.3.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.0.tgz#4c657a55e07e5f2cf947f8a366567c04a0dedc83" - integrity sha512-2eehun/8ALW8TLoIl7MVaRUrg+yCnenu8B4kBlRxj3GJGDKU1Og7sMXPNm1BYyM1DOJmTZ4YeN/Nwxv+8XJsUA== - dependencies: - cliui "^5.0.0" - find-up "^3.0.0" - get-caller-file "^2.0.1" - require-directory "^2.1.1" - require-main-filename "^2.0.0" - set-blocking "^2.0.0" - string-width "^3.0.0" - which-module "^2.0.0" - y18n "^4.0.0" - yargs-parser "^13.1.1" - -zen-observable-ts@^0.8.13: - version "0.8.13" - resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-0.8.13.tgz#ae1fd77c84ef95510188b1f8bca579d7a5448fc2" - integrity sha512-WDb8SM0tHCb6c0l1k60qXWlm1ok3zN9U4VkLdnBKQwIYwUoB9psH7LIFgR+JVCCMmBxUgOjskIid8/N02k/2Bg== - dependencies: - zen-observable "^0.8.0" - -zen-observable@^0.8.0: - version "0.8.11" - resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.11.tgz#d3415885eeeb42ee5abb9821c95bb518fcd6d199" - integrity sha512-N3xXQVr4L61rZvGMpWe8XoCGX8vhU35dPyQ4fm5CY/KDlG0F75un14hjbckPXTDuKUY6V0dqR2giT6xN8Y4GEQ==