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 index 7413725c41..45172ddcd1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,6 +5,7 @@ - **Strictness**: Some strictness is disabled in `tsconfig.base.json` (e.g., `strictNullChecks: false`), but new code should avoid unsafe patterns. - **Testing**: Place tests under `src/test`. Do not include test code in production files. - **Localization**: Use `%key%` syntax for strings that require localization. See `package.nls.json`. +- **Regular Expressions**: Use named capture groups for clarity when working with complex regex patterns. ## Extension-Specific Practices - **VS Code API**: Use the official VS Code API for all extension points. Register commands, views, and menus via `package.json`. @@ -19,8 +20,8 @@ - **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. +- Never touch the package-lock.json file. +- Run `npm 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. 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 index c9f9ff8872..74dc8fb9a7 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -29,10 +29,10 @@ jobs: uses: actions/setup-node@v4 with: node-version: "20" - cache: "yarn" + cache: "npm" - name: Install dependencies - run: yarn install --frozen-lockfile + run: npm ci env: # Skip Playwright browser downloads to avoid installation failures PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 @@ -48,7 +48,7 @@ jobs: continue-on-error: true - name: Update VS Code type definitions - run: yarn update-dts + run: npm run update-dts - name: Basic build verification run: | diff --git a/.gitignore b/.gitignore index 4d5727ad18..3520467a42 100644 --- a/.gitignore +++ b/.gitignore @@ -339,7 +339,6 @@ media .vscode-test-web .DS_Store *.vsix -package-lock.json src/@types/vscode.d.ts src/@types/vscode.proposed.d.ts diff --git a/.husky/pre-commit b/.husky/pre-commit index 41bddbd059..1e24795ff3 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,5 +1,37 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -npm run hygiene -npm run lint \ No newline at end of file +# Hygiene reads staged contents while ESLint checks the working tree, so they can run concurrently. +node ./build/hygiene.js & +hygiene_pid=$! +lint_pid='' + +# 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 node ./node_modules/eslint/bin/eslint.js --fix --cache & + lint_pid=$! +fi + +hygiene_status=0 +if wait "$hygiene_pid"; then + : +else + hygiene_status=$? +fi + +lint_status=0 +if [ -n "$lint_pid" ]; then + if wait "$lint_pid"; then + : + else + lint_status=$? + fi +fi + +if [ "$hygiene_status" -ne 0 ]; then + exit "$hygiene_status" +fi + +exit "$lint_status" diff --git a/.vscodeignore b/.vscodeignore index 3c9c0e53af..e64d84d935 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -11,6 +11,7 @@ documentation/** node_modules/** scripts/** src/** +!src/lm/skills/** webviews/** **/test/** .eslintcache* @@ -21,16 +22,15 @@ webviews/** .gitignore-revs .prettierignore .prettierrc -.yarnrc +package-lock.json azure-pipeline.* **/tsconfig*.json *.tsbuildinfo **/webpack.config*.js -yarn.lock +npm-debug.log **/*.map **/*.svg -!**/output.svg -!**/pr_webview.svg +!**/*_webview.svg **/*.ts *.vsix **/*.bak diff --git a/.yarnrc b/.yarnrc deleted file mode 100644 index 123ac74a0a..0000000000 --- a/.yarnrc +++ /dev/null @@ -1 +0,0 @@ -ignore-engines true diff --git a/CHANGELOG.md b/CHANGELOG.md index 17c6227717..c515aa3b1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,386 @@ # Changelog +## 0.158.0 + +### Changes + +- Display verified badges for commits in the pull request timeline. +- Adopt the updated chat context API. + +### Fixes + +- Correctly account for required approving reviews when determining whether a pull request is ready to merge. + +## 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 diff --git a/azure-pipeline.nightly.yml b/azure-pipeline.nightly.yml index d4f1eeccb7..68099f3613 100644 --- a/azure-pipeline.nightly.yml +++ b/azure-pipeline.nightly.yml @@ -3,7 +3,7 @@ trigger: none pr: none schedules: - - cron: '0 4 * * Mon-Thu' + - cron: '0 4 * * Mon-Fri' displayName: Nightly Release Schedule always: true branches: @@ -34,11 +34,11 @@ extends: nodeVersion: "20.x" buildSteps: - - script: yarn install --frozen-lockfile --check-files + - script: npm ci displayName: Install dependencies retryCountOnTaskFailure: 3 - - script: yarn run bundle + - script: npm run bundle displayName: Compile - script: > @@ -51,11 +51,11 @@ extends: displayName: Override package.json testSteps: - - script: yarn install --frozen-lockfile --check-files + - script: npm ci displayName: Install dependencies retryCountOnTaskFailure: 3 - - script: yarn run bundle + - script: npm run bundle displayName: Compile - bash: | @@ -63,23 +63,23 @@ extends: echo ">>> Started xvfb" displayName: Start xvfb - - script: yarn run test + - script: npm run test displayName: Run test suite env: DISPLAY: ':99.0' TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/test-results.xml - # - script: yarn run browsertest --browserType=chromium + # - script: npm run browsertest --browserType=chromium # displayName: Run test suite (chromium) # env: # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-chromium-test-results.xml - # - script: yarn run browsertest --browserType=firefox + # - script: npm run browsertest --browserType=firefox # displayName: Run test suite (firefox) # env: # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-firefox-test-results.xml - # - script: yarn run browsertest --browserType=webkit + # - script: npm run browsertest --browserType=webkit # displayName: Run test suite (webkit) # env: # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-webkit-test-results.xml diff --git a/azure-pipeline.pr.yml b/azure-pipeline.pr.yml index 3f86e93a24..df40e8d2b9 100644 --- a/azure-pipeline.pr.yml +++ b/azure-pipeline.pr.yml @@ -2,11 +2,11 @@ jobs: - job: test_suite displayName: Test suite pool: - vmImage: 'macos-13' + vmImage: 'macos-15' steps: - template: scripts/ci/common-setup.yml - - script: yarn run compile + - script: npm run compile displayName: Compile - script: npm run hygiene @@ -15,22 +15,25 @@ jobs: - script: npm run lint displayName: Run lint - - script: yarn run test + - 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: yarn run browsertest --browserType=chromium + # - script: npm run browsertest --browserType=chromium # displayName: Run test suite (chromium) # env: # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-chromium-test-results.xml - # - script: yarn run browsertest --browserType=firefox + # - script: npm run browsertest --browserType=firefox # displayName: Run test suite (firefox) # env: # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-firefox-test-results.xml - # - script: yarn run browsertest --browserType=webkit + # - script: npm run browsertest --browserType=webkit # displayName: Run test suite (webkit) # env: # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-webkit-test-results.xml diff --git a/azure-pipeline.release.yml b/azure-pipeline.release.yml index 1f8bb2c0d6..94c8158f19 100644 --- a/azure-pipeline.release.yml +++ b/azure-pipeline.release.yml @@ -31,19 +31,19 @@ extends: nodeVersion: "20.x" buildSteps: - - script: yarn install --frozen-lockfile --check-files + - script: npm ci displayName: Install dependencies retryCountOnTaskFailure: 3 - - script: yarn run bundle + - script: npm run bundle displayName: Compile testSteps: - - script: yarn install --frozen-lockfile --check-files + - script: npm ci displayName: Install dependencies retryCountOnTaskFailure: 3 - - script: yarn run bundle + - script: npm run bundle displayName: Compile - bash: | @@ -51,23 +51,23 @@ extends: echo ">>> Started xvfb" displayName: Start xvfb - - script: yarn run test + - script: npm run test displayName: Run test suite env: DISPLAY: ':99.0' TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/test-results.xml - # - script: yarn run browsertest --browserType=chromium + # - script: npm run browsertest --browserType=chromium # displayName: Run test suite (chromium) # env: # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-chromium-test-results.xml - # - script: yarn run browsertest --browserType=firefox + # - script: npm run browsertest --browserType=firefox # displayName: Run test suite (firefox) # env: # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-firefox-test-results.xml - # - script: yarn run browsertest --browserType=webkit + # - script: npm run browsertest --browserType=webkit # displayName: Run test suite (webkit) # env: # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-webkit-test-results.xml diff --git a/build/filters.js b/build/filters.js index 7f8f92eaa1..95672b1ff2 100644 --- a/build/filters.js +++ b/build/filters.js @@ -25,6 +25,7 @@ module.exports.unicodeFilter = [ '**', // except specific files '!documentation/**/*', + '!src/@types/vscode.proposed*.d.ts', '!**/ThirdPartyNotices.txt', '!**/LICENSE.{txt,rtf}', '!**/LICENSE', @@ -47,6 +48,7 @@ module.exports.indentationFilter = [ // except multiple specific files '!**/package.json', + '!**/package-lock.json', '!**/yarn.lock', '!**/yarn-error.log', '!**/fixtures/**/*' @@ -64,11 +66,13 @@ module.exports.copyrightFilter = [ '!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/**' diff --git a/build/hygiene.js b/build/hygiene.js index 76df48120e..9f6b4c5bd0 100644 --- a/build/hygiene.js +++ b/build/hygiene.js @@ -26,6 +26,14 @@ function hygiene(some) { 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; @@ -54,7 +62,7 @@ function hygiene(some) { }); const indentation = es.through(function (file) { - const lines = file.__lines; + const lines = getFileLines(file); lines?.forEach((line, i) => { if (/^\s*$/.test(line)) { @@ -77,7 +85,7 @@ function hygiene(some) { }); const copyrights = es.through(function (file) { - const lines = file.__lines; + const lines = getFileLines(file); for (let i = 0; i < copyrightHeaderLines.length; i++) { if (lines[i] !== copyrightHeaderLines[i]) { 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/views.ts b/common/views.ts index 707db9097b..9e2f35fdf2 100644 --- a/common/views.ts +++ b/common/views.ts @@ -130,6 +130,7 @@ export interface CreateParamsNew { creating: boolean; reviewing: boolean; + usingTemplate: boolean; } export interface ChooseRemoteAndBranchArgs { @@ -165,6 +166,10 @@ export interface TitleAndDescriptionResult { description: string | undefined; } +export interface DescriptionResult { + description: string | undefined; +} + export interface CloseResult { state: GithubItemStateEnum; commentEvent?: CommentEvent; @@ -175,4 +180,11 @@ 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 72abd5f90e..de7db2dff0 100644 --- a/documentation/IssueFeatures.md +++ b/documentation/IssueFeatures.md @@ -111,3 +111,6 @@ Some of the settings (ex. `githubIssues.workingIssueFormatScm` and `githubIssues - `${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.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/releasing.md b/documentation/releasing.md index e76d24aace..9be934ecb4 100644 --- a/documentation/releasing.md +++ b/documentation/releasing.md @@ -3,6 +3,7 @@ 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/main/CHANGELOG.md) diff --git a/eslint.config.mjs b/eslint.config.mjs index 088acb5aee..2f509573d0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,6 +15,7 @@ export default defineConfig([ // Global ignore patterns { ignores: [ + 'build', 'dist/**/*', 'out/**/*', 'src/@types/**/*.d.ts', diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..b6aa9c1ca5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,12482 @@ +{ + "name": "vscode-pull-request-github", + "version": "0.158.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vscode-pull-request-github", + "version": "0.158.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.130.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 baa8497a14..961fc866c8 100644 --- a/package.json +++ b/package.json @@ -12,14 +12,16 @@ }, "enabledApiProposals": [ "activeComment", + "chatContextProvider", "chatParticipantAdditions", "chatParticipantPrivate", - "chatSessionsProvider@2", + "chatSessionsProvider", "codiconDecoration", "codeActionRanges", "commentingRangeHint", "commentReactor", "commentReveal", + "commentsDraftState", "commentThreadApplicability", "contribAccessibilityHelpContent", "contribCommentEditorActionsMenu", @@ -30,16 +32,20 @@ "contribShareMenu", "diffCommand", "languageModelToolResultAudience", + "markdownAlertSyntax", "quickDiffProvider", "remoteCodingAgents", "shareProvider", + "tabInputMultiDiff", "tokenInformation", + "treeItemMarkdownLabel", "treeViewMarkdownMessage" ], - "version": "0.120.0", + "version": "0.158.0", "publisher": "GitHub", "engines": { - "vscode": "^1.106.0" + "node": ">=20", + "vscode": "^1.130.0" }, "categories": [ "Other", @@ -57,7 +63,9 @@ "onFileSystem:githubcommit", "onFileSystem:review", "onWebviewPanel:IssueOverview", - "onWebviewPanel:PullRequestOverview" + "onWebviewPanel:PullRequestOverview", + "onChatContextProvider:githubpr", + "onChatContextProvider:githubissue" ], "browser": "./dist/browser/extension", "l10n": "./dist/browser/extension", @@ -69,36 +77,16 @@ "virtualWorkspaces": true }, "contributes": { - "chatSessions": [ - { - "type": "copilot-swe-agent", - "name": "copilot", - "displayName": "GitHub Copilot coding agent", - "description": "Delegate tasks to the GitHub Copilot coding agent. The agent works asynchronously to implement changes, iterates via chat, and can create or update pull requests as needed.", - "when": "config.chat.agentSessionsViewLocation && config.chat.agentSessionsViewLocation != 'disabled' && !config.github.copilot.chat.advanced.copilotCodingAgent.enabled", - "capabilities": { - "supportsFileAttachments": true - } - } - ], - "remoteCodingAgents": [ - { - "id": "githubCodingAgent", - "command": "githubpr.remoteAgent", - "displayName": "GitHub Copilot coding agent", - "description": "Copilot coding agent is a remote, autonomous software development agent. Developers delegate tasks to the agent, which iterates on pull requests based on feedback and reviews.", - "followUpRegex": "open-pull-request-webview.*((%7B.*?%7D)|(\\{.*?\\}))", - "when": "config.githubPullRequests.codingAgent.enabled && config.githubPullRequests.codingAgent.uiIntegration && copilotCodingAgentAssignable && !config.github.copilot.chat.advanced.copilotCodingAgent.enabled" - } - ], - "chatParticipants": [ + "chatContext": [ { "id": "githubpr", - "name": "githubpr", - "fullName": "GitHub Pull Requests", - "description": "Chat participant for GitHub Pull Requests extension", - "when": "config.githubPullRequests.experimental.chat", - "isSticky": true + "icon": "$(github)", + "displayName": "GitHub Pull Requests" + }, + { + "id": "githubissue", + "icon": "$(issues)", + "displayName": "GitHub Issues" } ], "configuration": { @@ -128,12 +116,14 @@ "enum": [ "template", "commit", + "branchName", "none", "Copilot" ], "enumDescriptions": [ "%githubPullRequests.pullRequestDescription.template%", "%githubPullRequests.pullRequestDescription.commit%", + "%githubPullRequests.pullRequestDescription.branchName%", "%githubPullRequests.pullRequestDescription.none%", "%githubPullRequests.pullRequestDescription.copilot%" ], @@ -191,6 +181,18 @@ }, "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": [ @@ -225,7 +227,7 @@ "default": [ { "label": "%githubPullRequests.queries.copilotOnMyBehalf%", - "query": "repo:${owner}/${repository} is:open author:copilot involves:${user}" + "query": "repo:${owner}/${repository} is:open author:copilot assignee:${user}" }, { "label": "Local Pull Request Branches", @@ -293,6 +295,11 @@ "default": false, "description": "%githubPullRequests.hideViewedFiles.description%" }, + "githubPullRequests.fileAutoReveal": { + "type": "boolean", + "default": true, + "description": "%githubPullRequests.fileAutoReveal.description%" + }, "githubPullRequests.defaultDeletionMethod.selectLocalBranch": { "type": "boolean", "default": true, @@ -303,6 +310,24 @@ "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": [ @@ -322,11 +347,13 @@ "type": "string", "enum": [ "never", - "ask" + "ask", + "always" ], "enumDescriptions": [ "%githubPullRequests.createOnPublishBranch.never%", - "%githubPullRequests.createOnPublishBranch.ask%" + "%githubPullRequests.createOnPublishBranch.ask%", + "%githubPullRequests.createOnPublishBranch.always%" ], "default": "ask", "description": "%githubPullRequests.createOnPublishBranch.description%" @@ -335,11 +362,13 @@ "type": "string", "enum": [ "expandUnresolved", - "collapseAll" + "collapseAll", + "collapsePreexisting" ], "enumDescriptions": [ "%githubPullRequests.commentExpandState.expandUnresolved%", - "%githubPullRequests.commentExpandState.collapseAll%" + "%githubPullRequests.commentExpandState.collapseAll%", + "%githubPullRequests.commentExpandState.collapsePreexisting%" ], "default": "expandUnresolved", "description": "%githubPullRequests.commentExpandState.description%" @@ -458,13 +487,17 @@ "type": "string", "enum": [ "checkoutDefaultBranch", - "checkoutDefaultBranchAndPull" + "checkoutDefaultBranchAndPull", + "checkoutPullRequestBaseBranch", + "checkoutPullRequestBaseBranchAndPull" ], "description": "%githubPullRequests.postDone.description%", "default": "checkoutDefaultBranch", "enumDescriptions": [ "%githubPullRequests.postDone.checkoutDefaultBranch%", - "%githubPullRequests.postDone.checkoutDefaultBranchAndPull%" + "%githubPullRequests.postDone.checkoutDefaultBranchAndPull%", + "%githubPullRequests.postDone.checkoutPullRequestBaseBranch%", + "%githubPullRequests.postDone.checkoutPullRequestBaseBranchAndPull%" ] }, "githubPullRequests.defaultCommentType": { @@ -594,6 +627,11 @@ "markdownDescription": "%githubPullRequests.webviewRefreshInterval.description%", "default": 60 }, + "githubPullRequests.devMode": { + "type": "boolean", + "markdownDescription": "%githubPullRequests.devMode.description%", + "default": false + }, "githubIssues.ignoreMilestones": { "type": "array", "default": [], @@ -614,11 +652,6 @@ ], "description": "%githubIssues.createIssueTriggers.description%" }, - "githubPullRequests.codingAgent.codeLens": { - "type": "boolean", - "default": true, - "description": "%githubPullRequests.codingAgent.codeLens.description%" - }, "githubIssues.createInsertFormat": { "type": "string", "enum": [ @@ -693,11 +726,31 @@ "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.issueCompletionFormatEditor": { + "type": "string", + "default": "${issueNumberLabel}", + "markdownDescription": "%githubIssues.issueCompletionFormatEditor.markdownDescription%" + }, "githubIssues.workingIssueFormatScm": { "type": "string", "default": "${issueTitle} \nFixes ${issueNumberLabel}", @@ -763,15 +816,24 @@ "type": "string", "enum": [ "author", - "assignee" + "assignee", + "state", + "generic" ], "enumDescriptions": [ "%githubIssues.issueAvatarDisplay.author%", - "%githubIssues.issueAvatarDisplay.assignee%" + "%githubIssues.issueAvatarDisplay.assignee%", + "%githubIssues.issueAvatarDisplay.state%", + "%githubIssues.issueAvatarDisplay.generic%" ], "default": "author", "description": "%githubIssues.issueAvatarDisplay.description%" }, + "githubIssues.showIssueNumberInTree": { + "type": "boolean", + "default": false, + "description": "%githubIssues.showIssueNumberInTree.description%" + }, "githubPullRequests.focusedMode": { "properties": { "oneOf": [ @@ -803,6 +865,21 @@ "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, @@ -810,6 +887,16 @@ } } }, + "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": [ { @@ -912,11 +999,6 @@ ] }, "commands": [ - { - "command": "githubpr.remoteAgent", - "title": "%command.githubpr.remoteAgent.title%", - "enablement": "config.githubPullRequests.codingAgent.enabled" - }, { "command": "github.api.preloadPullRequest", "title": "Preload Pull Request", @@ -953,6 +1035,24 @@ "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%", @@ -979,6 +1079,21 @@ "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%", @@ -1000,6 +1115,11 @@ "title": "%command.pr.openFileOnGitHub.title%", "category": "%command.pull.request.category%" }, + { + "command": "pr.revealFileInOS", + "title": "%command.pr.revealFileInOS.title%", + "category": "%command.pull.request.category%" + }, { "command": "pr.copyCommitHash", "title": "%command.pr.copyCommitHash.title%", @@ -1153,6 +1273,12 @@ "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%", @@ -1373,17 +1499,34 @@ "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%", @@ -1421,6 +1564,11 @@ "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%", @@ -1505,6 +1653,12 @@ "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%", @@ -1792,42 +1946,14 @@ "category": "%command.notifications.category%", "icon": "$(gear)" }, - { - "command": "codingAgent.openSessionLog", - "title": "%command.codingAgent.openSessionLog.title%", - "category": "%command.pull.request.category%" - }, - { - "command": "pr.refreshChatSessions", - "title": "%command.pr.refreshChatSessions.title%", - "icon": "$(refresh)", - "category": "%command.pull.request.category%" - }, - { - "command": "pr.preferredCodingAgentGitHubRemote", - "title": "%command.pr.preferredCodingAgentGitHubRemote.title%", - "icon": "$(gear)", - "enablement": "github:hasMultipleGitHubRemotes", - "category": "%command.pull.request.category%" - }, - { - "command": "pr.resetCodingAgentPreferences", - "title": "%command.pr.resetCodingAgentPreferences.title%", - "category": "%command.pull.request.category%" - }, { "command": "pr.checkoutChatSessionPullRequest", "title": "%command.pr.checkoutChatSessionPullRequest.title%", "category": "%command.pull.request.category%" }, { - "command": "pr.closeChatSessionPullRequest", - "title": "%command.pr.closeChatSessionPullRequest.title%", - "category": "%command.pull.request.category%" - }, - { - "command": "pr.cancelCodingAgent", - "title": "%command.pr.cancelCodingAgent.title%", + "command": "pr.generateTitleAndDescription", + "title": "%command.pr.generateTitleAndDescription.title%", "category": "%command.pull.request.category%" } ], @@ -1936,31 +2062,6 @@ "view": "notifications:github", "when": "ReposManagerStateContext == RepositoriesLoaded && github:notificationCount == -1", "contents": "%welcome.github.notifications.contents%" - }, - { - "view": "workbench.view.chat.sessions.copilot-swe-agent", - "when": "workspaceFolderCount == 0", - "contents": "%welcome.pr.github.noFolder.contents%" - }, - { - "view": "workbench.view.chat.sessions.copilot-swe-agent", - "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount == 0", - "contents": "%welcome.pr.github.noRepo.contents%" - }, - { - "view": "workbench.view.chat.sessions.copilot-swe-agent", - "when": "git.state == initialized && workspaceFolderCount > 0 && (git.parentRepositoryCount > 0 || gitOpenRepositoryCount > 0) && !github:hasGitHubRemotes", - "contents": "%welcome.chat.sessions.copilot-swe-agent.noGitHub.contents%" - }, - { - "view": "workbench.view.chat.sessions.copilot-swe-agent", - "when": "ReposManagerStateContext == NeedsAuthentication && github:hasGitHubRemotes", - "contents": "%welcome.chat.sessions.copilot-swe-agent.login.contents%" - }, - { - "view": "workbench.view.chat.sessions.copilot-swe-agent", - "when": "ReposManagerStateContext != NeedsAuthentication && github:hasGitHubRemotes", - "contents": "%welcome.chat.sessions.copilot-swe-agent.startSession.contents%" } ], "keybindings": [ @@ -1986,6 +2087,18 @@ "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": { @@ -1995,8 +2108,8 @@ "when": "false" }, { - "command": "githubpr.remoteAgent", - "when": "false" + "command": "pr.addAttestationCommit", + "when": "config.githubPullRequests.enableAttestationCommits" }, { "command": "pr.configureRemotes", @@ -2022,6 +2135,18 @@ "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" @@ -2060,7 +2185,19 @@ }, { "command": "pr.readyForReview", - "when": "gitHubOpenRepositoryCount != 0 && github:inReviewMode" + "when": "false" + }, + { + "command": "pr.readyForReviewDescription", + "when": "false" + }, + { + "command": "pr.readyForReviewAndMergeDescription", + "when": "false" + }, + { + "command": "pr.readyForReviewAndMerge", + "when": "false" }, { "command": "pr.openPullRequestOnGitHub", @@ -2078,6 +2215,10 @@ "command": "pr.openFileOnGitHub", "when": "false" }, + { + "command": "pr.revealFileInOS", + "when": "false" + }, { "command": "pr.openOriginalFile", "when": "false" @@ -2138,10 +2279,18 @@ "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" @@ -2254,6 +2403,10 @@ "command": "pr.makeSuggestion", "when": "false" }, + { + "command": "pr.uploadFile", + "when": "false" + }, { "command": "pr.startReview", "when": "false" @@ -2619,7 +2772,7 @@ "when": "github:inReviewMode" }, { - "command": "pr.preferredCodingAgentGitHubRemote", + "command": "pr.generateTitleAndDescription", "when": "false" } ], @@ -2773,16 +2926,6 @@ "command": "notifications.refresh", "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == notifications:github", "group": "navigation@1" - }, - { - "command": "pr.refreshChatSessions", - "when": "view == workbench.view.chat.sessions.copilot-swe-agent", - "group": "navigation@1" - }, - { - "command": "pr.preferredCodingAgentGitHubRemote", - "when": "github:hasMultipleGitHubRemotes && view == workbench.view.chat.sessions.copilot-swe-agent", - "group": "overflow@1" } ], "view/item/context": [ @@ -2801,9 +2944,19 @@ "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)/ && config.multiDiffEditor.experimental.enabled", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /(pullrequest|description)/", "group": "2_pullrequest@1" }, { @@ -2838,7 +2991,7 @@ }, { "command": "issue.chatSummarizeIssue", - "when": "view == pr:github && viewItem =~ /pullrequest/ && github.copilot-chat.activated && config.githubPullRequests.experimental.chat", + "when": "view == pr:github && viewItem =~ /pullrequest/ && github.copilot-chat.activated && config.githubPullRequests.experimental.chat && !config.chat.disableAIFeatures", "group": "5_pullrequest@2" }, { @@ -2848,7 +3001,7 @@ }, { "command": "pr.openChanges", - "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /(pullrequest|description)/ && config.multiDiffEditor.experimental.enabled", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /(pullrequest|description)/", "group": "inline@0" }, { @@ -2864,32 +3017,32 @@ { "command": "notification.chatSummarizeNotification", "group": "issues_0@0", - "when": "view == notifications:github && (viewItem == 'Issue' || viewItem == 'PullRequest') && config.githubPullRequests.experimental.notificationsView && config.githubPullRequests.experimental.chat" + "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') && config.githubPullRequests.experimental.notificationsView" + "when": "view == notifications:github && (viewItem == 'Issue' || viewItem == 'PullRequest')" }, { "command": "notification.markAsRead", "group": "inline@3", - "when": "view == notifications:github && (viewItem == 'Issue' || viewItem == 'PullRequest') && config.githubPullRequests.experimental.notificationsView" + "when": "view == notifications:github && (viewItem == 'Issue' || viewItem == 'PullRequest')" }, { "command": "notification.markAsRead", "group": "issues_0@2", - "when": "view == notifications:github && (viewItem == 'Issue' || viewItem == 'PullRequest') && config.githubPullRequests.experimental.notificationsView" + "when": "view == notifications:github && (viewItem == 'Issue' || viewItem == 'PullRequest')" }, { "command": "notification.markAsDone", "group": "inline@4", - "when": "view == notifications:github && (viewItem == 'Issue' || viewItem == 'PullRequest') && config.githubPullRequests.experimental.notificationsView" + "when": "view == notifications:github && (viewItem == 'Issue' || viewItem == 'PullRequest')" }, { "command": "notification.markAsDone", "group": "issues_0@3", - "when": "view == notifications:github && (viewItem == 'Issue' || viewItem == 'PullRequest') && config.githubPullRequests.experimental.notificationsView" + "when": "view == notifications:github && (viewItem == 'Issue' || viewItem == 'PullRequest')" }, { "command": "pr.openPullRequestOnGitHub", @@ -2915,15 +3068,20 @@ "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@1" + "group": "0_open@2" }, { "command": "pr.openModifiedFile", "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /filechange:MODIFY/", - "group": "0_open@2" + "group": "0_open@3" }, { "command": "review.diffWithPrHead", @@ -3007,17 +3165,17 @@ }, { "command": "issue.chatSummarizeIssue", - "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/ && github.copilot-chat.activated && config.githubPullRequests.experimental.chat", + "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", + "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", + "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/ && config.githubPullRequests.codingAgent.enabled && !config.chat.disableAIFeatures", "group": "issues_1@2" }, { @@ -3070,6 +3228,11 @@ "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": [ @@ -3141,6 +3304,13 @@ "group": "1_modification@5" } ], + "scm/repository": [ + { + "command": "pr.create", + "when": "scmProvider =~ /^git|^remoteHub:github/ && scmProviderRootUri in github:reposNotInReviewMode", + "group": "inline" + } + ], "comments/commentThread/context": [ { "command": "pr.createComment", @@ -3173,6 +3343,11 @@ "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": [ @@ -3219,7 +3394,7 @@ }, { "command": "pr.applySuggestionWithCopilot", - "when": "commentController =~ /^github-review/ && !(comment =~ /hasSuggestion/)" + "when": "commentController =~ /^github-review/ && !config.chat.disableAIFeatures" } ], "comments/comment/title": [ @@ -3231,7 +3406,7 @@ { "command": "pr.applySuggestionWithCopilot", "group": "overflow@0", - "when": "commentController =~ /^github-review/ && !(comment =~ /hasSuggestion/)" + "when": "commentController =~ /^github-review/ && !config.chat.disableAIFeatures" }, { "command": "pr.editComment", @@ -3468,16 +3643,19 @@ "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", + "group": "1_review@1" }, { - "command": "review.comment", - "when": "webviewId == 'github:activePullRequest' && github:reviewCommentMenu && github:reviewCommentComment" + "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" + "when": "webviewId == 'github:activePullRequest' && github:reviewCommentMenu && github:reviewCommentRequestChanges", + "group": "1_review@3" }, { "command": "review.approveOnDotCom", @@ -3488,16 +3666,19 @@ "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", + "group": "1_review@1" }, { - "command": "review.commentDescription", - "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentComment" + "command": "review.approveDescription", + "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentApprove", + "group": "1_review@2" }, { "command": "review.requestChangesDescription", - "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentRequestChanges" + "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentRequestChanges", + "group": "1_review@3" }, { "command": "review.approveOnDotComDescription", @@ -3509,53 +3690,75 @@ }, { "command": "pr.copyPrLink", - "when": "webviewId == PullRequestOverview && github:copyMenu" + "when": "(webviewId == PullRequestOverview || webviewId == IssueOverview) && github:copyMenu" }, { "command": "pr.copyVscodeDevPrLink", "when": "webviewId == PullRequestOverview && github:copyMenu" }, + { + "command": "pr.readyForReviewDescription", + "when": "(webviewId == PullRequestOverview) && github:readyForReviewMenu" + }, + { + "command": "pr.readyForReviewAndMergeDescription", + "when": "(webviewId == PullRequestOverview) && github:readyForReviewMenu && github:readyForReviewMenuWithMerge" + }, + { + "command": "pr.readyForReview", + "when": "(webviewId == 'github:activePullRequest') && github:readyForReviewMenu" + }, + { + "command": "pr.readyForReviewAndMerge", + "when": "(webviewId == 'github:activePullRequest') && github:readyForReviewMenu && github:readyForReviewMenuWithMerge" + }, { "command": "pr.openChanges", "group": "checkout@0", "when": "webviewId == PullRequestOverview && github:checkoutMenu" }, { - "command": "pr.checkoutOnVscodeDevFromDescription", + "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-swe-agent", + "when": "chatSessionType == copilot-cloud-agent && !config.chat.disableAIFeatures", "group": "inline" }, { "command": "pr.checkoutChatSessionPullRequest", - "when": "chatSessionType == copilot-swe-agent", - "group": "context" - }, - { - "command": "pr.closeChatSessionPullRequest", - "when": "chatSessionType == copilot-swe-agent", - "group": "context" - }, - { - "command": "pr.cancelCodingAgent", - "when": "chatSessionType == copilot-swe-agent", + "when": "chatSessionType == copilot-cloud-agent && !config.chat.disableAIFeatures", "group": "context" } ], - "chat/multiDiff/context": [ + "chat/input/editing/sessionToolbar": [ { - "command": "pr.checkoutFromDescription", - "when": "chatSessionType == copilot-swe-agent" + "command": "pr.checkoutFromChat", + "group": "navigation@0", + "when": "chatSessionType == copilot-cloud-agent && workspaceFolderCount > 0 && github.vscode-pull-request-github.activated && !config.chat.disableAIFeatures" }, { - "command": "pr.applyChangesFromDescription", - "when": "chatSessionType == copilot-swe-agent" + "command": "pr.applyChangesFromChat", + "group": "navigation@1", + "when": "chatSessionType == copilot-cloud-agent && workspaceFolderCount > 0 && github.vscode-pull-request-github.activated && !config.chat.disableAIFeatures" } ] }, @@ -3590,6 +3793,16 @@ }, "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": { @@ -3652,38 +3865,27 @@ } } ], - "languageModelTools": [ + "chatSkills": [ { - "name": "github-pull-request_copilot-coding-agent", - "displayName": "%languageModelTools.github-pull-request_copilot-coding-agent.displayName%", - "modelDescription": "Completes the provided task using an asynchronous coding agent. Use when the user wants copilot continue completing a task in the background or asynchronously. IMPORTANT: Use this tool LAST/FINAL when users mention '#github-pull-request_copilot-coding-agent' in their query. This indicates they want the task/job implemented by the remote coding agent after all other analysis, planning, and preparation is complete. Call this tool at the END to hand off the fully-scoped task to the asynchronous GitHub Copilot coding agent. The agent will create a new branch, implement the changes, and open a pull request. Always use this tool as the final step when the hashtag is mentioned, after completing any other necessary tools or analysis first.", - "when": "config.githubPullRequests.codingAgent.enabled", - "icon": "$(send-to-remote-agent)", - "canBeReferencedInPrompt": true, - "toolReferenceName": "copilotCodingAgent", - "userDescription": "%languageModelTools.github-pull-request_copilot-coding-agent.userDescription%", - "inputSchema": { - "type": "object", - "required": [ - "title", - "body" - ], - "properties": { - "title": { - "type": "string", - "description": "The title of the issue. Populate from chat context." - }, - "body": { - "type": "string", - "description": "The body/description of the issue. Populate from chat context." - }, - "existingPullRequest": { - "type": "number", - "description": "The number of an existing pull request related to the current coding agent task. Look in the chat history for this number. In the chat it may look like 'Coding agent will continue work in #17...'. In this example, you should return '17'." - } - } - } + "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": [ @@ -3693,6 +3895,7 @@ ], "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, @@ -3729,228 +3932,80 @@ "when": "config.githubPullRequests.experimental.chat" }, { - "name": "github-pull-request_notification_fetch", - "tags": [ - "github", - "notification" - ], - "toolReferenceName": "notification_fetch", - "displayName": "%languageModelTools.github-pull-request_notification_fetch.displayName%", - "modelDescription": "Get a GitHub notification's details as a JSON object.", - "icon": "$(info)", - "canBeReferencedInPrompt": false, - "inputSchema": { - "type": "object", - "properties": { - "thread_id": { - "type": "string", - "description": "The notification thread id." - } - }, - "required": [ - "thread_id" - ] - }, - "when": "config.githubPullRequests.experimental.chat" - }, - { - "name": "github-pull-request_issue_summarize", + "name": "github-pull-request_labels_fetch", "tags": [ "github", - "issues", - "prs" + "labels" ], - "toolReferenceName": "issue_summarize", - "displayName": "%languageModelTools.github-pull-request_issue_summarize.displayName%", - "modelDescription": "Summarizes a GitHub issue or pull request. A summary is a great way to describe an issue or pull request.", - "icon": "$(info)", - "canBeReferencedInPrompt": false, + "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": { - "title": { - "type": "string", - "description": "The title of the issue/PR" - }, - "body": { - "type": "string", - "description": "The body of the issue/PR" - }, - "owner": { - "type": "string", - "description": "The owner of the repo in which the issue/PR is located" - }, "repo": { - "type": "string", - "description": "The repo in which the issue/PR is located" - }, - "comments": { - "type": "array", - "items": { - "type": "object", - "properties": { - "body": { - "type": "string", - "description": "The comment body" - }, - "author": { - "type": "string", - "description": "The author of the comment" - } - } - }, - "description": "The array of associated string comments" - }, - "fileChanges": { - "type": "array", - "items": { - "type": "object", - "properties": { - "fileName": { - "type": "string", - "description": "The name of the file of the change" - }, - "patch": { - "type": "string", - "description": "The patch of the change" - } + "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." } }, - "description": "For a PR, the array of associated file changes" + "required": [ + "owner", + "name" + ] } - }, - "required": [ - "title", - "body", - "comments", - "owner", - "repo" - ] + } }, "when": "config.githubPullRequests.experimental.chat" }, { - "name": "github-pull-request_notification_summarize", + "name": "github-pull-request_notification_fetch", "tags": [ "github", "notification" ], - "toolReferenceName": "notification_summarize", - "displayName": "%languageModelTools.github-pull-request_notification_summarize.displayName%", - "modelDescription": "Summarizes a GitHub notification. A summary is a great way to describe a notification.", + "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": false, + "canBeReferencedInPrompt": true, "inputSchema": { "type": "object", "properties": { - "lastReadAt": { - "type": "string", - "description": "The last read time of the notification." - }, - "lastUpdatedAt": { - "type": "string", - "description": "The last updated time of the notification." - }, - "unread": { - "type": "boolean", - "description": "Whether the notification is unread." - }, - "title": { - "type": "string", - "description": "The title of the notification issue/PR" - }, - "body": { - "type": "string", - "description": "The body of the notification issue/PR" - }, - "owner": { - "type": "string", - "description": "The owner of the repo in which the issue/PR is located" - }, - "repo": { - "type": "string", - "description": "The repo in which the issue/PR is located" - }, - "comments": { - "type": "array", - "items": { - "type": "object", - "properties": { - "body": { - "type": "string", - "description": "The comment body" - }, - "author": { - "type": "string", - "description": "The author of the comment" - } - } - }, - "description": "The array of unread comments under the issue/PR of the notification" - }, - "threadId": { - "type": "number", - "description": "The thread id of the notification" - }, - "notificationKey": { - "type": "string", - "description": "The key of the notification" - }, - "itemNumber": { - "type": "string", - "description": "The number of the issue/PR in the notification" - }, - "itemType": { + "thread_id": { "type": "string", - "description": "The type of the item in the notification - whether it is an issue or a PR" - }, - "fileChanges": { - "type": "array", - "items": { - "type": "object", - "properties": { - "fileName": { - "type": "string", - "description": "The name of the file of the change" - }, - "patch": { - "type": "string", - "description": "The patch of the change" - } - }, - "required": [ - "fileName", - "patch" - ] - }, - "description": "For a notification about a PR, the array of associated file changes" + "description": "The notification thread id." } }, "required": [ - "title", - "comments", - "lastUpdatedAt", - "unread", - "threadId", - "notificationKey", - "owner", - "repo", - "itemNumber", - "itemType" + "thread_id" ] }, "when": "config.githubPullRequests.experimental.chat" }, { - "name": "github-pull-request_suggest-fix", + "name": "github-pull-request_doSearch", "tags": [ "github", - "issues" + "issues", + "search" ], - "toolReferenceName": "suggest-fix", - "displayName": "%languageModelTools.github-pull-request_suggest-fix.displayName%", - "modelDescription": "Summarize and suggest a fix for a GitHub issue.", - "icon": "$(info)", + "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", @@ -3973,46 +4028,68 @@ "name" ] }, - "issueNumber": { - "type": "number", - "description": "The number of the issue to get." + "query": { + "type": "string", + "description": "A well formed GitHub search query using proper GitHub search syntax." } }, "required": [ - "issueNumber", + "query", "repo" ] }, "when": "config.githubPullRequests.experimental.chat" }, { - "name": "github-pull-request_formSearchQuery", + "name": "github-pull-request_currentActivePullRequest", "tags": [ "github", - "issues", - "search", - "query", - "natural language" + "pull request" ], - "toolReferenceName": "searchSyntax", - "displayName": "%languageModelTools.github-pull-request_formSearchQuery.displayName%", - "modelDescription": "Converts natural language to a GitHub search query. Should ALWAYS be called before doing a search.", - "icon": "$(search)", + "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 issue from.", + "description": "The repository to get the pull request status checks from.", "properties": { "owner": { "type": "string", - "description": "The owner of the repository to get the issue from." + "description": "The owner of the repository." }, "name": { "type": "string", - "description": "The name of the repository to get the issue from." + "description": "The name of the repository." } }, "required": [ @@ -4020,221 +4097,124 @@ "name" ] }, - "naturalLanguageString": { - "type": "string", - "description": "A plain text description of what the search should be." + "pullRequestNumber": { + "type": "number", + "description": "The number of the pull request to get status checks for." } }, "required": [ - "naturalLanguageString" + "pullRequestNumber" ] }, + "userDescription": "%languageModelTools.github-pull-request_pullRequestStatusChecks.description%", "when": "config.githubPullRequests.experimental.chat" }, { - "name": "github-pull-request_doSearch", + "name": "github-pull-request_pullRequestInViewport", "tags": [ "github", - "issues", - "search" + "pull request" ], - "toolReferenceName": "doSearch", - "displayName": "%languageModelTools.github-pull-request_doSearch.displayName%", - "modelDescription": "Execute a GitHub search given a well formed GitHub search query. Call github-pull-request_formSearchQuery first to get good search syntax and pass the exact result in as the 'query'.", - "icon": "$(search)", + "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 get the issue from.", + "description": "The repository to create the pull request in.", "properties": { "owner": { "type": "string", - "description": "The owner of the repository to get the issue from." + "description": "The owner of the repository." }, "name": { "type": "string", - "description": "The name of the repository to get the issue from." + "description": "The name of the repository." } - }, - "required": [ - "owner", - "name" - ] + } }, - "query": { + "title": { "type": "string", - "description": "A well formed GitHub search query using proper GitHub search syntax." + "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": [ - "query", - "repo" + "title", + "head" ] }, "when": "config.githubPullRequests.experimental.chat" }, { - "name": "github-pull-request_renderIssues", + "name": "github-pull-request_resolveReviewThread", "tags": [ "github", - "issues", - "render", - "display" + "pull request", + "review" ], - "toolReferenceName": "renderIssues", - "displayName": "%languageModelTools.github-pull-request_renderIssues.displayName%", - "modelDescription": "Render issue items from an issue search in a markdown table. The markdown table will be displayed directly to the user by the tool. No further display should be done after this!", - "icon": "$(paintcan)", + "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": { - "arrayOfIssues": { - "type": "array", - "description": "An array of GitHub Issues.", - "items": { - "type": "object", - "properties": { - "title": { - "type": "string", - "description": "The title of the issue." - }, - "number": { - "type": "number", - "description": "The number of the issue." - }, - "url": { - "type": "string", - "description": "The URL of the issue." - }, - "state": { - "type": "string", - "description": "The state of the issue (open/closed)." - }, - "createdAt": { - "type": "string", - "description": "The creation date of the issue." - }, - "updatedAt": { - "type": "string", - "description": "The last update date of the issue." - }, - "closedAt": { - "type": "string", - "description": "The closing date of the issue." - }, - "author": { - "type": "object", - "description": "The author of the issue.", - "properties": { - "login": { - "type": "string", - "description": "The login of the author." - }, - "url": { - "type": "string", - "description": "The URL of the author's profile." - } - } - }, - "labels": { - "type": "array", - "description": "The labels associated with the issue.", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "The name of the label." - }, - "color": { - "type": "string", - "description": "The color of the label." - } - } - } - }, - "assignees": { - "type": "array", - "description": "The assignees of the issue.", - "items": { - "type": "object", - "properties": { - "login": { - "type": "string", - "description": "The login of the assignee." - }, - "url": { - "type": "string", - "description": "The URL of the assignee's profile." - } - } - } - }, - "commentsCount": { - "type": "number", - "description": "The number of comments on the issue." - } - }, - "required": [ - "title", - "number", - "url", - "state", - "createdAt", - "author", - "commentCount", - "reactionCount" - ] - } - }, - "totalIssues": { - "type": "number", - "description": "The total number of issues in the search." + "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": [ - "arrayOfIssues", - "totalIssues" + "threadId" ] - }, - "when": "config.githubPullRequests.experimental.chat" - }, - { - "name": "github-pull-request_activePullRequest", - "tags": [ - "github", - "pull request" - ], - "toolReferenceName": "activePullRequest", - "displayName": "%languageModelTools.github-pull-request_activePullRequest.displayName%", - "modelDescription": "Get comprehensive information about the active GitHub pull request (PR). The active PR is the one that is currently checked out. This includes the PR title, full description, list of changed files, review comments, PR state, and status checks/CI results. For PRs created by Copilot, it also includes the session logs which indicate the development process and decisions made by the coding agent. When asked about the active or current pull request, do this first! Use this tool for any request related to \"current changes,\" \"pull request details,\" \"what changed,\" \"PR status,\" or similar queries even if the user does not explicitly mention \"pull request.\" When asked to use this tool, ALWAYS use it.", - "icon": "$(git-pull-request)", - "canBeReferencedInPrompt": true, - "userDescription": "%languageModelTools.github-pull-request_activePullRequest.description%", - "when": "config.githubPullRequests.experimental.chat" - }, - { - "name": "github-pull-request_openPullRequest", - "tags": [ - "github", - "pull request" - ], - "toolReferenceName": "openPullRequest", - "displayName": "%languageModelTools.github-pull-request_openPullRequest.displayName%", - "modelDescription": "Get comprehensive information about the GitHub pull request (PR) which is currently visible, but not necessarily checked out. This includes the PR title, full description, list of changed files, review comments, PR state, and status checks/CI results. For PRs created by Copilot, it also includes the session logs which indicate the development process and decisions made by the coding agent. When asked about the currently open pull request, do this first! Use this tool for any request related to \"pull request details,\" \"what changed,\" \"PR status,\" or similar queries even if the user does not explicitly mention \"pull request.\" When asked to use this tool, ALWAYS use it.", - "icon": "$(git-pull-request)", - "canBeReferencedInPrompt": true, - "userDescription": "%languageModelTools.github-pull-request_openPullRequest.description%", - "when": "config.githubPullRequests.experimental.chat" + } } ] }, "scripts": { - "postinstall": "yarn update-dts", + "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", @@ -4245,11 +4225,11 @@ "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 --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", + "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": "yarn run browsertest:preprocess && node ./dist/browser/test/runTests.js", + "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", @@ -4257,16 +4237,19 @@ "watch": "webpack --watch --mode development --env esbuild", "watch:web": "webpack --watch --mode development --config-name extension:webworker --config-name webviews", "hygiene": "node ./build/hygiene.js", - "prepare": "husky install" + "check:commands": "node scripts/check-commands.js", + "prepare": "husky install", + "update:codicons": "npx ts-node --project tsconfig.scripts.json build/update-codicons.ts" }, "devDependencies": { "@eslint/js": "^9.36.0", "@shikijs/monaco": "^3.7.0", "@types/chai": "^4.1.4", "@types/glob": "7.1.3", + "@types/js-yaml": "^4.0.9", "@types/lru-cache": "^5.1.0", "@types/marked": "^0.7.2", - "@types/mocha": "^8.2.2", + "@types/mocha": "^10.0.10", "@types/node": "22", "@types/react": "^16.8.4", "@types/react-dom": "^16.8.2", @@ -4285,7 +4268,7 @@ "constants-browserify": "^1.0.0", "crypto-browserify": "3.12.0", "css-loader": "7.1.2", - "esbuild-loader": "4.2.2", + "esbuild-loader": "4.5.0", "eslint": "^9.36.0", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "2.31.0", @@ -4298,13 +4281,13 @@ "graphql-tag": "2.11.0", "gulp-filter": "^7.0.0", "husky": "^8.0.1", - "jsdom": "19.0.0", + "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": "^9.0.1", + "mocha": "^11.7.5", "mocha-junit-reporter": "1.23.0", "mocha-multi-reporters": "1.1.7", "os-browserify": "^0.3.0", @@ -4318,15 +4301,16 @@ "style-loader": "4.0.0", "svg-inline-loader": "^0.8.2", "temp": "0.9.4", - "terser-webpack-plugin": "5.1.1", + "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.94.0", + "webpack": "5.104.1", "webpack-cli": "4.2.0" }, "dependencies": { @@ -4334,7 +4318,7 @@ "@octokit/rest": "22.0.0", "@octokit/types": "14.1.0", "@vscode/codicons": "^0.0.36", - "@vscode/extension-telemetry": "0.7.5", + "@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", @@ -4344,10 +4328,10 @@ "debounce": "^1.2.1", "events": "3.2.0", "fast-deep-equal": "^3.1.3", + "js-yaml": "^4.2.0", "jsonc-parser": "^3.3.1", - "jszip": "^3.10.1", "lru-cache": "6.0.0", - "markdown-it": "^14.1.0", + "markdown-it": "^14.2.0", "marked": "^4.0.10", "react": "^16.12.0", "react-dom": "^16.12.0", @@ -4356,10 +4340,17 @@ "temporal-polyfill": "^0.3.0", "tunnel": "0.0.6", "url-search-params-polyfill": "^8.1.1", - "uuid": "8.3.2", + "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" }, diff --git a/package.nls.json b/package.nls.json index fab85ddf89..27e1241c71 100644 --- a/package.nls.json +++ b/package.nls.json @@ -2,9 +2,10 @@ "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.none": "Do not have a default description", + "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.", @@ -12,7 +13,7 @@ "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 as GitHub 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": [ @@ -23,9 +24,12 @@ "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.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.", @@ -37,18 +41,24 @@ "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.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.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\".", @@ -74,38 +84,40 @@ "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.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.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.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. Can also be set to additionally merge updates from the base branch.", + "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.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.", - "githubIssues.ignoreMilestones.description": "An array of milestones titles to never show issues from.", + "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.", - "githubPullRequests.codingAgent.codeLens.description": "Show CodeLens actions above TODO comments for delegating to coding agent.", "githubIssues.createInsertFormat.description": "Controls whether an issue number (ex. #1234) or a full url (ex. https://github.com/owner/name/issues/1234) is inserted when the Create Issue code action is run.", "githubIssues.issueCompletions.enabled.description": "Controls whether completion suggestions are shown for issues.", "githubIssues.userCompletions.enabled.description": "Controls whether completion suggestions are shown for users.", @@ -114,7 +126,7 @@ "githubIssues.ignoreUserCompletionTrigger.description": "Languages that the '@' character should not be used to trigger user completion suggestions.", "githubIssues.ignoreUserCompletionTrigger.items": "Language that user completions should not trigger on '@'.", "githubIssues.issueBranchTitle.markdownDescription": { - "message": "Advanced settings for the name of the branch that is created when you start working on an issue. \n- `${user}` will be replaced with the currently logged in username \n- `${issueNumber}` will be replaced with the current issue number \n- `${sanitizedIssueTitle}` will be replaced with the issue title, with all spaces and unsupported characters (https://git-scm.com/docs/git-check-ref-format) removed. For lowercase, use `${sanitizedLowercaseIssueTitle}` ", + "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" @@ -130,12 +142,27 @@ "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.issueCompletionFormatEditor.markdownDescription": { + "message": "Sets the format of issue completions in the editor. \n- `${user}` will be replaced 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 \n- `${issueTitle}` will be replaced with the issue title", + "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": [ @@ -145,7 +172,7 @@ "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": "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", @@ -153,8 +180,9 @@ "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)", + "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).", + "githubIssues.showIssueNumberInTree.description": "Shows the issue number in the tree view.", "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.", @@ -162,7 +190,13 @@ "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 value of label that you want to add", + "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", @@ -184,20 +218,24 @@ "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.githubpr.remoteAgent.title": "Remote agent integration", "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": "Mark Pull Request Ready For Review", + "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", @@ -216,12 +254,12 @@ "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.refreshChatSessions.title": "Refresh Chat Sessions", "command.pr.setFileListLayoutAsTree.title": "View as Tree", "command.pr.setFileListLayoutAsFlat.title": "View as List", "command.pr.toggleHideViewedFiles.title": "Toggle Hide Viewed Files", @@ -234,6 +272,7 @@ "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", @@ -257,7 +296,7 @@ "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 With Copilot", + "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", @@ -285,10 +324,14 @@ "command.pr.toggleEditorCommentingOn.title": "Toggle Editor Commenting On", "command.pr.toggleEditorCommentingOff.title": "Toggle Editor Commenting Off", "command.pr.checkoutFromDescription.title": "Checkout", - "command.pr.applyChangesFromDescription.title": "Apply Changes", + "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", @@ -332,12 +375,8 @@ "command.notifications.markPullRequestsAsDone.title": "Mark Pull Requests as Done", "command.notifications.configureNotificationsViewlet.title": "Configure...", "command.notification.chatSummarizeNotification.title": "Summarize With Copilot", - "command.codingAgent.openSessionLog.title": "Open Coding Agent Session Log", "command.pr.checkoutChatSessionPullRequest.title": "Checkout Pull Request", - "command.pr.closeChatSessionPullRequest.title": "Close Pull Request", - "command.pr.preferredCodingAgentGitHubRemote.title": "Set Preferred GitHub Remote", - "command.pr.resetCodingAgentPreferences.title": "Reset Coding Agent Workspace Preferences", - "command.pr.cancelCodingAgent.title": "Cancel Coding Agent", + "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": [ @@ -400,33 +439,27 @@ "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...", - "welcome.chat.sessions.copilot-swe-agent.login.contents": { - "message": "Sign in to get started with Copilot coding agent\n[Sign in](command:pr.signin)", - "comment": [ - "Do not translate what's inside of (...). It is link syntax.", - "{Locked='](command:pr.signin)'}" - ] - }, - "welcome.chat.sessions.copilot-swe-agent.startSession.contents": { - "message": "No Copilot coding agent sessions\n[Start a coding session](command:workbench.action.chat.openNewSessionEditor.copilot-swe-agent)", - "comment": [ - "Do not translate what's inside of (...). It is link syntax.", - "{Locked='](command:workbench.action.chat.open?%7B%22query%22%3A%22%23copilotCodingAgent%20%22%2C%22isPartialQuery%22%3Atrue%7D)'}" - ] - }, - "welcome.chat.sessions.copilot-swe-agent.noGitHub.contents": "Clone or open a GitHub repository to get started", "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_activePullRequest.displayName": "Active Pull Request", - "languageModelTools.github-pull-request_activePullRequest.description": "Get information about the active GitHub pull request. This information includes: comments, files changed, pull request title + description, pull request state, and pull request status checks/CI.", - "languageModelTools.github-pull-request_openPullRequest.displayName": "Open Pull Request", - "languageModelTools.github-pull-request_openPullRequest.description": "Get information about the open GitHub pull request. This information includes: comments, files changed, pull request title + description, pull request state, and pull request status checks/CI.", - "languageModelTools.github-pull-request_copilot-coding-agent.displayName": "Copilot coding agent", - "languageModelTools.github-pull-request_copilot-coding-agent.userDescription": "Completes the provided task using an asynchronous coding agent. Use when the user wants copilot continue completing a task in the background or asynchronously. Launch an autonomous GitHub Copilot agent to work on coding tasks in the background. The agent will create a new branch, implement the requested changes, and open a pull request with the completed work." + "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/resources/icons/alert.svg b/resources/icons/alert.svg deleted file mode 100644 index 3c493a4c29..0000000000 --- a/resources/icons/alert.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/assignee.svg b/resources/icons/assignee.svg deleted file mode 100644 index 2bb8758146..0000000000 --- a/resources/icons/assignee.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/resources/icons/check.svg b/resources/icons/check.svg deleted file mode 100644 index f94e91f51b..0000000000 --- a/resources/icons/check.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/chevron.svg b/resources/icons/chevron.svg deleted file mode 100644 index f6f3e9e3eb..0000000000 --- a/resources/icons/chevron.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/chevron_down.svg b/resources/icons/chevron_down.svg deleted file mode 100644 index d369b3dc9d..0000000000 --- a/resources/icons/chevron_down.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/close.svg b/resources/icons/close.svg deleted file mode 100644 index f8af265cc4..0000000000 --- a/resources/icons/close.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/codicons/account.svg b/resources/icons/codicons/account.svg new file mode 100644 index 0000000000..851e0511fb --- /dev/null +++ b/resources/icons/codicons/account.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/add.svg b/resources/icons/codicons/add.svg new file mode 100644 index 0000000000..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 dc1d10c63f..0000000000 --- a/resources/icons/commit_icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/copilot.svg b/resources/icons/copilot.svg deleted file mode 100644 index 2e5f639e35..0000000000 --- a/resources/icons/copilot.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/copy.svg b/resources/icons/copy.svg deleted file mode 100644 index 3a932546b3..0000000000 --- a/resources/icons/copy.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/dark/pr_webview.svg b/resources/icons/dark/git-pull-request_webview.svg similarity index 100% rename from resources/icons/dark/pr_webview.svg rename to resources/icons/dark/git-pull-request_webview.svg diff --git a/resources/icons/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/delete.svg b/resources/icons/delete.svg deleted file mode 100644 index 4bebdd27c4..0000000000 --- a/resources/icons/delete.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/dot.svg b/resources/icons/dot.svg deleted file mode 100644 index 0394588aec..0000000000 --- a/resources/icons/dot.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/edit.svg b/resources/icons/edit.svg deleted file mode 100644 index b02c84f152..0000000000 --- a/resources/icons/edit.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/resources/icons/error.svg b/resources/icons/error.svg deleted file mode 100644 index 4cfb5bbfab..0000000000 --- a/resources/icons/error.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/gear.svg b/resources/icons/gear.svg deleted file mode 100644 index d4b13f9797..0000000000 --- a/resources/icons/gear.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/pr_webview.svg b/resources/icons/git-pull-request_webview.svg similarity index 100% rename from resources/icons/pr_webview.svg rename to resources/icons/git-pull-request_webview.svg diff --git a/resources/icons/github-project.svg b/resources/icons/github-project.svg deleted file mode 100644 index cf8b1ddf03..0000000000 --- a/resources/icons/github-project.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/resources/icons/issue.svg b/resources/icons/issue.svg deleted file mode 100644 index 666a3baac7..0000000000 --- a/resources/icons/issue.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/issue_closed.svg b/resources/icons/issue_closed.svg deleted file mode 100644 index 6a6c314255..0000000000 --- a/resources/icons/issue_closed.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/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/label.svg b/resources/icons/label.svg deleted file mode 100644 index 06c7fb55ea..0000000000 --- a/resources/icons/label.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/loading.svg b/resources/icons/loading.svg deleted file mode 100644 index 4e3e640e03..0000000000 --- a/resources/icons/loading.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/merge_icon.svg b/resources/icons/merge_icon.svg deleted file mode 100644 index 6d3f716696..0000000000 --- a/resources/icons/merge_icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/merge_method.svg b/resources/icons/merge_method.svg deleted file mode 100644 index b8596218f1..0000000000 --- a/resources/icons/merge_method.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/milestone.svg b/resources/icons/milestone.svg deleted file mode 100644 index 58ac83a825..0000000000 --- a/resources/icons/milestone.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/output.svg b/resources/icons/output.svg deleted file mode 100644 index 41f7853c24..0000000000 --- a/resources/icons/output.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/plus.svg b/resources/icons/plus.svg deleted file mode 100644 index 4d9389336b..0000000000 --- a/resources/icons/plus.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/pr.svg b/resources/icons/pr.svg deleted file mode 100644 index 6d59036b31..0000000000 --- a/resources/icons/pr.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/pr_base.svg b/resources/icons/pr_base.svg deleted file mode 100644 index 862a547917..0000000000 --- a/resources/icons/pr_base.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/pr_closed.svg b/resources/icons/pr_closed.svg deleted file mode 100644 index 4fb8a73d1f..0000000000 --- a/resources/icons/pr_closed.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/resources/icons/pr_draft.svg b/resources/icons/pr_draft.svg deleted file mode 100644 index d0cf9b3008..0000000000 --- a/resources/icons/pr_draft.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/resources/icons/pr_merge.svg b/resources/icons/pr_merge.svg deleted file mode 100644 index cf26950f84..0000000000 --- a/resources/icons/pr_merge.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/quote.svg b/resources/icons/quote.svg deleted file mode 100644 index 1c71b75363..0000000000 --- a/resources/icons/quote.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/request_changes.svg b/resources/icons/request_changes.svg deleted file mode 100644 index c412bb8546..0000000000 --- a/resources/icons/request_changes.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/resources/icons/reviewer.svg b/resources/icons/reviewer.svg deleted file mode 100644 index e83e580fd1..0000000000 --- a/resources/icons/reviewer.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/settings.svg b/resources/icons/settings.svg deleted file mode 100644 index 4e7e022bcf..0000000000 --- a/resources/icons/settings.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/skip.svg b/resources/icons/skip.svg deleted file mode 100644 index b7368b71f2..0000000000 --- a/resources/icons/skip.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/sparkle.svg b/resources/icons/sparkle.svg deleted file mode 100644 index 442e6cc389..0000000000 --- a/resources/icons/sparkle.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/stop-circle.svg b/resources/icons/stop-circle.svg deleted file mode 100644 index 4f39984fa2..0000000000 --- a/resources/icons/stop-circle.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/sync.svg b/resources/icons/sync.svg deleted file mode 100644 index 63c0090a6c..0000000000 --- a/resources/icons/sync.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/tasklist.svg b/resources/icons/tasklist.svg deleted file mode 100644 index efc81525d0..0000000000 --- a/resources/icons/tasklist.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/three-bars.svg b/resources/icons/three-bars.svg deleted file mode 100644 index f49db2a735..0000000000 --- a/resources/icons/three-bars.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/scripts/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 8cb20e9f94..5d835ccc52 100644 --- a/scripts/ci/common-setup.yml +++ b/scripts/ci/common-setup.yml @@ -4,6 +4,6 @@ steps: inputs: versionSpec: "20.x" - - script: yarn install --frozen-lockfile --check-files + - script: npm ci displayName: Install dependencies retryCountOnTaskFailure: 3 diff --git a/src/@types/git.d.ts b/src/@types/git.d.ts index 702e382fee..8f25bbd79f 100644 --- a/src/@types/git.d.ts +++ b/src/@types/git.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Uri, Event, Disposable, ProviderResult, Command, CancellationToken } from 'vscode'; +import { Uri, Event, Disposable, ProviderResult, Command, CancellationToken, SourceControlHistoryItem } from 'vscode'; export { ProviderResult } from 'vscode'; export interface Git { @@ -16,7 +16,8 @@ export interface InputBox { export const enum ForcePushMode { Force, - ForceWithLease + ForceWithLease, + ForceWithLeaseIfIncludes, } export const enum RefType { @@ -29,12 +30,14 @@ export interface Ref { readonly type: RefType; readonly name?: string; readonly commit?: string; + readonly commitDetails?: Commit; readonly remote?: string; } export interface UpstreamRef { readonly remote: string; readonly name: string; + readonly commit?: string; } export interface Branch extends Ref { @@ -43,6 +46,12 @@ export interface Branch extends Ref { readonly behind?: number; } +export interface CommitShortStat { + readonly files: number; + readonly insertions: number; + readonly deletions: number; +} + export interface Commit { readonly hash: string; readonly message: string; @@ -51,6 +60,7 @@ export interface Commit { readonly authorName?: string; readonly authorEmail?: string; readonly commitDate?: Date; + readonly shortStat?: CommitShortStat; } export interface Submodule { @@ -66,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, @@ -78,6 +96,8 @@ export const enum Status { UNTRACKED, IGNORED, INTENT_TO_ADD, + INTENT_TO_RENAME, + TYPE_CHANGED, ADDED_BY_US, ADDED_BY_THEM, @@ -101,15 +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; } @@ -126,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 { @@ -155,10 +190,27 @@ export interface FetchOptions { depth?: number; } +export interface InitOptions { + defaultBranch?: string; +} + +export interface CloneOptions { + parentPath?: Uri; + /** + * ref is only used if the repository cache is missed. + */ + ref?: string; + recursive?: boolean; + /** + * If no postCloneAction is provided, then the users setting for git.openAfterClone is used. + */ + postCloneAction?: 'none'; +} + export interface RefQuery { readonly contains?: string; readonly count?: number; - readonly pattern?: string; + readonly pattern?: string | string[]; readonly sort?: 'alphabetically' | 'committerdate'; } @@ -173,9 +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 }>; @@ -211,9 +267,11 @@ export interface Repository { getBranchBase(name: string): Promise; setBranchUpstream(name: string, upstream: string): Promise; + checkIgnore(paths: string[]): Promise>; + getRefs(query: RefQuery, cancellationToken?: CancellationToken): Promise; - getMergeBase(ref1: string, ref2: string): Promise; + getMergeBase(ref1: string, ref2: string): Promise; tag(name: string, upstream: string): Promise; deleteTag(name: string): Promise; @@ -236,6 +294,19 @@ export interface Repository { commit(message: string, opts?: CommitOptions): Promise; merge(ref: string): Promise; mergeAbort(): Promise; + + applyStash(index?: number): Promise; + popStash(index?: number): Promise; + dropStash(index?: number): Promise; + + 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 { @@ -276,6 +347,38 @@ export interface PushErrorHandler { handlePushError(repository: Repository, remote: Remote, refspec: string, error: Error & { gitErrorCode: GitErrorCodes }): Promise; } +export interface BranchProtection { + readonly remote: string; + readonly rules: BranchProtectionRule[]; +} + +export interface BranchProtectionRule { + readonly include?: string[]; + readonly exclude?: string[]; +} + +export interface BranchProtectionProvider { + onDidChangeBranchProtection: Event; + provideBranchProtection(): BranchProtection[]; +} + +export interface AvatarQueryCommit { + readonly hash: string; + readonly authorName?: string; + readonly authorEmail?: string; +} + +export interface AvatarQuery { + readonly commits: AvatarQueryCommit[]; + readonly size: number; +} + +export interface SourceControlHistoryItemDetailsProvider { + provideAvatar(repository: Repository, query: AvatarQuery): ProviderResult>; + provideHoverCommands(repository: Repository): ProviderResult; + provideMessageLinks(repository: Repository, message: string): ProviderResult; +} + export type APIState = 'uninitialized' | 'initialized'; export interface PublishEvent { @@ -294,14 +397,24 @@ export interface GitAPI { toGitUri(uri: Uri, ref: string): Uri; getRepository(uri: Uri): Repository | null; - init(root: Uri): Promise; - openRepository(root: Uri): Promise + getRepositoryRoot(uri: Uri): Promise; + getRepositoryWorkspace(uri: Uri): Promise; + init(root: Uri, options?: InitOptions): Promise; + /** + * Checks the cache of known cloned repositories, and clones if the repository is not found. + * Make sure to pass `postCloneAction` 'none' if you want to have the uri where you can find the repository returned. + * @returns The URI of a folder or workspace file which, when opened, will open the cloned repository. + */ + clone(uri: Uri, options?: CloneOptions): Promise; + openRepository(root: Uri): Promise; registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable; registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; registerCredentialsProvider(provider: CredentialsProvider): Disposable; registerPostCommitCommandsProvider(provider: PostCommitCommandsProvider): Disposable; registerPushErrorHandler(handler: PushErrorHandler): Disposable; + registerBranchProtectionProvider(root: Uri, provider: BranchProtectionProvider): Disposable; + registerSourceControlHistoryItemDetailsProvider(provider: SourceControlHistoryItemDetailsProvider): Disposable; } export interface GitExtension { @@ -319,21 +432,25 @@ export interface GitExtension { * @param version Version number. * @returns API instance */ - getAPI(version: 1): GitAPI; + getAPI(version: 1): API; } export const enum GitErrorCodes { BadConfigFile = 'BadConfigFile', + BadRevision = 'BadRevision', AuthenticationFailed = 'AuthenticationFailed', NoUserNameConfigured = 'NoUserNameConfigured', NoUserEmailConfigured = 'NoUserEmailConfigured', NoRemoteRepositorySpecified = 'NoRemoteRepositorySpecified', NotAGitRepository = 'NotAGitRepository', + NotASafeGitRepository = 'NotASafeGitRepository', NotAtRepositoryRoot = 'NotAtRepositoryRoot', Conflict = 'Conflict', StashConflict = 'StashConflict', UnmergedChanges = 'UnmergedChanges', PushRejected = 'PushRejected', + ForcePushWithLeaseRejected = 'ForcePushWithLeaseRejected', + ForcePushWithLeaseIfIncludesRejected = 'ForcePushWithLeaseIfIncludesRejected', RemoteConnectionError = 'RemoteConnectionError', DirtyWorkTree = 'DirtyWorkTree', CantOpenResource = 'CantOpenResource', @@ -361,5 +478,10 @@ export const enum GitErrorCodes { EmptyCommitMessage = 'EmptyCommitMessage', BranchFastForwardRejected = 'BranchFastForwardRejected', BranchNotYetBorn = 'BranchNotYetBorn', - TagConflict = 'TagConflict' + TagConflict = 'TagConflict', + CherryPickEmpty = 'CherryPickEmpty', + CherryPickConflict = 'CherryPickConflict', + WorktreeContainsChanges = 'WorktreeContainsChanges', + WorktreeAlreadyExists = 'WorktreeAlreadyExists', + WorktreeBranchAlreadyUsed = 'WorktreeBranchAlreadyUsed' } diff --git a/src/@types/vscode.proposed.chatContextProvider.d.ts b/src/@types/vscode.proposed.chatContextProvider.d.ts new file mode 100644 index 0000000000..709bb3deca --- /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 "Add 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 `iconPath` is not defined, no icon is shown. + * - If `iconPath` is a file or folder {@link ThemeIcon theme icon}, the icon is derived from {@link resourceUri} if `resourceUri` is defined. + * - Otherwise, `iconPath` is used. + */ + iconPath?: IconPath; + /** + * 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; + } + //@api should we rename to ChatGlobalContextProvider? + 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 index 778d6d7a40..567c509670 100644 --- a/src/@types/vscode.proposed.chatParticipantAdditions.d.ts +++ b/src/@types/vscode.proposed.chatParticipantAdditions.d.ts @@ -32,7 +32,8 @@ declare module 'vscode' { export class ChatResponseCodeblockUriPart { isEdit?: boolean; value: Uri; - constructor(value: Uri, isEdit?: boolean); + undoStopId?: string; + constructor(value: Uri, isEdit?: boolean, undoStopId?: string); } /** @@ -64,6 +65,29 @@ declare module 'vscode' { 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; @@ -72,6 +96,108 @@ declare module 'vscode' { 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; @@ -79,9 +205,12 @@ declare module 'vscode' { constructor(value: Uri, license: string, snippet: string); } - export class ChatPrepareToolInvocationPart { - toolName: string; - constructor(toolName: 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 { @@ -91,6 +220,118 @@ declare module 'vscode' { 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 { @@ -102,10 +343,16 @@ declare module 'vscode' { pastTenseMessage?: string | MarkdownString; isConfirmed?: boolean; isComplete?: boolean; - toolSpecificData?: ChatTerminalToolInvocationData; - fromSubAgent?: 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, isError?: boolean); + constructor(toolName: string, toolCallId: string, errorMessage?: string); } /** @@ -152,20 +399,64 @@ declare module 'vscode' { */ title: string; + /** + * Whether the multi diff editor should be read-only. + * When true, users cannot open individual files or interact with file navigation. + */ + readOnly?: boolean; + /** * Create a new ChatResponseMultiDiffPart. * @param value Array of file diff entries. * @param title The title for the multi diff editor. + * @param readOnly Optional flag to make the multi diff editor read-only. */ - constructor(value: ChatResponseDiffEntry[], title: string); + constructor(value: ChatResponseDiffEntry[], title: string, readOnly?: boolean); + } + + export class ChatResponseExternalEditPart { + uris: Uri[]; + callback: () => Thenable; + applied: Thenable; + constructor(uris: Uri[], callback: () => Thenable); } - export type ExtendedChatResponsePart = ChatResponsePart | ChatResponseTextEditPart | ChatResponseNotebookEditPart | ChatResponseConfirmationPart | ChatResponseCodeCitationPart | ChatResponseReferencePart2 | ChatResponseMovePart | ChatResponseExtensionsPart | ChatResponsePullRequestPart | ChatPrepareToolInvocationPart | ChatToolInvocationPart | ChatResponseMultiDiffPart | ChatResponseThinkingProgressPart; + /** + * 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; @@ -264,12 +555,32 @@ declare module 'vscode' { } export class ChatResponsePullRequestPart { - readonly uri: Uri; + /** + * @deprecated use `command` instead + */ + readonly uri?: Uri; + readonly command: Command; readonly linkTag: string; readonly title: string; readonly description: string; readonly author: string; - constructor(uri: Uri, title: string, description: string, author: string, linkTag: 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 { @@ -294,6 +605,20 @@ declare module 'vscode' { 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; @@ -310,6 +635,15 @@ declare module 'vscode' { */ 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))`. @@ -319,17 +653,47 @@ declare module 'vscode' { */ 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; - prepareToolInvocation(toolName: 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 { @@ -380,7 +744,7 @@ declare module 'vscode' { /** * A map of all tools that should (`true`) and should not (`false`) be used in this request. */ - readonly tools: Map; + readonly tools: Map; } export namespace lm { @@ -470,6 +834,58 @@ declare module 'vscode' { 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; @@ -608,10 +1024,6 @@ declare module 'vscode' { readonly toolReferences?: readonly ChatLanguageModelToolReference[]; } - export interface ChatResultFeedback { - readonly unhelpfulReason?: string; - } - export namespace lm { export function fileIsIgnored(uri: Uri, token?: CancellationToken): Thenable; } @@ -644,6 +1056,37 @@ declare module 'vscode' { 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 { @@ -652,8 +1095,16 @@ declare module 'vscode' { } 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 index dca0f364d7..f0fad8b953 100644 --- a/src/@types/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/@types/vscode.proposed.chatParticipantPrivate.d.ts @@ -3,8 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 11 - declare module 'vscode' { /** @@ -30,12 +28,17 @@ declare module 'vscode' { } export class ChatRequestEditorData { + + readonly editor: TextEditor; + //TODO@API should be the editor document: TextDocument; selection: Selection; + + /** @deprecated */ wholeRange: Range; - constructor(document: TextDocument, selection: Selection, wholeRange: Range); + constructor(editor: TextEditor, document: TextDocument, selection: Selection, wholeRange: Range); } export class ChatRequestNotebookData { @@ -56,10 +59,17 @@ declare module 'vscode' { readonly attempt: number; /** - * The session identifier for this chat request + * 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. */ @@ -87,6 +97,42 @@ declare module 'vscode' { * 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 { @@ -104,6 +150,10 @@ declare module 'vscode' { * 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. * @@ -139,13 +189,28 @@ declare module 'vscode' { */ 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); + 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. */ @@ -189,6 +254,15 @@ declare module 'vscode' { 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; @@ -216,15 +290,44 @@ declare module 'vscode' { provideFileIgnored(uri: Uri, token: CancellationToken): ProviderResult; } + export type PreToolUsePermissionDecision = 'allow' | 'deny' | 'ask'; + export interface LanguageModelToolInvocationOptions { chatRequestId?: string; - chatSessionId?: string; + chatSessionResource?: Uri; chatInteractionId?: string; terminalCommand?: string; /** - * Lets us add some nicer UI to toolcalls that came from a sub-agent, but in the long run, this should probably just be rendered in a similar way to thinking text + tool call groups + * 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`. */ - fromSubAgent?: boolean; + 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 { @@ -233,8 +336,18 @@ declare module 'vscode' { */ input: T; chatRequestId?: string; - chatSessionId?: 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 { @@ -246,6 +359,8 @@ declare module 'vscode' { 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 @@ -271,6 +386,19 @@ declare module 'vscode' { 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 @@ -305,4 +433,82 @@ declare module 'vscode' { } // #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 index 534c45a192..1003937f26 100644 --- a/src/@types/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/@types/vscode.proposed.chatSessionsProvider.d.ts @@ -3,8 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 2 - declare module 'vscode' { /** * Represents the status of a chat session. @@ -23,11 +21,46 @@ declare module 'vscode' { /** * The chat session is currently in progress. */ - InProgress = 2 + 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 { /** @@ -35,53 +68,236 @@ declare module 'vscode' { */ readonly onDidChangeChatSessionItems: Event; + /** + * Provides a list of chat sessions. + */ + // TODO: Do we need a flag to try auth if needed? + provideChatSessionItems(token: CancellationToken): ProviderResult; + + /** + * @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; + /** - * DEPRECATED: Will be removed! - * Creates a new chat session. + * Handler called to refresh the collection of chat session items. * - * @param options Options for the new session including an optional initial prompt and history - * @param token A cancellation token - * @returns Metadata for the chat session + * This is also called on first load to get the initial set of items. */ - provideNewChatSessionItem?(options: { - /** - * The chat request that initiated the session creation - */ - readonly request: ChatRequest; + readonly refreshHandler: ChatSessionItemControllerRefreshHandler; - /** - * Additional metadata to use for session creation - */ - metadata?: any; - }, token: CancellationToken): ProviderResult; + /** + * Fired when an item's archived state changes. + */ + readonly onDidChangeChatSessionItemState: Event; /** - * Provides a list of chat sessions. + * 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. */ - // TODO: Do we need a flag to try auth if needed? - provideChatSessionItems(token: CancellationToken): ProviderResult; + 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; } - export interface ChatSessionItem { + /** + * 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; + /** - * Unique identifier for the chat session. + * Efficiently gets a chat session item by resource, if it exists, in the collection. * - * @deprecated Will be replaced by `resource` + * @param resource Item resource to get. + * + * @returns The found item or undefined if it does not exist. */ - id: string; + 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. */ - resource: Uri | undefined; + readonly resource: Uri; /** * Human readable name of the session shown in the UI @@ -98,6 +314,11 @@ declare module 'vscode' { */ 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. */ @@ -109,36 +330,118 @@ declare module 'vscode' { tooltip?: string | MarkdownString; /** - * The times at which session started and ended + * 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. */ - startTime: number; + readonly startTime?: number; + /** * Session end timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * @deprecated Use `lastRequestEnded` instead. */ - endTime?: number; + readonly endTime?: number; }; /** * Statistics about the chat session. */ - statistics?: { - /** - * Number of insertions made during the session. - */ - insertions: number; + changes?: readonly ChatSessionChangedFile[]; - /** - * Number of deletions made during the session. - */ - deletions: number; - }; + /** + * 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 * @@ -149,9 +452,14 @@ declare module 'vscode' { readonly history: ReadonlyArray; /** - * Options configured for this session. + * 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?: { model?: LanguageModelChatInformation }; + readonly options?: Record; /** * Callback invoked by the editor for a currently running response. This allows the session to push items for the @@ -169,31 +477,101 @@ declare module 'vscode' { */ // 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 { /** - * Resolves a chat session into a full `ChatSession` object. + * @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. * - * @param sessionId The id of the chat session to open. + * 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(sessionId: string, token: CancellationToken): Thenable | ChatSession; + provideChatSessionContent(resource: Uri, token: CancellationToken, context: { + readonly inputState: ChatSessionInputState; + }): Thenable | ChatSession; /** + * @deprecated * - * @param sessionId Identifier of the chat session being updated. + * @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?(sessionId: string, updates: ReadonlyArray, token: CancellationToken): void; + provideHandleOptionsChange?(resource: Uri, updates: ReadonlyArray, token: CancellationToken): void; /** + * @deprecated + * * Called as soon as you register (call me once) - * @param token */ - provideChatSessionProviderOptions?(token: CancellationToken): Thenable | ChatSessionProviderOptions; + provideChatSessionProviderOptions?(token: CancellationToken): Thenable; } export interface ChatSessionOptionUpdate { @@ -209,40 +587,40 @@ declare module 'vscode' { } export namespace chat { - /** - * Registers a new {@link ChatSessionItemProvider chat session item provider}. - * - * To use this, also make sure to also add `chatSessions` contribution in the `package.json`. - * - * @param chatSessionType The type of chat session the provider is for. - * @param provider The provider to register. - * - * @returns A disposable that unregisters the provider when disposed. - */ - export function registerChatSessionItemProvider(chatSessionType: string, provider: ChatSessionItemProvider): Disposable; - /** * Registers a new {@link ChatSessionContentProvider chat session content provider}. * - * @param chatSessionType A unique identifier for the chat session type. This is used to differentiate between different chat session providers. + * @param scheme The uri-scheme to register for. This must be unique. * @param provider The provider to register. + * @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(chatSessionType: string, provider: ChatSessionContentProvider, chatParticipant: ChatParticipant, capabilities?: ChatSessionCapabilities): Disposable; + export function registerChatSessionContentProvider(scheme: string, provider: ChatSessionContentProvider, defaultChatParticipant: ChatParticipant, capabilities?: ChatSessionCapabilities): Disposable; } export interface ChatContext { readonly chatSessionContext?: ChatSessionContext; - readonly chatSummary?: { - readonly prompt?: string; - readonly history?: string; - }; } 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 { @@ -252,31 +630,220 @@ declare module 'vscode' { supportsInterruptions?: boolean; } - export interface ChatSessionProviderOptions { + /** + * Represents a single selectable item within a provider option group. + */ + export interface ChatSessionProviderOptionItem { + /** + * Unique identifier for the option item. + */ + readonly id: string; + /** - * Set of available models. + * Human-readable name displayed in the UI. */ - models?: LanguageModelChatInformation[]; + 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 promo?: { + readonly id: string; + readonly discountPercent: number; + readonly endsAt: string; + readonly message: string; + }; + readonly maxInputTokens?: number; + readonly maxOutputTokens?: number; + readonly capabilities?: { + readonly vision?: boolean; + readonly toolCalling?: boolean; + }; } /** - * @deprecated + * Represents a group of related provider options (e.g., models, sub-agents). */ - export interface ChatSessionShowOptions { + 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 editor view column to show the chat session in. + * 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. * - * If not provided, the chat session will be shown in the chat panel instead. + * Keys correspond to option group IDs (e.g., 'models', 'subagents'). */ - readonly viewColumn?: ViewColumn; + readonly newSessionOptions?: Record; } - export namespace window { + /** + * Represents the current state of user inputs for a chat session. + */ + export interface ChatSessionInputState { + /** + * Fired when the input state is disposed. + */ + readonly onDidDispose: Event; + /** - * Shows a chat session in the panel or editor. + * Fired when the input state is changed by the user. * - * @deprecated + * 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. */ - export function showChatSession(chatSessionType: string, sessionId: string, options: ChatSessionShowOptions): Thenable; + groups: readonly ChatSessionProviderOptionGroup[]; } } 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.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.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.treeItemMarkdownLabel.d.ts b/src/@types/vscode.proposed.treeItemMarkdownLabel.d.ts new file mode 100644 index 0000000000..6150fa0667 --- /dev/null +++ b/src/@types/vscode.proposed.treeItemMarkdownLabel.d.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + // @kycutler https://github.com/microsoft/vscode/issues/271523 + + export interface TreeItemLabel2 { + highlights?: [number, number][]; + + /** + * A human-readable string or MarkdownString describing the {@link TreeItem Tree item}. + * + * When using MarkdownString, only the following Markdown syntax is supported: + * - Icons (e.g., `$(icon-name)`, when the `supportIcons` flag is also set) + * - Bold, italics, and strikethrough formatting, but only when the syntax wraps the entire string + * (e.g., `**bold**`, `_italic_`, `~~strikethrough~~`) + */ + label: string | MarkdownString; + } +} diff --git a/src/api/api.d.ts b/src/api/api.d.ts index 67d5a856d4..89d1cfd021 100644 --- a/src/api/api.d.ts +++ b/src/api/api.d.ts @@ -53,6 +53,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 { Status } from './api1'; export interface Change { @@ -71,6 +79,7 @@ export interface RepositoryState { readonly HEAD: Branch | undefined; readonly remotes: Remote[]; readonly submodules: Submodule[]; + readonly worktrees?: Worktree[]; readonly rebaseCommit: Commit | undefined; readonly mergeChanges: Change[]; @@ -91,6 +100,7 @@ export interface CommitOptions { signoff?: boolean; signCommit?: boolean; empty?: boolean; + noVerify?: boolean; } export interface FetchOptions { @@ -188,7 +198,7 @@ export interface Repository { setBranchUpstream(name: string, upstream: string): Promise; getRefs?(query: RefQuery, cancellationToken?: CancellationToken): Promise; // Optional, because Remote Hub doesn't support this - getMergeBase(ref1: string, ref2: string): Promise; + getMergeBase(ref1: string, ref2: string): Promise; status(): Promise; checkout(treeish: string): Promise; @@ -209,6 +219,9 @@ export interface Repository { 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; } /** @@ -239,10 +252,12 @@ export interface IGit { readonly onDidPublish?: Event; registerPostCommitCommandsProvider?(provider: PostCommitCommandsProvider): Disposable; + getRepositoryWorkspace?(uri: Uri): Promise; + clone?(uri: Uri, options?: CloneOptions): Promise; } export interface TitleAndDescriptionProvider { - provideTitleAndDescription(context: { commitMessages: string[], patches: string[] | { patch: string, fileUri: string, previousFileUri?: string }[], issues?: { reference: string, content: string }[] }, token: CancellationToken): Promise<{ title: string, description?: string } | undefined>; + provideTitleAndDescription(context: { commitMessages: string[], patches: string[] | { patch: string, fileUri: string, previousFileUri?: string }[], issues?: { reference: string, content: string }[], template?: string, compareBranch?: string }, token: CancellationToken): Promise<{ title: string, description?: string } | undefined>; } export interface ReviewerComments { diff --git a/src/api/api1.ts b/src/api/api1.ts index 1527e0622d..4eabe95f06 100644 --- a/src/api/api1.ts +++ b/src/api/api1.ts @@ -5,7 +5,7 @@ import * as vscode from 'vscode'; import { API, IGit, PostCommitCommandsProvider, Repository, ReviewerCommentsProvider, TitleAndDescriptionProvider } from './api'; -import { APIState, PublishEvent } from '../@types/git'; +import { APIState, CloneOptions, PublishEvent } from '../@types/git'; import { Disposable } from '../common/lifecycle'; import Logger from '../common/logger'; import { TernarySearchTree } from '../common/utils'; @@ -84,6 +84,25 @@ export class GitApiImpl extends Disposable implements API, IGit { super(); } + async getRepositoryWorkspace(uri: vscode.Uri): Promise { + for (const [, provider] of this._providers) { + if (provider.getRepositoryWorkspace) { + return provider.getRepositoryWorkspace(uri); + } + } + return null; + } + + async clone(uri: vscode.Uri, options?: CloneOptions): Promise { + for (const [, provider] of this._providers) { + if (provider.clone) { + return provider.clone(uri, options); + } + } + return null; + } + + public get repositories(): Repository[] { const ret: Repository[] = []; diff --git a/src/authentication/githubServer.ts b/src/authentication/githubServer.ts index 993fc9cda2..78f192c177 100644 --- a/src/authentication/githubServer.ts +++ b/src/authentication/githubServer.ts @@ -8,6 +8,7 @@ import * as vscode from 'vscode'; 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'; @@ -15,8 +16,11 @@ export class GitHubManager { 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); } @@ -25,6 +29,20 @@ export class GitHubManager { 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 GitHubServerType.None; @@ -63,6 +81,9 @@ export class GitHubManager { 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()); diff --git a/src/commands.ts b/src/commands.ts index 0e5c49db0d..79f4303e59 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -7,20 +7,21 @@ import * as pathLib from 'path'; import * as vscode from 'vscode'; import { Repository } from './api/api'; -import { GitErrorCodes, Status } from './api/api1'; +import { GitErrorCodes } from './api/api1'; import { CommentReply, findActiveHandler, resolveCommentHandler } from './commentHandlerResolver'; import { commands } from './common/executeCommands'; import Logger from './common/logger'; -import * as PersistentState from './common/persistentState'; import { FILE_LIST_LAYOUT, HIDE_VIEWED_FILES, PR_SETTINGS_NAMESPACE } from './common/settingKeys'; import { editQuery } from './common/settingsUtils'; import { ITelemetry } from './common/telemetry'; +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 { ICopilotRemoteAgentCommandArgs } from './github/common'; -import { ChatSessionWithPR } from './github/copilotApi'; -import { CopilotRemoteAgentManager } from './github/copilotRemoteAgent'; +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'; @@ -31,10 +32,15 @@ import { PullRequestModel } from './github/pullRequestModel'; import { PullRequestOverviewPanel } from './github/pullRequestOverview'; import { chooseItem } from './github/quickPicks'; import { RepositoriesManager } from './github/repositoriesManager'; -import { getIssuesUrl, getPullsUrl, isInCodespaces, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput, vscodeDevPrLink } from './github/utils'; -import { OverviewContext } from './github/views'; +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'; @@ -51,81 +57,6 @@ import { import { PRNode } from './view/treeNodes/pullRequestNode'; import { RepositoryChangesNode } from './view/treeNodes/repositoryChangesNode'; -// Modal dialog options for handling uncommitted changes during PR checkout -const STASH_CHANGES = vscode.l10n.t('Stash changes'); -const DISCARD_CHANGES = vscode.l10n.t('Discard changes'); -const DONT_SHOW_AGAIN = vscode.l10n.t('Try to checkout anyway and don\'t show again'); - -// Constants for persistent state storage -const UNCOMMITTED_CHANGES_SCOPE = vscode.l10n.t('uncommitted changes warning'); -const UNCOMMITTED_CHANGES_STORAGE_KEY = 'showWarning'; - -/** - * Shows a modal dialog when there are uncommitted changes during PR checkout - * @param repository The git repository with uncommitted changes - * @returns Promise true if user chose to proceed (after staging/discarding), false if cancelled - */ -async function handleUncommittedChanges(repository: Repository): Promise { - // Check if user has disabled the warning using persistent state - if (PersistentState.fetch(UNCOMMITTED_CHANGES_SCOPE, UNCOMMITTED_CHANGES_STORAGE_KEY) === false) { - return true; // User has disabled warnings, proceed without showing dialog - } - - // Filter out untracked files as they typically don't conflict with PR checkout - const trackedWorkingTreeChanges = repository.state.workingTreeChanges.filter(change => change.status !== Status.UNTRACKED); - const hasTrackedWorkingTreeChanges = trackedWorkingTreeChanges.length > 0; - const hasIndexChanges = repository.state.indexChanges.length > 0; - - if (!hasTrackedWorkingTreeChanges && !hasIndexChanges) { - return true; // No tracked uncommitted changes, proceed - } - - const modalResult = await vscode.window.showInformationMessage( - vscode.l10n.t('You have uncommitted changes that might be overwritten by checking out this pull request.'), - { - modal: true, - detail: vscode.l10n.t('Choose how to handle your uncommitted changes before checking out the pull request.'), - }, - STASH_CHANGES, - DISCARD_CHANGES, - DONT_SHOW_AGAIN, - ); - - if (!modalResult) { - return false; // User cancelled - } - - if (modalResult === DONT_SHOW_AGAIN) { - // Store preference to never show this dialog again using persistent state - PersistentState.store(UNCOMMITTED_CHANGES_SCOPE, UNCOMMITTED_CHANGES_STORAGE_KEY, false); - return true; // Proceed with checkout - } - - try { - if (modalResult === STASH_CHANGES) { - // Stash all changes (working tree changes + any unstaged changes) - const allChangedFiles = [ - ...trackedWorkingTreeChanges.map(change => change.uri.fsPath), - ...repository.state.indexChanges.map(change => change.uri.fsPath), - ]; - if (allChangedFiles.length > 0) { - await repository.add(allChangedFiles); - await vscode.commands.executeCommand('git.stash', repository); - } - } else if (modalResult === DISCARD_CHANGES) { - // Discard all tracked working tree changes - const trackedWorkingTreeFiles = trackedWorkingTreeChanges.map(change => change.uri.fsPath); - if (trackedWorkingTreeFiles.length > 0) { - await repository.clean(trackedWorkingTreeFiles); - } - } - return true; // Successfully handled changes, proceed with checkout - } catch (error) { - vscode.window.showErrorMessage(vscode.l10n.t('Failed to handle uncommitted changes: {0}', formatError(error))); - return false; - } -} - function ensurePR(folderRepoManager: FolderRepositoryManager, pr?: PRNode): PullRequestModel; function ensurePR>(folderRepoManager: FolderRepositoryManager, pr?: TIssueModel): TIssueModel; function ensurePR>(folderRepoManager: FolderRepositoryManager, pr?: PRNode | TIssueModel): TIssueModel { @@ -154,11 +85,16 @@ export async function openDescription( if (revealNode) { descriptionNode?.reveal(descriptionNode, { select: true, focus: true }); } + 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, issue, undefined, preserveFocus); + await PullRequestOverviewPanel.createOrShow(telemetry, folderManager.context.extensionUri, folderManager, identity, issue, undefined, preserveFocus); } else { - await IssueOverviewPanel.createOrShow(telemetry, folderManager.context.extensionUri, folderManager, issue); + await IssueOverviewPanel.createOrShow(telemetry, folderManager.context.extensionUri, folderManager, identity, issue); /* __GDPR__ "issue.openDescription" : {} */ @@ -193,9 +129,9 @@ export async function closeAllPrAndReviewEditors() { } } -function isChatSessionWithPR(value: any): value is ChatSessionWithPR { - const asChatSessionWithPR = value as Partial; - return !!asChatSessionWithPR.pullRequest; +function isCrossChatSessionWithPR(value: any): value is CrossChatSessionWithPR { + const asCrossChatSessionWithPR = value as Partial; + return !!asCrossChatSessionWithPR.pullRequestDetails; } export function registerCommands( @@ -205,9 +141,13 @@ export function registerCommands( telemetry: ITelemetry, copilotRemoteAgentManager: CopilotRemoteAgentManager, notificationManager: NotificationsManager, - prsTreeModel: PrsTreeModel + prsTreeModel: PrsTreeModel, + tree: PullRequestsTreeDataProvider ) { const logId = 'RegisterCommands'; + + PullRequestOverviewPanel.registerGlobalCommands(context, telemetry); + context.subscriptions.push( vscode.commands.registerCommand( 'pr.openPullRequestOnGitHub', @@ -279,6 +219,51 @@ export function registerCommands( ), ); + 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!)); + } + + if (!pr || !folderManager) { + const activePullRequestsWithFolderManager = reposManager.folderManagers + .filter(m => m.activePullRequest) + .map(m => ({ activePr: m.activePullRequest!, folderManager: m })); + + if (activePullRequestsWithFolderManager.length === 0) { + vscode.window.showErrorMessage(vscode.l10n.t('No active pull request to add an attestation commit to.')); + return; + } + + 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) { @@ -302,6 +287,40 @@ export function registerCommands( }), ); + context.subscriptions.push( + vscode.commands.registerCommand('pr.revealFileInOS', (e: GitFileChangeNode | InMemFileChangeNode | undefined) => { + let fileChangeNode: FileChangeNode | undefined = e; + // When invoked from a keybinding, get the selected item from the tree view + if (!fileChangeNode) { + // First check the prStatus:github tree (checked out PRs) + for (const reviewManager of reviewsManager.reviewManagers) { + const selection = reviewManager.changesInPrDataProvider.view.selection; + const selectedFileChange = selection.find((node): node is GitFileChangeNode => node instanceof GitFileChangeNode); + if (selectedFileChange) { + fileChangeNode = selectedFileChange; + break; + } + } + // Then check the pr:github tree (non-checked out PRs) + if (!fileChangeNode) { + const prTreeSelection = tree.view.selection; + const selectedInMemFileChange = prTreeSelection.find((node): node is InMemFileChangeNode => node instanceof InMemFileChangeNode); + if (selectedInMemFileChange) { + fileChangeNode = selectedInMemFileChange; + } + } + } + if (!fileChangeNode) { + return; + } + const folderManager = reposManager.getManagerForIssueModel(fileChangeNode.pullRequest); + if (folderManager) { + const filePath = vscode.Uri.joinPath(folderManager.repository.rootUri, fileChangeNode.changeModel.fileName); + vscode.commands.executeCommand('revealFileInOS', filePath); + } + }), + ); + context.subscriptions.push( vscode.commands.registerCommand('pr.openOriginalFile', async (e: GitFileChangeNode) => { // if this is an image, encode it as a base64 data URI @@ -488,37 +507,6 @@ export function registerCommands( ), ); - const switchToPr = async (folderManager: FolderRepositoryManager, pullRequestModel: PullRequestModel, repository: Repository | undefined, isFromDescription: boolean) => { - // If we don't have a repository from the node, use the one from the folder manager - const repositoryToCheck = repository || folderManager.repository; - - // Check for uncommitted changes before proceeding with checkout - const shouldProceed = await handleUncommittedChanges(repositoryToCheck); - if (!shouldProceed) { - return; // User cancelled or there was an error handling changes - } - - /* __GDPR__ - "pr.checkout" : { - "fromDescriptionPage" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - telemetry.sendTelemetryEvent('pr.checkout', { fromDescription: isFromDescription.toString() }); - - return vscode.window.withProgress( - { - location: vscode.ProgressLocation.SourceControl, - title: vscode.l10n.t('Switching to Pull Request #{0}', pullRequestModel.number), - }, - async () => { - await ReviewManager.getReviewManagerForRepository( - reviewsManager.reviewManagers, - pullRequestModel.githubRepository, - repository - )?.switch(pullRequestModel); - }); - }; - context.subscriptions.push( vscode.commands.registerCommand('pr.pick', async (pr: PRNode | RepositoryChangesNode | PullRequestModel) => { if (pr === undefined) { @@ -544,11 +532,11 @@ export function registerCommands( } const fromDescriptionPage = pr instanceof PullRequestModel; - return switchToPr(folderManager, pullRequestModel, repository, fromDescriptionPage); + return reviewsManager.switchToPr(folderManager, pullRequestModel, repository, fromDescriptionPage); })); - const resolvePr = async (context: OverviewContext | undefined): Promise<{ folderManager: FolderRepositoryManager, pr: PullRequestModel } | undefined> => { + const resolvePr = async (context: BaseContext | undefined): Promise<{ folderManager: FolderRepositoryManager, pr: PullRequestModel } | undefined> => { if (!context) { return undefined; } @@ -566,7 +554,7 @@ export function registerCommands( return { folderManager, pr }; }; - const applyPullRequestChanges = async (folderManager: FolderRepositoryManager, pullRequest: PullRequestModel): Promise => { + const applyPullRequestChanges = async (task: vscode.Progress<{ message?: string; increment?: number; }>, folderManager: FolderRepositoryManager, pullRequest: PullRequestModel): Promise => { let patch: string | undefined; try { patch = await pullRequest.getPatch(); @@ -584,22 +572,13 @@ export function registerCommands( const encoder = new TextEncoder(); const tempUri = vscode.Uri.file(tempFilePath); - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: vscode.l10n.t('Applying changes from pull request #{0}', pullRequest.number.toString()), - cancellable: false - }, - async (task) => { - await vscode.workspace.fs.writeFile(tempUri, encoder.encode(patch)); - try { - await folderManager.repository.apply(tempFilePath, false); - task.report({ message: vscode.l10n.t('Successfully applied changes from pull request #{0}', pullRequest.number.toString()), increment: 100 }); - } finally { - await vscode.workspace.fs.delete(tempUri); - } - } - ); + await vscode.workspace.fs.writeFile(tempUri, encoder.encode(patch)); + try { + await folderManager.repository.apply(tempFilePath, false); + task.report({ message: vscode.l10n.t('Successfully applied changes from pull request #{0}', pullRequest.number.toString()), increment: 100 }); + } finally { + await vscode.workspace.fs.delete(tempUri); + } } catch (error) { const errorMessage = formatError(error); @@ -623,69 +602,162 @@ export function registerCommands( } }; - function contextHasPath(ctx: OverviewContext | { path: string } | undefined): ctx is { path: string } { + /** + * Metadata passed from chat/agent sessions containing repository information. + * This is provided by VS Code when commands are invoked from chat session toolbars. + */ + interface SessionMetadata { + /** GitHub repository owner/organization name */ + owner?: string; + /** GitHub repository name */ + name?: string; + [key: string]: unknown; + } + + /** + * Get the folder manager and GitHub repository for a repository based on metadata. + * Falls back to the first folder manager if metadata is not provided or repository not found. + * @param metadata Session metadata containing owner and repo information + * @returns Object with folderManager and githubRepo, or undefined if no folder managers exist + */ + function getFolderManagerFromMetadata(metadata: SessionMetadata | undefined): { folderManager: FolderRepositoryManager; githubRepo: GitHubRepository } | undefined { + if (metadata?.owner && metadata?.name) { + const folderManager = reposManager.getManagerForRepository(metadata.owner, metadata.name) ?? reposManager.folderManagers[0]; + if (!folderManager || folderManager.gitHubRepositories.length === 0) { + return undefined; + } + const githubRepo = folderManager.gitHubRepositories.find( + repo => repo.remote.owner === metadata.owner && repo.remote.repositoryName === metadata.name + ) ?? folderManager.gitHubRepositories[0]; + return { folderManager, githubRepo }; + } + if (reposManager.folderManagers.length === 0) { + return undefined; + } + const folderManager = reposManager.folderManagers[0]; + if (folderManager.gitHubRepositories.length === 0) { + return undefined; + } + return { folderManager, githubRepo: folderManager.gitHubRepositories[0] }; + } + + function contextHasPath(ctx: 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.checkoutFromDescription', async (ctx: OverviewContext | { path: string } | undefined) => { + 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; + } + } + + type ChatCommandArgs = { path: string } | [{ path: string } | undefined, SessionMetadata | undefined] | undefined; + + 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 }; + } + + 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 }; + } + + 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.')); } - if (contextHasPath(ctx)) { - const { path } = ctx; - const prNumber = Number(Buffer.from(path.substring(1), 'base64').toString('utf8')); - if (Number.isNaN(prNumber)) { - return vscode.window.showErrorMessage(vscode.l10n.t('Unable to parse pull request number.')); - } - const 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())); - } + const resolved = await resolvePrFromChat(ctx, metadata); + if (!resolved) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to find pull request from chat context.')); + } - return switchToPr(folderManager, pullRequest, folderManager.repository, true); + return reviewsManager.switchToPr(resolved.folderManager, resolved.pullRequest, resolved.folderManager.repository, true); + })); + + 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 resolved = await resolvePr(ctx); if (!resolved) { return vscode.window.showErrorMessage(vscode.l10n.t('Unable to resolve pull request for checkout.')); } - return switchToPr(resolved.folderManager, resolved.pr, resolved.folderManager.repository, true); - + return reviewsManager.switchToPr(resolved.folderManager, resolved.pr, resolved.folderManager.repository, true); })); - context.subscriptions.push(vscode.commands.registerCommand('pr.applyChangesFromDescription', async (ctx: OverviewContext | { path: string } | undefined) => { + 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.')); } - if (contextHasPath(ctx)) { - const { path } = ctx; - const prNumber = Number(Buffer.from(path.substring(1), 'base64').toString('utf8')); - if (Number.isNaN(prNumber)) { - return vscode.window.showErrorMessage(vscode.l10n.t('Unable to parse pull request number.')); - } - const folderManager = reposManager.folderManagers[0]; - const pullRequest = await folderManager.fetchById(folderManager.gitHubRepositories[0], Number(prNumber)); - if (!pullRequest) { - return vscode.window.showErrorMessage(vscode.l10n.t('Unable to find pull request #{0}', prNumber.toString())); - } - - return applyPullRequestChanges(folderManager, pullRequest); + const resolved = await resolvePrFromChat(ctx, metadata); + if (!resolved) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to find pull request from chat context.')); } - const resolved = await resolvePr(ctx); - if (!resolved) { - return vscode.window.showErrorMessage(vscode.l10n.t('Unable to resolve pull request for applying changes.')); + 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.applyChangesFromDescription', async (ctx: BaseContext | undefined) => { + if (!ctx) { + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request context provided for applying changes.')); } - return applyPullRequestChanges(resolved.folderManager, resolved.pr); + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t('Applying changes from pull request'), + cancellable: false + }, + async (task) => { + task.report({ increment: 30 }); + + const resolved = await resolvePr(ctx); + if (!resolved) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to resolve pull request for applying changes.')); + } + return applyPullRequestChanges(task, resolved.folderManager, resolved.pr); + } + ); })); context.subscriptions.push( - vscode.commands.registerCommand('pr.openChanges', async (pr: PRNode | RepositoryChangesNode | PullRequestModel | OverviewContext | ChatSessionWithPR | undefined) => { + vscode.commands.registerCommand('pr.openChanges', async (pr: PRNode | RepositoryChangesNode | PullRequestModel | OverviewContext | 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); @@ -698,10 +770,30 @@ export function registerCommands( pullRequestModel = pr.pullRequestModel; } else if (pr instanceof PullRequestModel) { pullRequestModel = pr; - } else if (isChatSessionWithPR(pr)) { - pullRequestModel = pr.pullRequest; - } else { - const resolved = await resolvePr(pr as OverviewContext); + } 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; } @@ -747,27 +839,82 @@ export function registerCommands( } })); + const pickPullRequest = async (pr: PRNode | RepositoryChangesNode | PullRequestModel, linkGenerator: (pr: PullRequestModel) => string, requiresHead: boolean = false) => { + if (pr === undefined) { + // This is unexpected, but has happened a few times. + Logger.error('Unexpectedly received undefined when picking a PR.', logId); + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request was selected to checkout, please try again.')); + } + + let pullRequestModel: PullRequestModel; + + if (pr instanceof PRNode || pr instanceof RepositoryChangesNode) { + pullRequestModel = pr.pullRequestModel; + } else { + pullRequestModel = pr; + } + + if (requiresHead && !pullRequestModel.head) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to checkout pull request: missing head branch information.')); + } + + return vscode.env.openExternal(vscode.Uri.parse(linkGenerator(pullRequestModel))); + }; + + context.subscriptions.push( + vscode.commands.registerCommand('pr.pickOnVscodeDev', async (pr: PRNode | RepositoryChangesNode | PullRequestModel) => + pickPullRequest(pr, vscodeDevPrLink) + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.pickOnCodespaces', async (pr: PRNode | RepositoryChangesNode | PullRequestModel) => + pickPullRequest(pr, codespacesPrLink, true) + ), + ); + context.subscriptions.push( - vscode.commands.registerCommand('pr.pickOnVscodeDev', async (pr: PRNode | RepositoryChangesNode | PullRequestModel) => { + vscode.commands.registerCommand('pr.pickInWorktree', async (pr: PRNode | PullRequestModel | unknown) => { if (pr === undefined) { - // This is unexpected, but has happened a few times. - Logger.error('Unexpectedly received undefined when picking a PR.', logId); + 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.')); } let pullRequestModel: PullRequestModel; + let repository: Repository | undefined; - if (pr instanceof PRNode || pr instanceof RepositoryChangesNode) { + if (pr instanceof PRNode) { pullRequestModel = pr.pullRequestModel; - } else { + 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.')); } - return vscode.env.openExternal(vscode.Uri.parse(vscodeDevPrLink(pullRequestModel))); + // 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 checkoutPRInWorktree(telemetry, folderManager, pullRequestModel, repository); }), ); - context.subscriptions.push(vscode.commands.registerCommand('pr.checkoutOnVscodeDevFromDescription', async (context: OverviewContext | undefined) => { + 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.checkoutOnVscodeDevFromDescription', async (context: BaseContext | undefined) => { if (!context) { return vscode.window.showErrorMessage(vscode.l10n.t('No pull request context provided for checkout.')); } @@ -778,6 +925,28 @@ export function registerCommands( return vscode.env.openExternal(vscode.Uri.parse(vscodeDevPrLink(resolved.pr))); })); + 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.openSessionLogFromDescription', async (context: SessionLinkInfo | undefined) => { + if (!context) { + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request context provided for checkout.')); + } + const resource = SessionIdForPr.getResource(context.pullNumber, context.sessionIndex); + return vscode.commands.executeCommand('vscode.open', resource); + })); + context.subscriptions.push( vscode.commands.registerCommand('pr.exit', async (pr: PRNode | RepositoryChangesNode | PullRequestModel | undefined) => { let pullRequestModel: PullRequestModel | undefined; @@ -816,7 +985,7 @@ export function registerCommands( const manager = reposManager.getManagerForIssueModel(pullRequestModel); if (manager) { const prBranch = manager.repository.state.HEAD?.name; - await manager.checkoutDefaultBranch(branch); + await manager.checkoutDefaultBranch(branch, pullRequestModel); if (prBranch) { await manager.cleanupAfterPullRequest(prBranch, pullRequestModel!); } @@ -867,37 +1036,6 @@ export function registerCommands( }), ); - context.subscriptions.push( - vscode.commands.registerCommand('pr.readyForReview', async (pr?: PRNode) => { - const folderManager = reposManager.getManagerForIssueModel(pr?.pullRequestModel); - if (!folderManager) { - return; - } - const pullRequest = ensurePR(folderManager, pr); - const yes = vscode.l10n.t('Yes'); - return vscode.window - .showWarningMessage( - vscode.l10n.t('Are you sure you want to mark this pull request as ready to review on GitHub?'), - { modal: true }, - yes, - ) - .then(async value => { - let isDraft; - if (value === yes) { - try { - isDraft = (await pullRequest.setReadyForReview()).isDraft; - return isDraft; - } catch (e) { - vscode.window.showErrorMessage( - `Unable to mark pull request as ready to review. ${formatError(e)}`, - ); - return isDraft; - } - } - }); - }), - ); - context.subscriptions.push( vscode.commands.registerCommand('pr.dismissNotification', node => { if (node instanceof PRNode) { @@ -915,7 +1053,7 @@ export function registerCommands( }), ); - async function openDescriptionCommand(argument: RepositoryChangesNode | PRNode | IssueModel | ChatSessionWithPR | undefined) { + async function openDescriptionCommand(argument: RepositoryChangesNode | PRNode | IssueModel | CrossChatSessionWithPR | PRChatContextItem | IssueChatContextItem | undefined) { let issueModel: IssueModel | undefined; if (!argument) { const activePullRequests: PullRequestModel[] = reposManager.folderManagers @@ -932,8 +1070,17 @@ export function registerCommands( issueModel = argument.pullRequestModel; } else if (argument instanceof PRNode) { issueModel = argument.pullRequestModel; - } else if (isChatSessionWithPR(argument)) { - issueModel = argument.pullRequest; + } 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; } @@ -963,8 +1110,14 @@ export function registerCommands( await openDescription(telemetry, issueModel, descriptionNode, folderManager, revealDescription, !(argument instanceof RepositoryChangesNode)); } - async function checkoutChatSessionPullRequest(argument: ChatSessionWithPR) { - const pr = argument.pullRequest; + 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; @@ -976,28 +1129,7 @@ export function registerCommands( return vscode.window.showErrorMessage(vscode.l10n.t('Unable to find repository for pull request #{0}', pr.number.toString())); } - return switchToPr(folderManager, pr, folderManager.repository, false); - } - - async function closeChatSessionPullRequest(argument: ChatSessionWithPR) { - const pr = argument.pullRequest; - if (!pr) { - Logger.warn(`No pull request found in chat session`, logId); - return; - } - pr.close(); - copilotRemoteAgentManager.refreshChatSessions(); - } - - async function cancelCodingAgent(argument: ChatSessionWithPR) { - const pr = argument.pullRequest; - if (!pr) { - Logger.warn(`No pull request found in chat session`, logId); - return; - } - - copilotRemoteAgentManager.cancelMostRecentChatSession(pr); - // TODO: show a progress icon until the cancelation is finished + return reviewsManager.switchToPr(folderManager, pr, folderManager.repository, false); } context.subscriptions.push( @@ -1007,20 +1139,6 @@ export function registerCommands( ) ); - context.subscriptions.push( - vscode.commands.registerCommand( - 'pr.closeChatSessionPullRequest', - closeChatSessionPullRequest - ) - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - 'pr.cancelCodingAgent', - cancelCodingAgent - ) - ); - context.subscriptions.push( vscode.commands.registerCommand( 'pr.openDescription', @@ -1037,16 +1155,18 @@ export function registerCommands( context.subscriptions.push( vscode.commands.registerCommand('pr.refreshDescription', async () => { - if (PullRequestOverviewPanel.currentPanel) { - PullRequestOverviewPanel.refresh(); + const panel = PullRequestOverviewPanel.getActivePanel(); + if (panel) { + panel.refreshPanel(); } }), ); context.subscriptions.push(vscode.commands.registerCommand('pr.focusDescriptionInput', async () => { - if (PullRequestOverviewPanel.currentPanel) { - PullRequestOverviewPanel.scrollToReview(); + const panel = PullRequestOverviewPanel.getActivePanel(); + if (panel) { + panel.scrollToPendingReview(); } } )); @@ -1060,8 +1180,13 @@ export function registerCommands( const pr = descriptionNode.pullRequestModel; const pullRequest = ensurePR(folderManager, pr); descriptionNode.reveal(descriptionNode, { select: true, focus: true }); + const identity = { + owner: pullRequest.remote.owner, + repo: pullRequest.remote.repositoryName, + number: pullRequest.number + }; // Create and show a new webview - PullRequestOverviewPanel.createOrShow(telemetry, context.extensionUri, folderManager, pullRequest, true); + PullRequestOverviewPanel.createOrShow(telemetry, context.extensionUri, folderManager, identity, pullRequest, true); /* __GDPR__ "pr.openDescriptionToTheSide" : {} @@ -1301,6 +1426,167 @@ ${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__ @@ -1489,7 +1775,11 @@ ${contents} ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager)?.updateComments(); } - PullRequestOverviewPanel.refresh(); + PullRequestOverviewPanel.refresh( + prNode.pullRequestModel.remote.owner, + prNode.pullRequestModel.remote.repositoryName, + prNode.pullRequestModel.number, + ); }), ); @@ -1592,7 +1882,7 @@ ${contents} })); context.subscriptions.push( - vscode.commands.registerCommand('pr.copyVscodeDevPrLink', async (params: OverviewContext | undefined) => { + 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); @@ -1612,13 +1902,17 @@ ${contents} })); context.subscriptions.push( - vscode.commands.registerCommand('pr.copyPrLink', async (params: OverviewContext | undefined) => { - let pr: PullRequestModel | undefined; + vscode.commands.registerCommand('pr.copyPrLink', async (params: BaseContext | undefined) => { + let item: PullRequestModel | IssueModel | undefined; if (params) { - pr = await reposManager.getManagerForRepository(params.owner, params.repo)?.resolvePullRequest(params.owner, params.repo, params.number, true); + 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 (pr) { - return vscode.env.clipboard.writeText(pr.html_url); + if (item) { + return vscode.env.clipboard.writeText(item.html_url); } })); @@ -1664,26 +1958,94 @@ ${contents} if (!githubRepo) { return; } - const prNumber = await vscode.window.showInputBox({ - ignoreFocusOut: true, prompt: vscode.l10n.t('Enter the pull request number or URL'), - validateInput: (input: string) => { - const result = validateAndParseInput(input, githubRepo.repo.remote.owner, githubRepo.repo.remote.repositoryName); - return result.isValid ? undefined : result.errorMessage; + + // Create QuickPick to show all PRs + const quickPick = vscode.window.createQuickPick(); + quickPick.placeholder = vscode.l10n.t('Enter a pull request number/URL or select from the list'); + quickPick.matchOnDescription = true; + quickPick.matchOnDetail = true; + quickPick.show(); + + let acceptDisposable: vscode.Disposable | undefined; + let hideDisposable: vscode.Disposable | undefined; + + try { + const selectedPromise = new Promise<{ selectedItem: (vscode.QuickPickItem & { prNumber?: number }) | undefined, selectedString: string | undefined }>((resolve) => { + acceptDisposable = quickPick.onDidAccept(() => { + let selectedString: string | undefined; + let selectedItem: (vscode.QuickPickItem & { prNumber?: number }) | undefined; + + if (quickPick.value) { + selectedString = quickPick.value; + } + + if (quickPick.selectedItems.length > 0) { + selectedItem = quickPick.selectedItems[0]; + } + + resolve({ selectedItem, selectedString }); + }); + hideDisposable = quickPick.onDidHide(() => resolve({ selectedItem: undefined, selectedString: undefined })); + }); + + const prs = await githubRepo.repo.getPullRequestNumbers(); + if (!prs) { + return vscode.window.showErrorMessage(vscode.l10n.t('Failed to fetch pull requests')); } - }); - if ((prNumber === undefined) || prNumber === '#') { - return; - } + // 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')); + } - // Extract PR number from input (either direct number or URL) - const parseResult = validateAndParseInput(prNumber, githubRepo.repo.remote.owner, githubRepo.repo.remote.repositoryName); - if (!parseResult.isValid) { - 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); + } - const prModel = await githubRepo.manager.fetchById(githubRepo.repo, parseResult.prNumber); - if (prModel) { - return ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, githubRepo.manager)?.switch(prModel); + 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(); } })); @@ -1728,23 +2090,52 @@ ${contents} } })); context.subscriptions.push( - vscode.commands.registerCommand('githubpr.remoteAgent', async (args: ICopilotRemoteAgentCommandArgs) => await copilotRemoteAgentManager.commandImpl(args)) - ); - context.subscriptions.push( - vscode.commands.registerCommand('pr.applySuggestionWithCopilot', async (comment: GHPRComment) => { + vscode.commands.registerCommand('pr.applySuggestionWithCopilot', async (comment: GHPRComment | GHPRCommentThread) => { /* __GDPR__ "pr.applySuggestionWithCopilot" : {} */ telemetry.sendTelemetryEvent('pr.applySuggestionWithCopilot'); - const commentThread = comment.parent; + const isThread = GHPRCommentThread.is(comment); + const commentThread = isThread ? comment : comment.parent; + const firstComment = isThread ? comment.comments[0] : comment; commentThread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed; - const message = comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body; - await vscode.commands.executeCommand('vscode.editorChat.start', { - initialRange: commentThread.range, - message: message, - autoSend: true, - }); + 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( @@ -1928,20 +2319,64 @@ ${contents} ); context.subscriptions.push( - vscode.commands.registerCommand('pr.refreshChatSessions', async () => { - copilotRemoteAgentManager.refreshChatSessions(); - }) - ); + 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; + } - context.subscriptions.push( - vscode.commands.registerCommand('pr.preferredCodingAgentGitHubRemote', async () => { - await copilotRemoteAgentManager.promptAndUpdatePreferredGitHubRemote(); - }) - ); + const folderManager = reposManager.getManagerForFile(args.rootUri); + if (!folderManager) { + Logger.error('Unable to find a repository for the provided rootUri.', logId); + return undefined; + } - context.subscriptions.push( - vscode.commands.registerCommand('pr.resetCodingAgentPreferences', async () => { - await copilotRemoteAgentManager.resetCodingAgentPreferences(); - }) + 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/common/async.ts b/src/common/async.ts index 35488704b6..99c385dd4f 100644 --- a/src/common/async.ts +++ b/src/common/async.ts @@ -36,13 +36,13 @@ export function throttle(fn: () => Promise): () => Promise { return trigger; } -export function debounce(fn: () => any, delay: number): () => void { +export function debounce any>(fn: T, delay: number): (...args: Parameters) => void { let timer: NodeJS.Timeout | undefined; - return () => { + return (...args: Parameters) => { if (timer) { clearTimeout(timer); } - timer = setTimeout(() => fn(), delay); + timer = setTimeout(() => fn(...args), delay); }; } diff --git a/src/common/comment.ts b/src/common/comment.ts index 889549c996..3f9559d973 100644 --- a/src/common/comment.ts +++ b/src/common/comment.ts @@ -65,6 +65,12 @@ export interface IComment { 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 = { diff --git a/src/common/copilot.ts b/src/common/copilot.ts index e8b644e625..0062faf66f 100644 --- a/src/common/copilot.ts +++ b/src/common/copilot.ts @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ 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'; @@ -15,6 +17,15 @@ export const COPILOT_LOGINS = [ '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, diff --git a/src/common/executeCommands.ts b/src/common/executeCommands.ts index bf550fd2ce..54dc5800d3 100644 --- a/src/common/executeCommands.ts +++ b/src/common/executeCommands.ts @@ -20,11 +20,13 @@ export namespace contexts { 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'; @@ -40,4 +42,8 @@ export namespace commands { export function setContext(context: string, value: any) { return executeCommand('setContext', context, value); } + + export function openFolder(ur: vscode.Uri, options: { forceNewWindow?: boolean, forceReuseWindow?: boolean }) { + return executeCommand('vscode.openFolder', ur, options); + } } \ No newline at end of file diff --git a/src/common/gitUtils.ts b/src/common/gitUtils.ts index dc4539e1ec..471b8a25ea 100644 --- a/src/common/gitUtils.ts +++ b/src/common/gitUtils.ts @@ -7,6 +7,372 @@ 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. diff --git a/src/common/remote.ts b/src/common/remote.ts index ae3e34ae23..c5283ca606 100644 --- a/src/common/remote.ts +++ b/src/common/remote.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { AuthProvider, GitHubServerType } from './authentication'; +import Logger from './logger'; import { Protocol } from './protocol'; import { Repository } from '../api/api'; import { getEnterpriseUri, isEnterprise } from '../github/utils'; @@ -74,6 +75,54 @@ export function parseRemote(remoteName: string, url: string, originalProtocol?: return null; } +/** + * Resolves git URL aliases by applying insteadOf substitutions from git config. + * For example, if git config has: + * [url "git@github.com:"] + * insteadOf = "gh:" + * Then "gh:user/repo" will be resolved to "git@github.com:user/repo" + * + * @param url The URL to resolve + * @param repository The repository to get config from + * @returns The resolved URL, or the original URL if no substitution found + */ +async function resolveGitUrl(url: string, repository: Repository): Promise { + try { + // Get all git config entries + const configs = await repository.getConfigs(); + + // Find all url.*.insteadOf entries + const urlSubstitutions: { prefix: string; replacement: string }[] = []; + + for (const config of configs) { + // Match patterns like "url.https://github.com/.insteadOf" or "url.git@github.com:.insteadOf" + const match = config.key.match(/^url\.(.+)\.insteadof$/i); + if (match) { + const replacement = match[1]; + const prefix = config.value; + urlSubstitutions.push({ prefix, replacement }); + } + } + + // Sort by prefix length (longest first) to handle overlapping prefixes correctly + urlSubstitutions.sort((a, b) => b.prefix.length - a.prefix.length); + + // Apply the first matching substitution + for (const { prefix, replacement } of urlSubstitutions) { + if (url.startsWith(prefix)) { + const resolvedUrl = replacement + url.substring(prefix.length); + Logger.appendLine(`Resolved git URL alias: "${url}" -> "${resolvedUrl}"`, 'Remote'); + return resolvedUrl; + } + } + } catch (error) { + Logger.error(`Failed to resolve git URL aliases for "${url}": ${error}`, 'Remote'); + } + + // No substitution found or error occurred, return original URL + return url; +} + export function parseRepositoryRemotes(repository: Repository): Remote[] { const remotes: Remote[] = []; for (const r of repository.state.remotes) { @@ -94,6 +143,38 @@ export function parseRepositoryRemotes(repository: Repository): Remote[] { return remotes; } +/** + * Asynchronously parses repository remotes with git URL alias resolution. + * This version resolves git URL aliases (e.g., "gh:" -> "git@github.com:") before parsing. + * Use this version when you need accurate remote parsing with alias resolution. + * + * @param repository The repository to parse remotes from + * @returns Promise resolving to array of Remote objects + */ +export async function parseRepositoryRemotesAsync(repository: Repository): Promise { + const remotes: Remote[] = []; + for (const r of repository.state.remotes) { + const urls: string[] = []; + if (r.fetchUrl) { + // Resolve git URL aliases before parsing + const resolvedUrl = await resolveGitUrl(r.fetchUrl, repository); + urls.push(resolvedUrl); + } + if (r.pushUrl && r.pushUrl !== r.fetchUrl) { + // Resolve git URL aliases before parsing + const resolvedUrl = await resolveGitUrl(r.pushUrl, repository); + urls.push(resolvedUrl); + } + urls.forEach(url => { + const remote = parseRemote(r.name, url); + if (remote) { + remotes.push(remote); + } + }); + } + return remotes; +} + export class GitHubRemote extends Remote { static remoteAsGitHub(remote: Remote, githubServerType: GitHubServerType): GitHubRemote { return new GitHubRemote(remote.remoteName, remote.url, remote.gitProtocol, githubServerType); diff --git a/src/common/settingKeys.ts b/src/common/settingKeys.ts index 1376c4c7bc..4a87f4256e 100644 --- a/src/common/settingKeys.ts +++ b/src/common/settingKeys.ts @@ -10,6 +10,7 @@ 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'; @@ -22,6 +23,8 @@ 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'; @@ -33,12 +36,17 @@ 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'; @@ -49,20 +57,28 @@ 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 ISSUE_COMPLETION_FORMAT_EDITOR = 'issueCompletionFormatEditor'; 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 SHOW_ISSUE_NUMBER_IN_TREE = 'showIssueNumberInTree'; 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'; @@ -85,13 +101,14 @@ export const WORD_WRAP = 'wordWrap'; export const COMMENTS = 'comments'; export const OPEN_VIEW = 'openView'; -// Explorer -export const EXPLORER = 'explorer'; -export const AUTO_REVEAL = 'autoReveal'; - // 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 @@ -99,4 +116,3 @@ 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'; -export const SHOW_CODE_LENS = 'codeLens'; \ No newline at end of file diff --git a/src/common/settingsUtils.ts b/src/common/settingsUtils.ts index 2c3b3d7001..3522ce3e98 100644 --- a/src/common/settingsUtils.ts +++ b/src/common/settingsUtils.ts @@ -6,7 +6,7 @@ 'use strict'; import * as vscode from 'vscode'; import { commands } from './executeCommands'; -import { PR_SETTINGS_NAMESPACE, QUERIES, USE_REVIEW_MODE } from './settingKeys'; +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 }; @@ -54,12 +54,17 @@ export function editQuery(namespace: string, queryName: string) { const inputBox = vscode.window.createQuickPick(); inputBox.title = vscode.l10n.t('Edit Query "{0}"', queryName ?? ''); inputBox.value = queryValue ?? ''; - inputBox.items = [ + 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 }, - { iconPath: new vscode.ThemeIcon('sparkle'), label: vscode.l10n.t('Edit with AI'), 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 () => { @@ -89,7 +94,7 @@ export function editQuery(namespace: string, queryName: string) { } else if (inputBox.selectedItems[0] === inputBox.items[2]) { openSettingsAtQuery(config, inspect, queryName); inputBox.dispose(); - } else if (inputBox.selectedItems[0] === inputBox.items[3]) { + } else if (inputBox.selectedItems[0] === editWithAIItem) { inputBox.ignoreFocusOut = true; await openCopilotForQuery(inputBox.value); inputBox.busy = false; diff --git a/src/common/temporaryState.ts b/src/common/temporaryState.ts index d702c75af4..3ae38b71ce 100644 --- a/src/common/temporaryState.ts +++ b/src/common/temporaryState.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import { disposeAll } from './lifecycle'; import Logger from './logger'; +import { isDescendant } from './utils'; let tempState: TemporaryState | undefined; @@ -39,10 +40,21 @@ export class TemporaryState extends vscode.Disposable { } } - private async writeState(subpath: string, filename: string, contents: Uint8Array, persistInSession: boolean): Promise { + private async writeState(subpath: string, filename: string, contents: Uint8Array, persistInSession: boolean, repositoryUri: vscode.Uri): Promise { let filePath: vscode.Uri = this.path; - const workspace = (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) - ? vscode.workspace.workspaceFolders[0].name : undefined; + let workspace: string | undefined; + + if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) { + const matchingFolder = vscode.workspace.workspaceFolders.find(folder => + isDescendant(folder.uri.fsPath, repositoryUri.fsPath) || isDescendant(repositoryUri.fsPath, folder.uri.fsPath) + ); + workspace = matchingFolder?.name; + } + + // Fall back to the first workspace folder if no match found + if (!workspace && vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) { + workspace = vscode.workspace.workspaceFolders[0].name; + } if (workspace) { filePath = vscode.Uri.joinPath(filePath, workspace); @@ -68,10 +80,21 @@ export class TemporaryState extends vscode.Disposable { return file; } - private async readState(subpath: string, filename: string): Promise { + private async readState(subpath: string, filename: string, repositoryUri: vscode.Uri): Promise { let filePath: vscode.Uri = this.path; - const workspace = (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) - ? vscode.workspace.workspaceFolders[0].name : undefined; + let workspace: string | undefined; + + if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) { + const matchingFolder = vscode.workspace.workspaceFolders.find(folder => + isDescendant(folder.uri.fsPath, repositoryUri.fsPath) || isDescendant(repositoryUri.fsPath, folder.uri.fsPath) + ); + workspace = matchingFolder?.name; + } + + // Fall back to the first workspace folder if no match found + if (!workspace && vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) { + workspace = vscode.workspace.workspaceFolders[0].name; + } if (workspace) { filePath = vscode.Uri.joinPath(filePath, workspace); @@ -87,7 +110,10 @@ export class TemporaryState extends vscode.Disposable { try { await vscode.workspace.fs.delete(tempState.path, { recursive: true }); } catch (e) { - Logger.appendLine(`Error in initialization: ${e.message}`, TemporaryState.ID); + // 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); @@ -99,19 +125,19 @@ export class TemporaryState extends vscode.Disposable { } } - static async write(subpath: string, filename: string, contents: Uint8Array, persistInSession: boolean = false): Promise { + static async write(subpath: string, filename: string, contents: Uint8Array, persistInSession: boolean = false, repositoryUri: vscode.Uri): Promise { if (!tempState) { return; } - return tempState.writeState(subpath, filename, contents, persistInSession); + return tempState.writeState(subpath, filename, contents, persistInSession, repositoryUri); } - static async read(subpath: string, filename: string): Promise { + static async read(subpath: string, filename: string, repositoryUri: vscode.Uri): Promise { if (!tempState) { return; } - return tempState.readState(subpath, filename); + 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 2f719c5f2e..a72848cfc8 100644 --- a/src/common/timelineEvent.ts +++ b/src/common/timelineEvent.ts @@ -22,9 +22,11 @@ export enum EventType { CrossReferenced, Closed, Reopened, + BaseRefChanged, CopilotStarted, CopilotFinished, CopilotFinishedError, + CopilotReviewStarted, Other, } @@ -59,7 +61,13 @@ export type ReviewStateValue = 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED' | export interface ReviewEvent { id: number; - reviewThread?: ReviewResolveInfo + /** + * 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; @@ -81,6 +89,22 @@ export interface CommitEvent { 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 { @@ -155,6 +179,15 @@ export interface ReopenedEvent { createdAt: string; } +export interface BaseRefChangedEvent { + id: string; + event: EventType.BaseRefChanged; + actor: IActor; + createdAt: string; + currentRefName: string; + previousRefName: string; +} + export interface SessionPullInfo { id: number; host: string; @@ -191,4 +224,10 @@ export interface CopilotFinishedErrorEvent { sessionLink: SessionLinkInfo; } -export type TimelineEvent = CommitEvent | ReviewEvent | CommentEvent | NewCommitsSinceReviewEvent | MergedEvent | AssignEvent | UnassignEvent | HeadRefDeleteEvent | CrossReferencedEvent | ClosedEvent | ReopenedEvent | CopilotStartedEvent | CopilotFinishedEvent | CopilotFinishedErrorEvent; +export interface CopilotReviewStartedEvent { + id: string; + event: EventType.CopilotReviewStarted; + createdAt: string; +} + +export type TimelineEvent = CommitEvent | ReviewEvent | CommentEvent | NewCommitsSinceReviewEvent | MergedEvent | AssignEvent | UnassignEvent | HeadRefDeleteEvent | CrossReferencedEvent | ClosedEvent | ReopenedEvent | BaseRefChangedEvent | CopilotStartedEvent | CopilotFinishedEvent | CopilotFinishedErrorEvent | CopilotReviewStartedEvent; diff --git a/src/common/uri.ts b/src/common/uri.ts index 9aecf5f3e0..dfe954370e 100644 --- a/src/common/uri.ts +++ b/src/common/uri.ts @@ -195,7 +195,7 @@ export async function asTempStorageURI(uri: vscode.Uri, repository: Repository): if (ImageMimetypes.indexOf(mimetype) > -1) { const contents = await repository.buffer(ref, absolutePath); - return TemporaryState.write(pathUtils.dirname(path), pathUtils.basename(path), contents); + return TemporaryState.write(pathUtils.dirname(path), pathUtils.basename(path), contents, false, repository.rootUri); } } catch (err) { return; @@ -290,6 +290,16 @@ export namespace DataUri { 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); @@ -324,9 +334,11 @@ export namespace DataUri { if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - const buffer = await response.arrayBuffer(); - await writeAvatarToCache(context, user, new Uint8Array(buffer)); - innerImageContents = Buffer.from(buffer); + 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(); @@ -520,6 +532,36 @@ export function createPRNodeUri( }); } +export interface CommitsNodeUriParams { + owner: string; + repo: string; + prNumber: number; +} + +export function createCommitsNodeUri(owner: string, repo: string, prNumber: number): vscode.Uri { + const params: CommitsNodeUriParams = { + owner, + repo, + prNumber + }; + + return vscode.Uri.parse(`${Schemes.CommitsNode}:${owner}/${repo}/${prNumber}`).with({ + scheme: Schemes.CommitsNode, + query: JSON.stringify(params) + }); +} + +export function fromCommitsNodeUri(uri: vscode.Uri): CommitsNodeUriParams | undefined { + if (uri.scheme !== Schemes.CommitsNode) { + return undefined; + } + try { + return JSON.parse(uri.query) as CommitsNodeUriParams; + } catch (e) { + return undefined; + } +} + export interface NotificationUriParams { key: string; } @@ -607,7 +649,7 @@ export function fromRepoUri(uri: vscode.Uri): RepoUriParams | undefined { } catch (e) { } } -const ownerRegex = /^(?!-)(?!.*--)[a-zA-Z0-9-]+(?[^\/]+)\/(?[^\/]+)\/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; @@ -676,14 +741,42 @@ export async function toOpenPullRequestWebviewUri(params: OpenPullRequestWebview return vscode.env.asExternalUri(vscode.Uri.from({ scheme: vscode.env.uriScheme, authority: EXTENSION_ID, path: UriHandlerPaths.OpenPullRequestWebview, query })); } -export function fromOpenPullRequestWebviewUri(uri: vscode.Uri): OpenPullRequestWebviewUriParams | undefined { +export 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) { + 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; @@ -726,7 +819,9 @@ export enum Schemes { 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 + 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) { diff --git a/src/common/user.ts b/src/common/user.ts index ce7b40e876..0056b83f12 100644 --- a/src/common/user.ts +++ b/src/common/user.ts @@ -7,7 +7,7 @@ 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', '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']; +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']; diff --git a/src/common/utils.ts b/src/common/utils.ts index 470e0a5379..10ee3c7005 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -102,7 +102,13 @@ function isWindowsPath(path: string): boolean { return /^[a-zA-Z]:\\/.test(path); } -export function isDescendant(parent: string, descendant: string, separator: string = sep): boolean { +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 === descendant) { return true; } @@ -111,12 +117,6 @@ export function isDescendant(parent: string, descendant: string, separator: stri parent += separator; } - // Windows is case insensitive - if (isWindowsPath(parent)) { - parent = parent.toLowerCase(); - descendant = descendant.toLowerCase(); - } - return descendant.startsWith(parent); } @@ -1009,9 +1009,183 @@ 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}`; -} \ No newline at end of file +} + +/** + * 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 index 02b59a029a..a41fab61e4 100644 --- a/src/common/webview.ts +++ b/src/common/webview.ts @@ -22,15 +22,6 @@ export interface IReplyMessage { res?: any; } -export function getNonce() { - let text = ''; - const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - for (let i = 0; i < 32; i++) { - text += possible.charAt(Math.floor(Math.random() * possible.length)); - } - return text; -} - export class WebviewBase extends Disposable { protected _webview?: vscode.Webview; @@ -83,6 +74,7 @@ export class WebviewBase extends Disposable { seq: originalMessage.req, res: message, }; + await this._waitForReady; this._webview?.postMessage(reply); } @@ -91,6 +83,7 @@ export class WebviewBase extends Disposable { seq: originalMessage?.req, err: error, }; + await this._waitForReady; this._webview?.postMessage(reply); } } diff --git a/src/experimentationService.ts b/src/experimentationService.ts index f184926664..9ffc1f4f34 100644 --- a/src/experimentationService.ts +++ b/src/experimentationService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import TelemetryReporter from '@vscode/extension-telemetry'; +import { TelemetryReporter } from '@vscode/extension-telemetry'; import * as vscode from 'vscode'; import { getExperimentationService, diff --git a/src/extension.ts b/src/extension.ts index 251c7451a6..d3a4c65a38 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,509 +1,511 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { COPILOT_SWE_AGENT } from './common/copilot'; -import { commands } from './common/executeCommands'; -import { isSubmodule } from './common/gitUtils'; -import Logger from './common/logger'; -import * as PersistentState from './common/persistentState'; -import { parseRepositoryRemotes } from './common/remote'; -import { BRANCH_PUBLISH, EXPERIMENTAL_CHAT, FILE_LIST_LAYOUT, GIT, IGNORE_SUBMODULES, OPEN_DIFF_ON_CLICK, PR_SETTINGS_NAMESPACE, SHOW_INLINE_OPEN_FILE_ACTION } from './common/settingKeys'; -import { initBasedOnSettingChange } from './common/settingsUtils'; -import { TemporaryState } from './common/temporaryState'; -import { Schemes } from './common/uri'; -import { isDescendant } from './common/utils'; -import { EXTENSION_ID, FOCUS_REVIEW_MODE } from './constants'; -import { createExperimentationService, ExperimentationTelemetry } from './experimentationService'; -import { CopilotRemoteAgentManager } from './github/copilotRemoteAgent'; -import { CredentialStore } from './github/credentials'; -import { FolderRepositoryManager } from './github/folderRepositoryManager'; -import { OverviewRestorer } from './github/overviewRestorer'; -import { RepositoriesManager } from './github/repositoriesManager'; -import { registerBuiltinGitProvider, registerLiveShareGitProvider } from './gitProviders/api'; -import { GitHubContactServiceProvider } from './gitProviders/GitHubContactServiceProvider'; -import { GitLensIntegration } from './integrations/gitlens/gitlensImpl'; -import { IssueFeatureRegistrar } from './issues/issueFeatureRegistrar'; -import { ChatParticipant, ChatParticipantState } from './lm/participants'; -import { registerTools } from './lm/tools/tools'; -import { migrate } from './migrations'; -import { NotificationsFeatureRegister } from './notifications/notificationsFeatureRegistar'; -import { NotificationsManager } from './notifications/notificationsManager'; -import { NotificationsProvider } from './notifications/notificationsProvider'; -import { ThemeWatcher } from './themeWatcher'; -import { UriHandler } from './uriHandler'; -import { CommentDecorationProvider } from './view/commentDecorationProvider'; -import { CompareChanges } from './view/compareChangesTreeDataProvider'; -import { CreatePullRequestHelper } from './view/createPullRequestHelper'; -import { EmojiCompletionProvider } from './view/emojiCompletionProvider'; -import { FileTypeDecorationProvider } from './view/fileTypeDecorationProvider'; -import { GitHubCommitFileSystemProvider } from './view/githubFileContentProvider'; -import { getInMemPRFileSystemProvider } from './view/inMemPRContentProvider'; -import { PullRequestChangesTreeDataProvider } from './view/prChangesTreeDataProvider'; -import { 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; - } - - if (vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'ask' | 'never' | undefined>(BRANCH_PUBLISH) !== 'ask') { - 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; - } - - 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)]); - - const notificationsProvider = new NotificationsProvider(credentialStore, reposManager); - context.subscriptions.push(notificationsProvider); - - const notificationsManager = new NotificationsManager(notificationsProvider, credentialStore, reposManager, context); - context.subscriptions.push(notificationsManager); - - const reviewsManager = new ReviewsManager(context, reposManager, reviewManagers, prsTreeModel, tree, changesTree, telemetry, credentialStore, git, copilotRemoteAgentManager, notificationsManager); - context.subscriptions.push(reviewsManager); - - context.subscriptions.push(vscode.languages.registerCompletionItemProvider( - { scheme: Schemes.Comment }, - new EmojiCompletionProvider(context), - ':' - )); - - git.onDidChangeState(() => { - Logger.appendLine(`Git initialization state changed: state=${git.state}`, ACTIVATION); - reviewsManager.reviewManagers.forEach(reviewManager => reviewManager.updateState(true)); - }); - - git.onDidOpenRepository(repo => { - function addRepo() { - // Make sure we don't already have a folder manager for this repo. - const existing = reposManager.folderManagers.find(manager => manager.repository.rootUri.toString() === repo.rootUri.toString()); - if (existing) { - Logger.appendLine(`Repo ${repo.rootUri} has already been setup.`, ACTIVATION); - return; - } - - // Check if submodules should be ignored - const ignoreSubmodules = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(IGNORE_SUBMODULES, false); - if (ignoreSubmodules && isSubmodule(repo, git)) { - Logger.appendLine(`Repo ${repo.rootUri} is a submodule and will be ignored due to ${IGNORE_SUBMODULES} setting.`, ACTIVATION); - return; - } - - const newFolderManager = new FolderRepositoryManager(reposManager.folderManagers.length, context, repo, telemetry, git, credentialStore, createPrHelper, themeWatcher); - reposManager.insertFolderManager(newFolderManager); - const newReviewManager = new ReviewManager( - reviewManagerIndex++, - context, - newFolderManager.repository, - newFolderManager, - telemetry, - changesTree, - tree, - showPRController, - activePrViewCoordinator, - createPrHelper, - git - ); - reviewsManager.addReviewManager(newReviewManager); - } - - // Check if repo is in one of the workspace folders or vice versa - if (workspaceFolders && !workspaceFolders.some(folder => isDescendant(folder.uri.fsPath, repo.rootUri.fsPath) || isDescendant(repo.rootUri.fsPath, folder.uri.fsPath))) { - Logger.appendLine(`Repo ${repo.rootUri} is not in a workspace folder, ignoring.`, ACTIVATION); - return; - } - addRepo(); - 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); - - const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); - await vscode.commands.executeCommand('setContext', 'fileListLayout:flat', layout === 'flat'); - - const issuesFeatures = new IssueFeatureRegistrar(git, reposManager, reviewsManager, context, telemetry, copilotRemoteAgentManager); - context.subscriptions.push(issuesFeatures); - await issuesFeatures.initialize(); - - const notificationsFeatures = new NotificationsFeatureRegister(credentialStore, reposManager, telemetry, notificationsManager); - context.subscriptions.push(notificationsFeatures); - - context.subscriptions.push(new GitLensIntegration()); - - context.subscriptions.push(new OverviewRestorer(reposManager, telemetry, context.extensionUri, credentialStore)); - - await vscode.commands.executeCommand('setContext', 'github:initialized', true); - - registerPostCommitCommandsProvider(reposManager, git); - - initChat(context, credentialStore, reposManager, copilotRemoteAgentManager, telemetry, prsTreeModel); - context.subscriptions.push(vscode.window.registerUriHandler(new UriHandler(reposManager, telemetry, context))); - - // 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, copilotRemoteManager: CopilotRemoteAgentManager, telemetry: ExperimentationTelemetry, prsTreeModel: PrsTreeModel) { - const createParticipant = () => { - const chatParticipantState = new ChatParticipantState(); - context.subscriptions.push(new ChatParticipant(context, chatParticipantState)); - registerTools(context, credentialStore, reposManager, chatParticipantState, copilotRemoteManager, telemetry, prsTreeModel); - }; - - const chatEnabled = () => vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(EXPERIMENTAL_CHAT, false); - if (chatEnabled()) { - createParticipant(); - } else { - initBasedOnSettingChange(PR_SETTINGS_NAMESPACE, EXPERIMENTAL_CHAT, chatEnabled, createParticipant, context.subscriptions); - } -} - -export async function activate(context: vscode.ExtensionContext): Promise { - Logger.appendLine(`Extension version: ${vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON.version}`, 'Activation'); - - // @ts-ignore - if (EXTENSION_ID === 'GitHub.vscode-pull-request-github-insiders') { - const stable = vscode.extensions.getExtension('github.vscode-pull-request-github'); - if (stable !== undefined) { - throw new Error( - 'GitHub Pull Requests and Issues Nightly cannot be used while GitHub Pull Requests and Issues is also installed. Please ensure that only one version of the extension is installed.', - ); - } - } - - const showPRController = new ShowPullRequest(); - vscode.commands.registerCommand('github.api.preloadPullRequest', async (shouldShow: boolean) => { - await vscode.commands.executeCommand('setContext', FOCUS_REVIEW_MODE, true); - await commands.focusView('github:activePullRequest:welcome'); - showPRController.shouldShow = shouldShow; - }); - await setGitSettingContexts(context); - - Logger.debug('Creating API implementation.', 'Activation'); - - telemetry = new ExperimentationTelemetry(new TelemetryReporter(ingestionKey)); - context.subscriptions.push(telemetry); - - return await deferredActivate(context, showPRController); -} - -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(reposManager: RepositoriesManager, git: GitApiImpl) { - const componentId = 'GitPostCommitCommands'; - class Provider implements PostCommitCommandsProvider { - - getCommands(repository: Repository) { - Logger.debug(`Looking for remote. Comparing ${repository.state.remotes.length} local repo remotes with ${reposManager.folderManagers.reduce((prev, curr) => prev + curr.gitHubRepositories.length, 0)} GitHub repositories.`, componentId); - const repoRemotes = parseRepositoryRemotes(repository); - - const found = reposManager.folderManagers.find(folderManager => folderManager.findRepo(githubRepo => { - return !!repoRemotes.find(remote => { - return remote.equals(githubRepo.remote); - }); - })); - Logger.debug(`Found ${found ? 'a repo' : 'no repos'} when getting post commit commands.`, componentId); - return found ? [{ - command: 'pr.pushAndCreate', - title: vscode.l10n.t('{0} Commit & Create Pull Request', '$(git-pull-request-create)'), - tooltip: vscode.l10n.t('Commit & Create Pull Request') - }] : []; - } - } - - function hasGitHubRepos(): boolean { - return reposManager.folderManagers.some(folderManager => folderManager.gitHubRepositories.length > 0); - } - function tryRegister(): boolean { - Logger.debug('Trying to register post commit commands.', 'GitPostCommitCommands'); - if (hasGitHubRepos()) { - Logger.debug('GitHub remote(s) found, registering post commit commands.', componentId); - git.registerPostCommitCommandsProvider(new Provider()); - return true; - } - return false; - } - - if (!tryRegister()) { - const reposDisposable = reposManager.onDidLoadAnyRepositories(() => { - if (tryRegister()) { - reposDisposable.dispose(); - } - }); - } -} - -async function deferredActivateRegisterBuiltInGitProvider(context: vscode.ExtensionContext, apiImpl: GitApiImpl, credentialStore: CredentialStore) { - Logger.debug('Registering built in git provider.', 'Activation'); - if (!(await doRegisterBuiltinGitProvider(context, credentialStore, apiImpl))) { - const extensionsChangedDisposable = vscode.extensions.onDidChange(async () => { - if (await doRegisterBuiltinGitProvider(context, credentialStore, apiImpl)) { - extensionsChangedDisposable.dispose(); - } - }); - context.subscriptions.push(extensionsChangedDisposable); - } -} - -async function deferredActivate(context: vscode.ExtensionContext, 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, apiImpl, prsTreeModel); - context.subscriptions.push(copilotRemoteAgentManager); - if (vscode.chat?.registerChatSessionItemProvider) { - const chatParticipant = vscode.chat.createChatParticipant(COPILOT_SWE_AGENT, async (request, context, stream, token) => - await copilotRemoteAgentManager.chatParticipantImpl(request, context, stream, token) - ); - context.subscriptions.push(chatParticipant); - - const provider = new class implements vscode.ChatSessionContentProvider, vscode.ChatSessionItemProvider { - label = vscode.l10n.t('GitHub Copilot Coding Agent'); - provideChatSessionItems = async (token) => { - return await copilotRemoteAgentManager.provideChatSessions(token); - }; - provideChatSessionContent = async (id, token) => { - return await copilotRemoteAgentManager.provideChatSessionContent(id, token); - }; - onDidChangeChatSessionItems = copilotRemoteAgentManager.onDidChangeChatSessions; - onDidCommitChatSessionItem = copilotRemoteAgentManager.onDidCommitChatSession; - }(); - - context.subscriptions.push(vscode.chat?.registerChatSessionItemProvider( - COPILOT_SWE_AGENT, - provider - )); - - context.subscriptions.push(vscode.chat?.registerChatSessionContentProvider( - COPILOT_SWE_AGENT, - provider, - chatParticipant, - { supportsInterruptions: true } - )); - } - - const prTree = new PullRequestsTreeDataProvider(prsTreeModel, telemetry, context, reposManager, copilotRemoteAgentManager); - context.subscriptions.push(prTree); - context.subscriptions.push(credentialStore.onDidGetSession(() => prTree.refreshAll(true))); - Logger.appendLine('Looking for git repository', ACTIVATION); - const repositories = apiImpl.repositories; - Logger.appendLine(`Found ${repositories.length} repositories during activation`, ACTIVATION); - const createPrHelper = new CreatePullRequestHelper(); - context.subscriptions.push(createPrHelper); - - const themeWatcher = new ThemeWatcher(); - context.subscriptions.push(themeWatcher); - - let folderManagerIndex = 0; - const folderManagers = repositories.map( - repository => new FolderRepositoryManager(folderManagerIndex++, context, repository, telemetry, apiImpl, credentialStore, createPrHelper, themeWatcher), - ); - context.subscriptions.push(...folderManagers); - for (const folderManager of folderManagers) { - reposManager.insertFolderManager(folderManager); - } - - const inMemPRFileSystemProvider = getInMemPRFileSystemProvider({ reposManager, gitAPI: apiImpl, credentialStore })!; - const readOnlyMessage = new vscode.MarkdownString(vscode.l10n.t('Cannot edit this pull request file. [Check out](command:pr.checkoutFromReadonlyFile) this pull request to edit.')); - readOnlyMessage.isTrusted = { enabledCommands: ['pr.checkoutFromReadonlyFile'] }; - context.subscriptions.push(vscode.workspace.registerFileSystemProvider(Schemes.Pr, inMemPRFileSystemProvider, { isReadonly: readOnlyMessage })); - const githubFilesystemProvider = new GitHubCommitFileSystemProvider(reposManager, apiImpl, credentialStore); - context.subscriptions.push(vscode.workspace.registerFileSystemProvider(Schemes.GitHubCommit, githubFilesystemProvider, { isReadonly: new vscode.MarkdownString(vscode.l10n.t('GitHub commits cannot be edited')) })); - - await init(context, apiImpl, credentialStore, repositories, prTree, liveshareApiPromise, showPRController, reposManager, createPrHelper, copilotRemoteAgentManager, themeWatcher, prsTreeModel); - return apiImpl; -} - -export async function deactivate() { - if (telemetry) { - telemetry.dispose(); - } -} +/*--------------------------------------------------------------------------------------------- + * 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 index 976baa4fee..4722bff657 100644 --- a/src/extensionState.ts +++ b/src/extensionState.ts @@ -13,6 +13,8 @@ export const NEVER_SHOW_PULL_NOTIFICATION = 'github.pullRequest.pullNotification 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[]; @@ -23,6 +25,10 @@ export interface ReposState { repos: { [ownerAndRepo: string]: RepoState }; } +export interface RecentlyUsedBranchesState { + branches: { [ownerAndRepo: string]: string[] }; +} + export function setSyncedKeys(context: vscode.ExtensionContext) { context.globalState.setKeysForSync([NEVER_SHOW_PULL_NOTIFICATION]); } \ No newline at end of file diff --git a/src/gitProviders/builtinGit.ts b/src/gitProviders/builtinGit.ts index 4c569a13ae..bb6df80374 100644 --- a/src/gitProviders/builtinGit.ts +++ b/src/gitProviders/builtinGit.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { APIState, GitAPI, GitExtension, PublishEvent } from '../@types/git'; +import { APIState, CloneOptions, GitAPI, GitExtension, PublishEvent } from '../@types/git'; import { IGit, Repository } from '../api/api'; import { commands } from '../common/executeCommands'; import { Disposable } from '../common/lifecycle'; @@ -46,6 +46,13 @@ export class BuiltinGitProvider extends Disposable implements IGit { this._register(this._gitAPI.onDidChangeState(e => this._onDidChangeState.fire(e))); this._register(this._gitAPI.onDidPublish(e => this._onDidPublish.fire(e))); } + getRepositoryWorkspace(uri: vscode.Uri): Promise { + return this._gitAPI.getRepositoryWorkspace(uri); + } + + clone(uri: vscode.Uri, options?: CloneOptions): Promise { + return this._gitAPI.clone(uri, options); + } static async createProvider(): Promise { const extension = vscode.extensions.getExtension('vscode.git'); diff --git a/src/gitProviders/vslsguest.ts b/src/gitProviders/vslsguest.ts index 5385745f91..aa4370d9be 100644 --- a/src/gitProviders/vslsguest.ts +++ b/src/gitProviders/vslsguest.ts @@ -6,7 +6,7 @@ import * as vscode from 'vscode'; import { LiveShare, SharedServiceProxy } from 'vsls/vscode.js'; -import { Branch, Change, Commit, Remote, RepositoryState, Submodule } from '../@types/git'; +import { Branch, Change, Commit, Ref, Remote, RepositoryState, Submodule, Worktree } from '../@types/git'; import { IGit, Repository } from '../api/api'; import { Disposable } from '../common/lifecycle'; import { @@ -138,6 +138,7 @@ class LiveShareRepositoryState implements RepositoryState { HEAD: Branch | undefined; remotes: Remote[]; submodules: Submodule[] = []; + worktrees: Worktree[] = []; rebaseCommit: Commit | undefined; mergeChanges: Change[] = []; indexChanges: Change[] = []; @@ -149,6 +150,8 @@ class LiveShareRepositoryState implements RepositoryState { this.HEAD = state.HEAD; this.remotes = state.remotes; } + refs: Ref[] = []; + untrackedChanges: Change[] = []; public update(state: RepositoryState) { this.HEAD = state.HEAD; diff --git a/src/github/activityBarViewProvider.ts b/src/github/activityBarViewProvider.ts index 881e3a49e1..03ffb923a7 100644 --- a/src/github/activityBarViewProvider.ts +++ b/src/github/activityBarViewProvider.ts @@ -5,25 +5,29 @@ import * as vscode from 'vscode'; import { openPullRequestOnGitHub } from '../commands'; +import { addAttestationCommit, isAttestationCommitsEnabled } from './attestationCommit'; import { FolderRepositoryManager } from './folderRepositoryManager'; -import { GithubItemStateEnum, IAccount, isITeam, ITeam, PullRequestMergeability, reviewerId, ReviewEventEnum, ReviewState } from './interface'; -import { PullRequestModel } from './pullRequestModel'; +import { GithubItemStateEnum, IAccount, MergeMethod, ReviewEventEnum, ReviewState } from './interface'; +import { isCopilotOnMyBehalf, PullRequestModel } from './pullRequestModel'; import { getDefaultMergeMethod } from './pullRequestOverview'; -import { PullRequestView } from './pullRequestOverviewCommon'; +import { PullRequestReviewCommon, ReviewContext } from './pullRequestReviewCommon'; import { isInCodespaces, parseReviewers } from './utils'; -import { MergeArguments, PullRequest, ReviewType, SubmitReviewReply } from './views'; +import { MergeArguments, PullRequest, ReviewType, SubmitReviewArgs } from './views'; import { IComment } from '../common/comment'; import { emojify, ensureEmojis } from '../common/emoji'; import { disposeAll } from '../common/lifecycle'; -import { ReviewEvent } from '../common/timelineEvent'; +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 { getNonce, IRequestMessage, WebviewViewBase } from '../common/webview'; +import { generateUuid } from '../common/uuid'; +import { IRequestMessage, WebviewViewBase } from '../common/webview'; import { ReviewManager } from '../view/reviewManager'; export class PullRequestViewProvider extends WebviewViewBase implements vscode.WebviewViewProvider { public override readonly viewType = 'github:activePullRequest'; private _existingReviewers: ReviewState[] = []; - private _isUpdating: boolean = false; + private _updatingPromise: Promise | undefined; constructor( extensionUri: vscode.Uri, @@ -33,7 +37,13 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W ) { super(extensionUri); - this._register(vscode.commands.registerCommand('review.approve', (e: { body: string }) => this.approvePullRequestCommand(e))); + 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: SubmitReviewArgs) => 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', () => { @@ -56,29 +66,11 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W } private async updateBranch(message: IRequestMessage): Promise { - if (this._folderRepositoryManager.repository.state.workingTreeChanges.length > 0 || this._folderRepositoryManager.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 this._replyMessage(message, {}); - } - const mergeSucceeded = await this._folderRepositoryManager.tryMergeBaseIntoHead(this._item, true); - if (!mergeSucceeded) { - this._replyMessage(message, {}); - } - // The mergability of the PR doesn't update immediately. Poll. - let mergability = PullRequestMergeability.Unknown; - let attemptsRemaining = 5; - do { - mergability = (await this._item.getMergeability()).mergeability; - attemptsRemaining--; - await new Promise(c => setTimeout(c, 1000)); - } while (attemptsRemaining > 0 && mergability === PullRequestMergeability.Unknown); - - const result: Partial = { - events: await this._item.getTimelineEvents(), - mergeable: mergability, - }; - await this.refresh(); - this._replyMessage(message, result); + return PullRequestReviewCommon.updateBranch( + this.getReviewContext(), + message, + () => this.refresh() + ); } protected override async _onDidReceiveMessage(message: IRequestMessage) { @@ -117,51 +109,22 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W 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 { - try { - const defaultBranch = await this._folderRepositoryManager.getPullRequestRepositoryDefaultBranch(this._item); - const prBranch = this._folderRepositoryManager.repository.state.HEAD?.name; - await this._folderRepositoryManager.checkoutDefaultBranch(defaultBranch); - if (prBranch) { - await this._folderRepositoryManager.cleanupAfterPullRequest(prBranch, this._item); - } - } finally { - // Complete webview promise so that button becomes enabled again - this._replyMessage(message, {}); - } + return PullRequestReviewCommon.checkoutDefaultBranch(this.getReviewContext(), message); } private reRequestReview(message: IRequestMessage): void { - let targetReviewer: ReviewState | undefined; - const userReviewers: IAccount[] = []; - const teamReviewers: ITeam[] = []; - - for (const reviewer of this._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); - } + return PullRequestReviewCommon.reRequestReview(this.getReviewContext(), message); + } - this._item.requestReview(userReviewers, teamReviewers, true).then(() => { - if (targetReviewer) { - targetReviewer.state = 'REQUESTED'; - } - this._replyMessage(message, { - reviewers: this._existingReviewers, - }); - }); + 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 { @@ -172,9 +135,22 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W } private 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; + 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; @@ -184,7 +160,7 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W } this._prDisposables = []; this._prDisposables.push(pullRequestModel.onDidChange(e => { - if ((e.state || e.comments || e.reviewers) && !this._isUpdating) { + if ((e.state || e.comments || e.reviewers) && !this._updatingPromise) { this.updatePullRequest(pullRequestModel); } })); @@ -193,10 +169,16 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W private _updatePendingVisibility: vscode.Disposable | undefined = undefined; public async updatePullRequest(pullRequestModel: PullRequestModel): Promise { - if (this._isUpdating) { - throw new Error('Already updating pull request view'); + const isSamePullRequest = pullRequestModel.equals(this._item); + if (this._updatingPromise && isSamePullRequest) { + Logger.error('Already updating pull request view', PullRequestViewProvider.name); + return; + } else if (this._updatingPromise && !isSamePullRequest) { + this._item = pullRequestModel; + await this._updatingPromise; + } else { + this._item = pullRequestModel; } - this._isUpdating = true; try { if (this._view && !this._view.visible) { @@ -211,7 +193,7 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W this.registerPrSpecificListeners(pullRequestModel); } this._item = pullRequestModel; - const [pullRequest, repositoryAccess, timelineEvents, requestedReviewers, branchInfo, defaultBranch, currentUser, viewerCanEdit, hasReviewDraft] = await Promise.all([ + const updatingPromise = Promise.all([ this._folderRepositoryManager.resolvePullRequest( pullRequestModel.remote.owner, pullRequestModel.remote.repositoryName, @@ -225,8 +207,17 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W this._folderRepositoryManager.getCurrentUser(pullRequestModel.githubRepository), pullRequestModel.canEdit(), pullRequestModel.validateDraftMode(), - ensureEmojis(this._folderRepositoryManager.context) + 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( @@ -234,6 +225,10 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W ); } + 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. @@ -265,6 +260,11 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W 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, @@ -285,10 +285,10 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W state: pullRequest.state, isCurrentlyCheckedOut: isCurrentlyCheckedOut, isRemoteBaseDeleted: pullRequest.isRemoteBaseDeleted, - base: pullRequest.base.label, + base: `${pullRequest.base.owner}/${pullRequest.base.name}:${pullRequest.base.ref}`, isRemoteHeadDeleted: pullRequest.isRemoteHeadDeleted, isLocalHeadDeleted: !branchInfo, - head: pullRequest.head?.label ?? '', + head: pullRequest.head ? `${pullRequest.head.owner}/${pullRequest.head.name}:${pullRequest.head.ref}` : '', canEdit: canEdit, hasWritePermission, mergeable: pullRequest.item.mergeable, @@ -299,7 +299,9 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W events: timelineEvents, mergeMethodsAvailability, defaultMergeMethod, + mergeQueueMethod, repositoryDefaultBranch: defaultBranch, + doneCheckoutBranch, isIssue: false, isAuthor: currentUser.login === pullRequest.author.login, reviewers: this._existingReviewers, @@ -307,7 +309,9 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W isDarkTheme: vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark, isEnterprise: pullRequest.githubRepository.remote.isEnterprise, hasReviewDraft, - currentUserReviewState: reviewState + currentUserReviewState: reviewState, + isCopilotOnMyBehalf: await isCopilotOnMyBehalf(pullRequest, currentUser, coAuthors), + attestationCommitsEnabled: isAttestationCommitsEnabled() }; this._postMessage({ @@ -317,8 +321,6 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W } catch (e) { vscode.window.showErrorMessage(`Error updating active pull request view: ${formatError(e)}`); - } finally { - this._isUpdating = false; } } @@ -344,83 +346,91 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W }); } - private updateReviewers(review?: ReviewEvent): void { - if (review && review.state) { - const existingReviewer = this._existingReviewers.find( - reviewer => review.user.login === reviewerId(reviewer.reviewer), - ); - if (existingReviewer) { - existingReviewer.state = review.state; - } else { - this._existingReviewers.push({ - reviewer: review.user, - state: review.state, - }); - } - } + + private async doReviewCommand(context: { body: string }, reviewType: ReviewType, action: (body: string) => Promise, additionalEvents?: TimelineEvent[]) { + return PullRequestReviewCommon.doReviewCommand( + this.getReviewContext(), + context, + reviewType, + false, + action, + additionalEvents, + ); } - private async doReviewCommand(context: { body: string }, reviewType: ReviewType, action: (body: string) => Promise) { - const submittingMessage = { - command: 'pr.submitting-review', - lastReviewType: reviewType - }; - this._postMessage(submittingMessage); - try { - const review = await action(context.body); - this.updateReviewers(review); - const reviewMessage: SubmitReviewReply & { command: string } = { - command: 'pr.append-review', - events: [], - reviewers: this._existingReviewers, - reviewedEvent: review, - }; - await this._postMessage(reviewMessage); - } catch (e) { - vscode.window.showErrorMessage(vscode.l10n.t('Submitting review failed. {0}', formatError(e))); - this._throwError(undefined, `${formatError(e)}`); - this._postMessage({ command: 'pr.append-review' }); - } + private async doReviewMessage(message: IRequestMessage, action: (body) => Promise, needsTimelineRefresh: boolean = false, additionalEvents?: TimelineEvent[]) { + return PullRequestReviewCommon.doReviewMessage( + this.getReviewContext(), + message, + needsTimelineRefresh, + action, + additionalEvents, + ); } - private async doReviewMessage(message: IRequestMessage, action: (body) => Promise) { - try { - const review = await action(message.args); - this.updateReviewers(review); - const reviewMessage: SubmitReviewReply = { - events: [], - reviewedEvent: review, - reviewers: this._existingReviewers, - }; - this._replyMessage(message, reviewMessage); - } catch (e) { - vscode.window.showErrorMessage(vscode.l10n.t('Submitting review failed. {0}', formatError(e))); - this._throwError(message, `${formatError(e)}`); + /** + * 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): Promise { - return this._item.approve(this._folderRepositoryManager.repository, body); + private approvePullRequest(body: string, expectedRemoteHead?: string): Promise { + return this._item.approve(this._folderRepositoryManager.repository, body, expectedRemoteHead); } - private approvePullRequestMessage(message: IRequestMessage): Promise { - return this.doReviewMessage(message, (body) => this.approvePullRequest(body)); + private async approvePullRequestMessage(message: IRequestMessage): Promise { + await this.doReviewMessageWithAttestation(message, (body, expectedRemoteHead) => this.approvePullRequest(body, expectedRemoteHead)); } - private approvePullRequestCommand(context: { body: string }): Promise { - return this.doReviewCommand(context, ReviewType.Approve, (body) => this.approvePullRequest(body)); + private async approvePullRequestCommand(context: SubmitReviewArgs): Promise { + const result = context.addAttestation + ? await addAttestationCommit(this._folderRepositoryManager, this._item) + : undefined; + if (context.addAttestation && !result) { + return; + } + await this.doReviewCommand( + context, + ReviewType.Approve, + (body) => this.approvePullRequest(body, result?.sha), + result ? [result.event] : undefined, + ); } private requestChanges(body: string): Promise { return this._item.requestChanges(body); } - private requestChangesCommand(context: { body: string }): Promise { - return this.doReviewCommand(context, ReviewType.RequestChanges, (body) => this.requestChanges(body)); + private async requestChangesCommand(context: { body: string }): Promise { + await this.doReviewCommand(context, ReviewType.RequestChanges, (body) => this.requestChanges(body)); } - private requestChangesMessage(message: IRequestMessage): Promise { - return this.doReviewMessage(message, (body) => this.requestChanges(body)); + private async requestChangesMessage(message: IRequestMessage): Promise { + await this.doReviewMessageWithAttestation(message, (body) => this.requestChanges(body)); } private submitReview(body: string): Promise { @@ -431,12 +441,12 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W return this.doReviewCommand(context, ReviewType.Comment, (body) => this.submitReview(body)); } - private submitReviewMessage(message: IRequestMessage) { - return this.doReviewMessage(message, (body) => this.submitReview(body)); + private submitReviewMessage(message: IRequestMessage) { + return this.doReviewMessageWithAttestation(message, (body) => this.submitReview(body)); } private async deleteBranch(message: IRequestMessage) { - const result = await PullRequestView.deleteBranch(this._folderRepositoryManager, this._item); + const result = await PullRequestReviewCommon.deleteBranch(this._folderRepositoryManager, this._item); if (result.isReply) { this._replyMessage(message, result.message); } else { @@ -444,16 +454,16 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W } } - private setReadyForReview(message: IRequestMessage>): void { - this._item - .setReadyForReview() - .then(result => { - this._replyMessage(message, result); - }) - .catch(e => { - vscode.window.showErrorMessage(vscode.l10n.t('Unable to set pull request ready for review. {0}', formatError(e))); - this._throwError(message, ''); - }); + private async setReadyForReview(message: IRequestMessage>): Promise { + return PullRequestReviewCommon.setReadyForReview(this.getReviewContext(), message); + } + + private async readyForReviewCommand(): Promise { + return PullRequestReviewCommon.readyForReviewCommand(this.getReviewContext()); + } + + private async readyForReviewAndMergeCommand(context: { mergeMethod: MergeMethod }): Promise { + return PullRequestReviewCommon.readyForReviewAndMergeCommand(this.getReviewContext(), context); } private async mergePullRequest( @@ -476,6 +486,13 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W 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, { @@ -489,7 +506,7 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W } private _getHtmlForWebview() { - const nonce = getNonce(); + const nonce = generateUuid(); const uri = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview-open-pr-view.js'); diff --git a/src/github/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 6d9b424d56..23a253493c 100644 --- a/src/github/common.ts +++ b/src/github/common.ts @@ -5,14 +5,10 @@ import * as OctokitRest from '@octokit/rest'; import { Endpoints } from '@octokit/types'; import { DocumentNode } from 'graphql'; -import { ChatSessionStatus, Uri } from 'vscode'; -import { SessionInfo, SessionSetupStep } from './copilotApi'; import { FolderRepositoryManager } from './folderRepositoryManager'; import { GitHubRepository } from './githubRepository'; import { Repository } from '../api/api'; -import { CopilotPRStatus } from '../common/copilot'; import { GitHubRemote } from '../common/remote'; -import { EventType, TimelineEvent } from '../common/timelineEvent'; export namespace OctokitCommon { export type IssuesAssignParams = OctokitRest.RestEndpointMethodTypes['issues']['addAssignees']['parameters']; @@ -100,56 +96,6 @@ export function mergeQuerySchemaWithShared(sharedSchema: DocumentNode, schema: D }; } -type RemoteAgentSuccessResult = { link: string; state: 'success'; number: number; webviewUri: Uri; llmDetails: string; sessionId: string }; -type RemoteAgentErrorResult = { error: string; innerError?: string; state: 'error' }; -export type RemoteAgentResult = RemoteAgentSuccessResult | RemoteAgentErrorResult; - -export interface IAPISessionLogs { - readonly info: SessionInfo; - readonly logs: string; - readonly setupSteps: SessionSetupStep[] | undefined; -} - -export interface ICopilotRemoteAgentCommandArgs { - userPrompt: string; - summary?: string; - source?: 'prompt' | (string & {}); - followup?: string; - _version?: number; // TODO(jospicer): Remove once stabilized/engine version enforced -} - -export interface ICopilotRemoteAgentCommandResponse { - uri: string; - title: string; - description: string; - author: string; - linkTag: string; -} - -export interface ToolCall { - function: { - arguments: string; - name: 'bash' | 'reply_to_comment' | (string & {}); - }; - id: string; - type: string; - index: number; -} - -export interface AssistantDelta { - content?: string; - role: 'assistant' | (string & {}); - tool_calls?: ToolCall[]; -} - -export interface Choice { - finish_reason?: 'tool_calls' | (string & {}); - delta: { - content?: string; - role: 'assistant' | (string & {}); - tool_calls?: ToolCall[]; - }; -} export interface RepoInfo { owner: string; @@ -160,33 +106,3 @@ export interface RepoInfo { ghRepository: GitHubRepository; fm: FolderRepositoryManager; } - -export function copilotEventToSessionStatus(event: TimelineEvent | undefined): ChatSessionStatus { - if (!event) { - return ChatSessionStatus.InProgress; - } - - switch (event.event) { - case EventType.CopilotStarted: - return ChatSessionStatus.InProgress; - case EventType.CopilotFinished: - return ChatSessionStatus.Completed; - case EventType.CopilotFinishedError: - return ChatSessionStatus.Failed; - default: - return ChatSessionStatus.InProgress; - } -} - -export function copilotPRStatusToSessionStatus(event: CopilotPRStatus): ChatSessionStatus { - switch (event) { - case CopilotPRStatus.Started: - return ChatSessionStatus.InProgress; - case CopilotPRStatus.Completed: - return ChatSessionStatus.Completed; - case CopilotPRStatus.Failed: - return ChatSessionStatus.Failed; - default: - return ChatSessionStatus.InProgress; - } -} diff --git a/src/github/copilotApi.ts b/src/github/copilotApi.ts index 159cf38a2f..7b172e3cae 100644 --- a/src/github/copilotApi.ts +++ b/src/github/copilotApi.ts @@ -4,52 +4,29 @@ *--------------------------------------------------------------------------------------------*/ import fetch from 'cross-fetch'; -import JSZip from 'jszip'; import * as vscode from 'vscode'; -import { CredentialStore, GitHub } from './credentials'; -import { PRType } from './interface'; +import { CredentialStore } from './credentials'; import { LoggingOctokit } from './loggingOctokit'; -import { PullRequestModel } from './pullRequestModel'; -import { RepositoriesManager } from './repositoriesManager'; import { hasEnterpriseUri } from './utils'; import { AuthProvider } from '../common/authentication'; -import { COPILOT_SWE_AGENT } from '../common/copilot'; import Logger from '../common/logger'; import { ITelemetry } from '../common/telemetry'; -const LEARN_MORE_URL = 'https://aka.ms/coding-agent-docs'; -const PREMIUM_REQUESTS_URL = 'https://docs.github.com/en/copilot/concepts/copilot-billing/understanding-and-managing-requests-in-copilot#what-are-premium-requests'; -// https://github.com/github/sweagentd/blob/59e7d9210ca3ebba029918387e525eea73cb1f4a/internal/problemstatement/problemstatement.go#L36-L53 -export const MAX_PROBLEM_STATEMENT_LENGTH = 30_000 - 50; // 50 character buffer -// https://github.com/github/sweagentd/blob/0ad8f81a9c64754cb8a83d10777de4638bba1a6e/docs/adr/0001-create-job-api.md#post-jobsownerrepo---create-job-task -const JOBS_API_VERSION = 'v1'; - -export interface RemoteAgentJobPayload { - problem_statement: string; - event_type: string; - pull_request?: { - title?: string; - body_placeholder?: string; - body_suffix?: string; - base_ref?: string; - head_ref?: string; - }; - run_name?: string; -} - -export interface RemoteAgentJobResponse { - job_id: string; - session_id: string; - actor: { - id: number; - login: string; +/** + * 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; + }; }; - created_at: string; - updated_at: string; -} - -export interface ChatSessionWithPR extends vscode.ChatSessionItem { - pullRequest: PullRequestModel; } export class CopilotApi { @@ -58,7 +35,6 @@ export class CopilotApi { constructor( private octokit: LoggingOctokit, private token: string, - private credentialStore: CredentialStore, private telemetry: ITelemetry ) { } @@ -74,152 +50,6 @@ export class CopilotApi { return this.makeApiCallFullUrl(`${this.baseUrl}${api}`, init); } - private get userAgent(): string { - const extensionVersion = vscode.extensions.getExtension('GitHub.vscode-pull-request-github')?.packageJSON.version ?? 'unknown'; - return `vscode-pull-request-github/${extensionVersion}`; - } - - async postRemoteAgentJob( - owner: string, - name: string, - payload: RemoteAgentJobPayload, - isTruncated: boolean, - ): Promise { - const repoSlug = `${owner}/${name}`; - const apiUrl = `/agents/swe/${JOBS_API_VERSION}/jobs/${repoSlug}`; - let status: number | undefined; - - const problemStatementLength = payload.problem_statement.length.toString(); - const payloadJson = JSON.stringify(payload); - const payloadLength = payloadJson.length.toString(); - Logger.trace(`postRemoteAgentJob: Posting job to ${apiUrl} with payload: ${JSON.stringify(payload)}`, CopilotApi.ID); - try { - const response = await this.makeApiCall(apiUrl, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${this.token}`, - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': this.userAgent, - }, - body: payloadJson - }); - - status = response.status; - if (!response.ok) { - throw new Error(await this.formatRemoteAgentJobError(status, repoSlug, response)); - } - - const data = await response.json(); - this.validateRemoteAgentJobResponse(data); - /* - __GDPR__ - "remoteAgent.postRemoteAgentJob" : { - "status" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "payloadLength": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "problemStatementLength": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "isTruncated": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetry.sendTelemetryEvent('remoteAgent.postRemoteAgentJob', { - status: status.toString(), - payloadLength, - problemStatementLength, - isTruncated: isTruncated.toString(), - }); - return data; - } catch (error) { - /* __GDPR__ - "remoteAgent.postRemoteAgentJob" : { - "status" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "payloadLength": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "problemStatementLength": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "isTruncated": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetry.sendTelemetryErrorEvent('remoteAgent.postRemoteAgentJob', { - status: status?.toString() || '999', - payloadLength, - problemStatementLength, - isTruncated: isTruncated.toString(), - }); - throw error; - } - } - - // https://github.com/github/sweagentd/blob/371ea6db280b9aecf790ccc20660e39a7ecb8d1c/internal/api/jobapi/handler.go#L110-L120 - private async formatRemoteAgentJobError(status: number, repoSlug: string, response: Response): Promise { - Logger.error(`Error in remote agent job: ${await response.text()}`, CopilotApi.ID); - switch (status) { - case 400: - return vscode.l10n.t('Bad request'); - case 401: - return vscode.l10n.t('Unauthorized'); - case 402: - return vscode.l10n.t('[Premium request]({0}) quota exceeded', PREMIUM_REQUESTS_URL); - case 403: - return vscode.l10n.t('[GitHub coding agent]({0}) is not enabled for repository \'{1}\'', LEARN_MORE_URL, repoSlug); - case 404: - return vscode.l10n.t('Repository \'{0}\' not found', repoSlug); - case 409: - return vscode.l10n.t('A coding agent pull request already exists'); - case 500: - return vscode.l10n.t('Server error. Please see logs for details.'); - default: - return vscode.l10n.t('Error: {0}. Please see logs for details', status); - } - } - - private validateRemoteAgentJobResponse(data: any): asserts data is RemoteAgentJobResponse { - if (!data || typeof data !== 'object') { - throw new Error('Invalid response from coding agent'); - } - if (typeof data.job_id !== 'string') { - throw new Error('Invalid job_id in response'); - } - if (typeof data.session_id !== 'string') { - throw new Error('Invalid session_id in response'); - } - if (!data.actor || typeof data.actor !== 'object') { - throw new Error('Invalid actor in response'); - } - if (typeof data.actor.id !== 'number') { - throw new Error('Invalid actor.id in response'); - } - if (typeof data.actor.login !== 'string') { - throw new Error('Invalid actor.login in response'); - } - if (typeof data.created_at !== 'string') { - throw new Error('Invalid created_at in response'); - } - if (typeof data.updated_at !== 'string') { - throw new Error('Invalid updated_at in response'); - } - } - - public async getLogsFromZipUrl(logsUrl: string): Promise { - const logsZip = await this.makeApiCallFullUrl(logsUrl, { - headers: { - Authorization: `Bearer ${this.token}`, - Accept: 'application/json', - }, - }); - if (!logsZip.ok) { - throw new Error(`Failed to fetch logs zip: ${logsZip.statusText}`); - } - const logsText = await logsZip.arrayBuffer(); - const copilotSteps: string[] = []; - const zip = await JSZip.loadAsync(logsText); - for (const fileName of Object.keys(zip.files)) { - const file = zip.files[fileName]; - if (!file.dir && fileName.endsWith('Processing Request.txt')) { - const content = await file.async('string'); - copilotSteps.push(...content.split('\n')); - } - } - return copilotSteps; - } - public async getAllSessions(pullRequestId: number | undefined): Promise { const response = await this.makeApiCall( pullRequestId @@ -238,111 +68,6 @@ export class CopilotApi { return sessions.sessions; } - public async getAllCodingAgentPRs(repositoriesManager: RepositoriesManager): Promise { - const hub = this.getHub(); - const username = (await hub?.currentUser)?.login; - if (!username) { - Logger.error('Failed to get GitHub username from auth provider', CopilotApi.ID); - return []; - } - const query = `is:open author:${COPILOT_SWE_AGENT}[bot] assignee:${username} is:pr repo:\${owner}/\${repository}`; - const allItems = await Promise.all( - repositoriesManager.folderManagers.map(async fm => { - const result = await fm.getPullRequests(PRType.Query, undefined, query); - return result.items; - }) - ); - return allItems.flat(); - } - - public async getSessionInfo(sessionId: string): Promise { - const response = await this.makeApiCall(`/agents/sessions/${sessionId}`, { - method: 'GET', - headers: { - Authorization: `Bearer ${this.token}`, - 'Accept': 'application/json' - } - }); - if (!response.ok) { - await this.handleApiError(response, 'getSessionInfo'); - } - - return (await response.json()) as SessionInfo; - } - - public async getLogsFromSession(sessionId: string): Promise { - const logsResponse = await this.makeApiCall(`/agents/sessions/${sessionId}/logs`, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${this.token}`, - 'Content-Type': 'application/json', - }, - }); - if (!logsResponse.ok) { - await this.handleApiError(logsResponse, 'getLogsFromSession'); - } - return await logsResponse.text(); - } - - public async getJobByJobId(owner: string, repo: string, jobId: string): Promise { - try { - const response = await this.makeApiCall(`/agents/swe/v1/jobs/${owner}/${repo}/${jobId}`, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${this.token}`, - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': this.userAgent, - } - }); - if (!response.ok) { - Logger.warn(`Failed to fetch job info for job ${jobId}: ${response.statusText}`, CopilotApi.ID); - return; - } - const data = await response.json() as JobInfo; - return data; - } catch (error) { - Logger.warn(`Error fetching job info for job ${jobId}: ${error}`, CopilotApi.ID); - return; - } - } - - public async getJobBySessionId(owner: string, repo: string, sessionId: string): Promise { - try { - const response = await this.makeApiCall(`/agents/swe/${JOBS_API_VERSION}/jobs/${owner}/${repo}/session/${sessionId}`, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${this.token}`, - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': this.userAgent, - } - }); - if (!response.ok) { - Logger.warn(`Failed to fetch job info for session ${sessionId}: ${response.statusText}`, CopilotApi.ID); - return; - } - const data = await response.json() as JobInfo; - return data; - } catch (error) { - Logger.warn(`Error fetching job info for session ${sessionId}: ${error}`, CopilotApi.ID); - return; - } - } - - private getHub(): 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; - } else { - return; - } - - return this.credentialStore.getHub(authProvider); - } - private async handleApiError(response: Response, action: string): Promise { let errorBody: string | undefined = undefined; try { @@ -390,39 +115,6 @@ export interface SessionInfo { error: string | null; } -export interface SessionSetupStep { - name: string; - status: 'completed' | 'in_progress' | 'queued'; -} - -export interface JobInfo { - job_id: string; - session_id: string; - problem_statement: string; - content_filter_mode?: string; - status: string; - result?: string; - actor: { - id: number; - login: string; - }; - created_at: string; - updated_at: string; - pull_request: { - id: number; - number: number; - }; - workflow_run?: { - id: number; - }; - error?: { - message: string; - }; - event_type?: string; - event_url?: string; - event_identifiers?: string[]; -} - export async function getCopilotApi(credentialStore: CredentialStore, telemetry: ITelemetry, authProvider?: AuthProvider): Promise { if (!authProvider) { if (credentialStore.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { @@ -440,5 +132,5 @@ export async function getCopilotApi(credentialStore: CredentialStore, telemetry: } const { token } = await github.octokit.api.auth() as { token: string }; - return new CopilotApi(github.octokit, token, credentialStore, telemetry); + 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 index 5a81e9ad06..429fa0c561 100644 --- a/src/github/copilotPrWatcher.ts +++ b/src/github/copilotPrWatcher.ts @@ -1,294 +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 { 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 }); - 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 { - 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(pr, 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); - } - +/*--------------------------------------------------------------------------------------------- + * 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 index d4d6437382..9f3c987aee 100644 --- a/src/github/copilotRemoteAgent.ts +++ b/src/github/copilotRemoteAgent.ts @@ -3,65 +3,28 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as pathLib from 'path'; -import * as marked from 'marked'; -import vscode, { ChatPromptReference, ChatSessionItem } from 'vscode'; -import { copilotPRStatusToSessionStatus, IAPISessionLogs, ICopilotRemoteAgentCommandArgs, ICopilotRemoteAgentCommandResponse, OctokitCommon, RemoteAgentResult, RepoInfo } from './common'; -import { ChatSessionWithPR, CopilotApi, getCopilotApi, JobInfo, RemoteAgentJobPayload, SessionInfo, SessionSetupStep } from './copilotApi'; -import { CodingAgentPRAndStatus, CopilotPRWatcher } from './copilotPrWatcher'; -import { parseSessionLogs, parseToolCallDetails, StrReplaceEditorToolData } from '../../common/sessionParsing'; -import { GitApiImpl } from '../api/api1'; -import { COPILOT_ACCOUNTS } from '../common/comment'; -import { CopilotRemoteAgentConfig } from '../common/config'; -import { COPILOT_LOGINS, COPILOT_SWE_AGENT } from '../common/copilot'; -import { commands } from '../common/executeCommands'; -import { Disposable } from '../common/lifecycle'; -import Logger from '../common/logger'; -import { GitHubRemote } from '../common/remote'; -import { CODING_AGENT, CODING_AGENT_AUTO_COMMIT_AND_PUSH } from '../common/settingKeys'; -import { ITelemetry } from '../common/telemetry'; -import { toOpenPullRequestWebviewUri } from '../common/uri'; -import { ChatSessionContentBuilder } from './copilotRemoteAgent/chatSessionContentBuilder'; -import { GitOperationsManager } from './copilotRemoteAgent/gitOperationsManager'; -import { extractTitle, formatBodyPlaceholder, truncatePrompt } from './copilotRemoteAgentUtils'; +import * as vscode from 'vscode'; +import { CopilotPRWatcher } from './copilotPrWatcher'; + import { CredentialStore } from './credentials'; -import { FolderRepositoryManager, ReposManagerState } from './folderRepositoryManager'; -import { GitHubRepository } from './githubRepository'; -import { issueMarkdown, PlainTextRenderer } from './markdownUtils'; -import { PullRequestModel } from './pullRequestModel'; -import { chooseItem } from './quickPicks'; import { RepositoriesManager } from './repositoriesManager'; -import { getRepositoryForFile } from './utils'; +import { COPILOT_CLOUD_AGENT } from '../common/copilot'; +import { Disposable } from '../common/lifecycle'; +import { ITelemetry } from '../common/telemetry'; import { PrsTreeModel } from '../view/prsTreeModel'; -const LEARN_MORE = vscode.l10n.t('Learn about coding agent'); -// Without Pending Changes -const CONTINUE = vscode.l10n.t('Continue'); -// With Pending Changes -const PUSH_CHANGES = vscode.l10n.t('Include changes'); -const CONTINUE_WITHOUT_PUSHING = vscode.l10n.t('Ignore changes'); -const CONTINUE_AND_DO_NOT_ASK_AGAIN = vscode.l10n.t('Continue and don\'t ask again'); - -const CONTINUE_TRUNCATION = vscode.l10n.t('Continue with truncation'); -const DELEGATE_MODAL_DETAILS = vscode.l10n.t('The agent will work asynchronously to create a pull request with your requested changes.'); - -const COPILOT = '@copilot'; - -const body_suffix = vscode.l10n.t('Created from VS Code via the [GitHub Pull Request](https://marketplace.visualstudio.com/items?itemName=GitHub.vscode-pull-request-github) extension.'); - -const PREFERRED_GITHUB_CODING_AGENT_REMOTE_WORKSPACE_KEY = 'PREFERRED_GITHUB_CODING_AGENT_REMOTE'; - - export namespace SessionIdForPr { const prefix = 'pull-session-by-index'; - export function getId(prNumber: number, sessionIndex: number): string { - return `${prefix}-${prNumber}-${sessionIndex}`; + export function getResource(prNumber: number, sessionIndex: number): vscode.Uri { + return vscode.Uri.from({ + scheme: COPILOT_CLOUD_AGENT, path: `/${prefix}-${prNumber}-${sessionIndex}`, + }); } - export function parse(id: string): { prNumber: number; sessionIndex: number } | undefined { - const match = id.match(new RegExp(`^${prefix}-(\\d+)-(\\d+)$`)); + export function parse(resource: vscode.Uri): { prNumber: number; sessionIndex: number } | undefined { + const match = resource.path.match(new RegExp(`^/${prefix}-(\\d+)-(\\d+)$`)); if (match) { return { prNumber: parseInt(match[1], 10), @@ -72,190 +35,8 @@ export namespace SessionIdForPr { } } -type ConfirmationResult = { step: string; accepted: boolean; metadata?: CreatePromptMetadata /* | SomeOtherMetadata */ }; - -interface CreatePromptMetadata { - prompt: string; - history?: string; - references?: ChatPromptReference[]; -} - export class CopilotRemoteAgentManager extends Disposable { - async chatParticipantImpl(request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) { - const startSession = async (source: string, prompt: string, history?: string, references?: readonly vscode.ChatPromptReference[]) => { - /* __GDPR__ - "copilot.remoteagent.editor.invoke" : { - "promptLength" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "historyLength" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "referencesCount" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "source" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetry.sendTelemetryEvent('copilot.remoteagent.editor.invoke', { - promptLength: prompt.length.toString() ?? '0', - historyLength: history?.length.toString() ?? '0', - referencesCount: references?.length.toString() ?? '0', - source, - }); - const result = await this.invokeRemoteAgent( - prompt, - [ - this.extractFileReferences(references), - history - ].join('\n\n').trim(), - token, - false, - stream, - ); - if (result.state !== 'success') { - Logger.error(`Failed to provide new chat session item: ${result.error}${result.innerError ? `\nInner Error: ${result.innerError}` : ''}`, CopilotRemoteAgentManager.ID); - stream.warning(result.error); - return; - } - return result.number; - }; - - const handleConfirmationData = async () => { - const results: ConfirmationResult[] = []; - results.push(...(request.acceptedConfirmationData?.map(data => ({ step: data.step, accepted: true, metadata: data?.metadata })) ?? [])); - results.push(...((request.rejectedConfirmationData ?? []).filter(data => !results.some(r => r.step === data.step)).map(data => ({ step: data.step, accepted: false, metadata: data?.metadata })))); - for (const data of results) { - switch (data.step) { - case 'create': - if (!data.accepted) { - stream.markdown(vscode.l10n.t('Coding agent request cancelled.')); - return {}; - } - const { prompt, history, references } = data.metadata as CreatePromptMetadata; - const number = await startSession('chat', prompt, history, references); - if (!number) { - return {}; - } - const pullRequest = await this.findPullRequestById(number, true); - if (!pullRequest) { - stream.warning(vscode.l10n.t('Could not find the associated pull request {0} for this chat session.', number)); - return {}; - } - const uri = await toOpenPullRequestWebviewUri({ owner: pullRequest.remote.owner, repo: pullRequest.remote.repositoryName, pullRequestNumber: pullRequest.number }); - const plaintextBody = marked.parse(pullRequest.body, { renderer: new PlainTextRenderer(true), smartypants: true }).trim(); - const card = new vscode.ChatResponsePullRequestPart(uri, pullRequest.title, plaintextBody, pullRequest.author.specialDisplayName ?? pullRequest.author.login, `#${pullRequest.number}`); - stream.push(card); - stream.markdown(vscode.l10n.t('GitHub Copilot coding agent has begun working on your request. Follow its progress in the associated chat and pull request.')); - vscode.window.showChatSession(COPILOT_SWE_AGENT, String(number), { viewColumn: vscode.ViewColumn.Active }); - break; - default: - stream.warning(`Unknown confirmation step: ${data.step}\n\n`); - break; - } - } - return {}; - }; - - if (request.acceptedConfirmationData || request.rejectedConfirmationData) { - return await handleConfirmationData(); - } - - if (context.chatSessionContext?.isUntitled) { - /* Generate new coding agent session from an 'untitled' session */ - const number = await startSession( - 'untitledChatSession', - context.chatSummary?.prompt ?? request.prompt, - context.chatSummary?.history, - request.references - ); - if (!number) { - return {}; - } - // Tell UI to the new chat session - const modified: vscode.ChatSessionItem = { id: String(number), label: `Pull Request ${number}` } as unknown as vscode.ChatSessionItem; - this._onDidCommitChatSession.fire({ original: context.chatSessionContext.chatSessionItem, modified }); - } else if (context.chatSessionContext) { - /* Follow up to an existing coding agent session */ - try { - if (token.isCancellationRequested) { - return {}; - } - - // Validate user input - const userPrompt = request.prompt; - if (!userPrompt || userPrompt.trim().length === 0) { - stream.markdown(vscode.l10n.t('Please provide a message for the coding agent.')); - return {}; - } - - stream.progress(vscode.l10n.t('Preparing')); - - const pullRequest = await this.findPullRequestById(parseInt(context.chatSessionContext.chatSessionItem.id, 10), true); - if (!pullRequest) { - stream.warning(vscode.l10n.t('Could not find the associated pull request {0} for this chat session.', context.chatSessionContext.chatSessionItem.id)); - return {}; - } - - stream.progress(vscode.l10n.t('Delegating request to coding agent')); - - // Add follow-up comment to the PR - const result = await this.addFollowUpToExistingPR(pullRequest.number, userPrompt); - if (!result) { - stream.markdown(vscode.l10n.t('Failed to add follow-up comment to the pull request.')); - return {}; - } - - // Show initial success message - stream.markdown(result); - stream.markdown('\n\n'); - - stream.progress(vscode.l10n.t('Attaching to session')); - - // Wait for new session and stream its progress - const newSession = await this.waitForNewSession(pullRequest, stream, token, true); - if (!newSession) { - return {}; - } - - // Stream the new session logs - stream.markdown(vscode.l10n.t('Coding agent has begun work on your request')); - stream.markdown('\n\n'); - - await this.streamSessionLogs(stream, pullRequest, newSession.id, token); - - return {}; - } catch (error) { - Logger.error(`Error in request handler: ${error}`, CopilotRemoteAgentManager.ID); - stream.markdown(vscode.l10n.t('An error occurred while processing your request.')); - return { errorDetails: { message: error.message } }; - } - } else { - /* @copilot invoked from a 'normal' chat or 'cloud button' */ - stream.confirmation( - vscode.l10n.t('Delegate to agent'), - DELEGATE_MODAL_DETAILS, - { - step: 'create', - metadata: { - prompt: context.chatSummary?.prompt ?? request.prompt, - history: context.chatSummary?.history, - references: request.references, - } - }, - ['Delegate', 'Cancel'] - ); - } - } - public static ID = 'CopilotRemoteAgentManager'; - - private readonly _onDidChangeStates = this._register(new vscode.EventEmitter()); - readonly onDidChangeStates = this._onDidChangeStates.event; - private readonly _onDidChangeNotifications = this._register(new vscode.EventEmitter()); - readonly onDidChangeNotifications = this._onDidChangeNotifications.event; - private readonly _onDidCreatePullRequest = this._register(new vscode.EventEmitter()); - readonly onDidCreatePullRequest = this._onDidCreatePullRequest.event; - private readonly _onDidChangeChatSessions = this._register(new vscode.EventEmitter()); - readonly onDidChangeChatSessions = this._onDidChangeChatSessions.event; - private readonly _onDidCommitChatSession = this._register(new vscode.EventEmitter<{ original: ChatSessionItem; modified: ChatSessionItem }>()); - readonly onDidCommitChatSession = this._onDidCommitChatSession.event; - - private readonly gitOperationsManager: GitOperationsManager; private _isAssignable: boolean | undefined; constructor( @@ -263,1406 +44,10 @@ export class CopilotRemoteAgentManager extends Disposable { public repositoriesManager: RepositoriesManager, private telemetry: ITelemetry, private context: vscode.ExtensionContext, - private gitAPI: GitApiImpl, private readonly prsTreeModel: PrsTreeModel, ) { super(); - this.gitOperationsManager = new GitOperationsManager(CopilotRemoteAgentManager.ID); - this._register(this.credentialStore.onDidChangeSessions((e: vscode.AuthenticationSessionsChangeEvent) => { - if (e.provider.id === 'github') { - this._copilotApiPromise = undefined; // Invalidate cached session - } - })); this._register(new CopilotPRWatcher(this.repositoriesManager, this.prsTreeModel)); - this._register(this.prsTreeModel.onDidChangeCopilotStates(() => { - this._onDidChangeStates.fire(); - this._onDidChangeChatSessions.fire(); - })); - this._register(this.prsTreeModel.onDidChangeCopilotNotifications(items => this._onDidChangeNotifications.fire(items))); - - this._register(this.repositoriesManager.onDidChangeFolderRepositories((event) => { - if (event.added) { - this._register(event.added.onDidChangeAssignableUsers(() => { - this.updateAssignabilityContext(); - })); - } - this.updateAssignabilityContext(); - })); - this.repositoriesManager.folderManagers.forEach(manager => { - this._register(manager.onDidChangeAssignableUsers(() => { - this.updateAssignabilityContext(); - })); - }); - this._register(vscode.workspace.onDidChangeConfiguration((e) => { - if (e.affectsConfiguration(CODING_AGENT)) { - this.updateAssignabilityContext(); - } - })); - - // Set initial context - this.updateAssignabilityContext(); - } - - private _copilotApiPromise: Promise | undefined; - private get copilotApi(): Promise { - if (!this._copilotApiPromise) { - this._copilotApiPromise = this.initializeCopilotApi(); - } - return this._copilotApiPromise; - } - - private async initializeCopilotApi(): Promise { - return await getCopilotApi(this.credentialStore, this.telemetry); - } - - public get enabled(): boolean { - return CopilotRemoteAgentConfig.getEnabled(); - } - - public get autoCommitAndPushEnabled(): boolean { - return CopilotRemoteAgentConfig.getAutoCommitAndPushEnabled(); - } - - private _repoManagerInitializationPromise: Promise | undefined; - private async waitRepoManagerInitialization() { - if (this.repositoriesManager.state === ReposManagerState.RepositoriesLoaded || this.repositoriesManager.state === ReposManagerState.NeedsAuthentication) { - return; - } - - if (!this._repoManagerInitializationPromise) { - this._repoManagerInitializationPromise = new Promise((resolve) => { - const disposable = this.repositoriesManager.onDidChangeState(() => { - if (this.repositoriesManager.state === ReposManagerState.RepositoriesLoaded || this.repositoriesManager.state === ReposManagerState.NeedsAuthentication) { - disposable.dispose(); - resolve(); - } - }); - }); - } - - return this._repoManagerInitializationPromise; - } - - async isAssignable(): Promise { - const setCachedResult = (b: boolean) => { - this._isAssignable = b; - return b; - }; - - if (this._isAssignable !== undefined) { - return this._isAssignable; - } - - const repoInfo = await this.repoInfo(); - if (!repoInfo) { - return setCachedResult(false); - } - - const { fm } = repoInfo; - - try { - // Ensure assignable users are loaded - await fm.getAssignableUsers(); - const allAssignableUsers = fm.getAllAssignableUsers(); - - if (!allAssignableUsers) { - return setCachedResult(false); - } - return setCachedResult(allAssignableUsers.some(user => COPILOT_LOGINS.includes(user.login))); - } catch (error) { - // If there's an error fetching assignable users, assume not assignable - return setCachedResult(false); - } - } - - async isAvailable(): Promise { - // Check if the manager is enabled, copilot API is available, and it's assignable - if (!CopilotRemoteAgentConfig.getEnabled()) { - return false; - } - - if (!this.credentialStore.isAnyAuthenticated()) { - // If not signed in, then we optimistically say it's available. - return true; - } - - const repoInfo = await this.repoInfo(); - if (!repoInfo) { - return false; - } - - const copilotApi = await this.copilotApi; - if (!copilotApi) { - return false; - } - - return await this.isAssignable(); - } - - private async updateAssignabilityContext(): Promise { - try { - this._isAssignable = undefined; // Invalidate cache - const available = await this.isAvailable(); - commands.setContext('copilotCodingAgentAssignable', available); - } catch (error) { - // Presume false - commands.setContext('copilotCodingAgentAssignable', false); - } - } - - private firstFolderManager(): FolderRepositoryManager | undefined { - if (!this.repositoriesManager.folderManagers.length) { - return; - } - return this.repositoriesManager.folderManagers[0]; - } - - private chooseFolderManager(): Promise { - return chooseItem( - this.repositoriesManager.folderManagers, - itemValue => ({ label: pathLib.basename(itemValue.repository.rootUri.fsPath) }), - ); - } - - public async resetCodingAgentPreferences() { - await this.context.workspaceState.update(PREFERRED_GITHUB_CODING_AGENT_REMOTE_WORKSPACE_KEY, undefined); - } - - public async promptAndUpdatePreferredGitHubRemote(skipIfValueAlreadyCached = false): Promise { - if (skipIfValueAlreadyCached) { - const cachedValue = await this.context.workspaceState.get(PREFERRED_GITHUB_CODING_AGENT_REMOTE_WORKSPACE_KEY); - if (cachedValue) { - return; - } - } - - const fm = this.firstFolderManager(); - if (!fm) { - return; - } - - const ghRemotes = await fm.getAllGitHubRemotes(); - Logger.trace(`There are ${ghRemotes.length} GitHub remotes available to select from`, CopilotRemoteAgentManager.ID); - - if (!ghRemotes || ghRemotes.length <= 1) { - // Unexpected if we reach here, command should be hidden. - Logger.trace('No need to select a coding agent GitHub remote, skipping prompt', CopilotRemoteAgentManager.ID); - return; - } - - // Sort so that origin is first if it exists - ghRemotes.sort((a, b) => (a.remoteName === 'origin' ? -1 : b.remoteName === 'origin' ? 1 : 0)); - - const result = await chooseItem( - ghRemotes, - itemValue => ({ label: itemValue.remoteName, description: `(${itemValue.owner}/${itemValue.repositoryName})` }), - { - title: vscode.l10n.t('Coding agent will create pull requests against the selected remote.'), - } - ); - - if (!result) { - Logger.warn('No coding agent GitHub remote selected.', CopilotRemoteAgentManager.ID); - Logger.warn(`Keeping previous value of: ${this.context.workspaceState.get(PREFERRED_GITHUB_CODING_AGENT_REMOTE_WORKSPACE_KEY)}`, CopilotRemoteAgentManager.ID); - return; - } - - Logger.appendLine(`Updated '${result.remoteName}' as preferred coding agent remote`, CopilotRemoteAgentManager.ID); - await this.context.workspaceState.update(PREFERRED_GITHUB_CODING_AGENT_REMOTE_WORKSPACE_KEY, result.remoteName); - } - - async repoInfo(fm?: FolderRepositoryManager): Promise { - fm = fm || this.firstFolderManager(); - const repository = fm?.repository; - const ghRepository = fm?.gitHubRepositories.find(repo => repo.remote instanceof GitHubRemote) as GitHubRepository | undefined; - if (!fm || !repository || !ghRepository) { - return; - } - const baseRef = repository.state.HEAD?.name; // TODO: Consider edge cases - const preferredRemoteName = this.context.workspaceState.get(PREFERRED_GITHUB_CODING_AGENT_REMOTE_WORKSPACE_KEY); - const ghRemotes = await fm.getGitHubRemotes(); - if (!ghRemotes || ghRemotes.length === 0) { - return; - } - - const remote = - preferredRemoteName - ? ghRemotes.find(remote => remote.remoteName === preferredRemoteName) // Cached preferred value - : (ghRemotes.find(remote => remote.remoteName === 'origin') || ghRemotes[0]); // Fallback to the first remote - - if (!remote) { - Logger.error(`no valid remotes for coding agent`, CopilotRemoteAgentManager.ID); - // Clear preference, something is wrong - this.context.workspaceState.update(PREFERRED_GITHUB_CODING_AGENT_REMOTE_WORKSPACE_KEY, undefined); - return; - } - - // Extract repo data from target remote - const { owner, repositoryName: repo } = remote; - if (!owner || !repo || !baseRef || !repository) { - return; - } - return { owner, repo, baseRef, remote, repository, ghRepository, fm }; - } - - async addFollowUpToExistingPR(pullRequestNumber: number, userPrompt: string, summary?: string): Promise { - try { - const pr = await this.findPullRequestById(pullRequestNumber, true); - if (!pr) { - Logger.error(`Could not find pull request #${pullRequestNumber}`, CopilotRemoteAgentManager.ID); - return; - } - // Add a comment tagging @copilot with the user's prompt - const commentBody = `${COPILOT} ${userPrompt} \n\n --- \n\n ${summary ?? ''}`; - const commentResult = await pr.createIssueComment(commentBody); - if (!commentResult) { - Logger.error(`Failed to add comment to PR #${pullRequestNumber}`, CopilotRemoteAgentManager.ID); - return; - } - Logger.appendLine(`Added comment ${commentResult.htmlUrl}`, CopilotRemoteAgentManager.ID); - // allow-any-unicode-next-line - return vscode.l10n.t('🚀 Follow-up comment added to [#{0}]({1})', pullRequestNumber, commentResult.htmlUrl); - } catch (err) { - Logger.error(`Failed to add follow-up comment to PR #${pullRequestNumber}: ${err}`, CopilotRemoteAgentManager.ID); - return; - } - } - - async tryPromptForAuthAndRepo(): Promise { - const authResult = await this.credentialStore.tryPromptForCopilotAuth(); - if (!authResult) { - return undefined; - } - // Wait for repos to update - const fm = await this.chooseFolderManager(); - await fm?.updateRepositories(); - return fm; - } - - async commandImpl(args?: ICopilotRemoteAgentCommandArgs): Promise { - if (!args) { - return; - } - const { userPrompt, summary, source, followup, _version } = args; - const fm = await this.tryPromptForAuthAndRepo(); - if (!fm) { - return; - } - - /* __GDPR__ - "remoteAgent.command.args" : { - "source" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "isFollowup" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "userPromptLength" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "summaryLength" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "version" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetry.sendTelemetryEvent('remoteAgent.command.args', { - source: source?.toString() || 'unknown', - isFollowup: !!followup ? 'true' : 'false', - userPromptLength: userPrompt.length.toString(), - summaryLength: summary ? summary.length.toString() : '0', - version: _version?.toString() || 'unknown' - }); - - if (!userPrompt || userPrompt.trim().length === 0) { - return; - } - - const repoInfo = await this.repoInfo(fm); - if (!repoInfo) { - /* __GDPR__ - "remoteAgent.command.result" : { - "reason" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetry.sendTelemetryErrorEvent('remoteAgent.command.result', { reason: 'noRepositoryInfo' }); - return; - } - const { repository, owner, repo } = repoInfo; - - const repoName = `${owner}/${repo}`; - const hasChanges = repository.state.workingTreeChanges.length > 0 || repository.state.indexChanges.length > 0; - const learnMoreCb = async () => { - /* __GDPR__ - "remoteAgent.command.result" : { - "reason" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetry.sendTelemetryErrorEvent('remoteAgent.command.result', { reason: 'learnMore' }); - vscode.env.openExternal(vscode.Uri.parse('https://aka.ms/coding-agent-docs')); - }; - - let autoPushAndCommit = false; - const message = vscode.l10n.t('Copilot coding agent will continue your work in \'{0}\'.', repoName); - const detail = DELEGATE_MODAL_DETAILS; - if (source !== 'prompt' && hasChanges && CopilotRemoteAgentConfig.getAutoCommitAndPushEnabled()) { - // Pending changes modal - const modalResult = await vscode.window.showInformationMessage( - message, - { - modal: true, - detail, - }, - PUSH_CHANGES, - CONTINUE_WITHOUT_PUSHING, - LEARN_MORE, - ); - - if (!modalResult) { - /* __GDPR__ - "remoteAgent.command.result" : { - "reason" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetry.sendTelemetryErrorEvent('remoteAgent.command.result', { reason: 'cancel' }); - return; - } - - if (modalResult === LEARN_MORE) { - learnMoreCb(); - return; - } - - if (modalResult === PUSH_CHANGES) { - autoPushAndCommit = true; - } - } else if (CopilotRemoteAgentConfig.getPromptForConfirmation()) { - // No pending changes modal - const modalResult = await vscode.window.showInformationMessage( - source !== 'prompt' ? message : vscode.l10n.t('Copilot coding agent will implement the specification outlined in this prompt file'), - { - modal: true, - detail: source !== 'prompt' ? detail : undefined - }, - CONTINUE, - CONTINUE_AND_DO_NOT_ASK_AGAIN, - LEARN_MORE, - ); - if (!modalResult) { - return; - } - - if (modalResult === CONTINUE_AND_DO_NOT_ASK_AGAIN) { - await CopilotRemoteAgentConfig.disablePromptForConfirmation(); - } - - if (modalResult === LEARN_MORE) { - learnMoreCb(); - return; - } - } - - const result = await this.invokeRemoteAgent( - userPrompt, - summary, - undefined, - autoPushAndCommit, - undefined, - fm - ); - - if (result.state !== 'success') { - /* __GDPR__ - "remoteAgent.command.result" : { - "reason" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetry.sendTelemetryErrorEvent('remoteAgent.command.result', { reason: 'invocationFailure' }); - vscode.window.showErrorMessage(result.error); - return; - } - - const { webviewUri, link, number } = result; - - /* __GDPR__ - "remoteAgent.command.success" : { - "source" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "hasFollowup" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "outcome" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetry.sendTelemetryEvent('remoteAgent.command.success', { - source: source || 'unknown', - hasFollowup: (!!followup).toString(), - outcome: 'success' - }); - - const viewLocationSetting = vscode.workspace.getConfiguration('chat').get('agentSessionsViewLocation'); - const pr = await (async () => { - const capi = await this.copilotApi; - if (!capi) { - return; - } - const sessions = await capi.getAllCodingAgentPRs(this.repositoriesManager); - return sessions.find(session => session.number === number); - })(); - - if (!viewLocationSetting || viewLocationSetting === 'disabled') { - vscode.commands.executeCommand('vscode.open', webviewUri); - } else { - await this.provideChatSessions(new vscode.CancellationTokenSource().token); - if (pr) { - vscode.window.showChatSession(COPILOT_SWE_AGENT, `${pr.number}`, {}); - } - } - - if (pr && (_version && _version === 2)) { /* version 2 means caller knows how to render this */ - const plaintextBody = marked.parse(pr.body, { renderer: new PlainTextRenderer(true), smartypants: true }).trim(); - - return { - uri: webviewUri.toString(), - title: pr.title, - description: plaintextBody, - author: COPILOT_ACCOUNTS[pr.author.login].name, - linkTag: `#${pr.number}` - }; - } - - // allow-any-unicode-next-line - return vscode.l10n.t('🚀 Coding agent will continue work in [#{0}]({1}). Track progress [here]({2}).', number, link, webviewUri.toString()); - } - - async invokeRemoteAgent(prompt: string, problemContext?: string, token?: vscode.CancellationToken, autoPushAndCommit = true, chatStream?: vscode.ChatResponseStream, fm?: FolderRepositoryManager): Promise { - const capiClient = await this.copilotApi; - if (!capiClient) { - return { error: vscode.l10n.t('Failed to initialize Copilot API. Please try again later.'), state: 'error' }; - } - - await this.promptAndUpdatePreferredGitHubRemote(true); - - const repoInfo = await this.repoInfo(fm); - if (!repoInfo) { - return { error: vscode.l10n.t('No repository information found. Please open a workspace with a GitHub repository.'), state: 'error' }; - } - const { owner, repo, remote, repository, ghRepository, baseRef } = repoInfo; - - // Check if user has permission to access the repository - try { - await ghRepository.octokit.api.repos.get({ owner, repo }); - } catch (error) { - if (error.status === 404 || error.status === 403) { - const currentUser = await this.credentialStore.getCurrentUser(remote.authProviderId); - return { - error: vscode.l10n.t( - 'Unable to access {0} as user {1}. Please check your permissions and try again.', - `\`${owner}/${repo}\``, - `\`${currentUser.login}\``, - ), - state: 'error', - }; - } - - // Re-throw other errors to be handled by the outer catch block - throw error; - } - - // Check if user has permission to assign Copilot in repository - if (!(await this.isAssignable())) { - return { - error: vscode.l10n.t( - 'Unable to assign GitHub Copilot coding agent in {0}. Please check your permissions and try again.', - `\`${owner}/${repo}\`` - ), - state: 'error', - }; - } - - // NOTE: This is as unobtrusive as possible with the current high-level APIs. - // We only create a new branch and commit if there are staged or working changes. - // This could be improved if we add lower-level APIs to our git extension (e.g. in-memory temp git index). - - const base_ref = baseRef; // This is the ref the PR will merge into - let head_ref: string | undefined; // This is the ref coding agent starts work from (omitted unless we push local changes) - const hasChanges = autoPushAndCommit && (repository.state.workingTreeChanges.length > 0 || repository.state.indexChanges.length > 0); - if (hasChanges) { - if (!CopilotRemoteAgentConfig.getAutoCommitAndPushEnabled()) { - return { error: vscode.l10n.t('Uncommitted changes detected. Please commit or stash your changes before starting the remote agent. Enable \'{0}\' to push your changes automatically.', CODING_AGENT_AUTO_COMMIT_AND_PUSH), state: 'error' }; - } - try { - chatStream?.progress(vscode.l10n.t('Waiting for local changes')); - head_ref = await this.gitOperationsManager.commitAndPushChanges(repoInfo); - } catch (error) { - return { error: vscode.l10n.t('Failed to commit and push changes. Please try again later.'), innerError: error.message, state: 'error' }; - } - } - - try { - if (!(await ghRepository.hasBranch(base_ref))) { - if (!CopilotRemoteAgentConfig.getAutoCommitAndPushEnabled()) { - // We won't auto-push a branch if the user has disabled the setting - return { error: vscode.l10n.t('The branch \'{0}\' does not exist on the remote repository \'{1}/{2}\'. Please create the remote branch first.', base_ref, owner, repo), state: 'error' }; - } - // Push the branch - Logger.appendLine(`Base ref needs to exist on remote. Auto pushing base_ref '${base_ref}' to remote repository '${owner}/${repo}'`, CopilotRemoteAgentManager.ID); - await repository.push(remote.remoteName, base_ref, true); - } - } catch (error) { - return { error: vscode.l10n.t('Failed to configure base branch \'{0}\' does not exist on the remote repository \'{1}/{2}\'. Please create the remote branch first.', base_ref, owner, repo), state: 'error' }; - } - - const title = extractTitle(prompt, problemContext); - const { problemStatement, isTruncated } = truncatePrompt(prompt, problemContext); - - if (isTruncated) { - chatStream?.progress(vscode.l10n.t('Truncating context')); - const truncationResult = await vscode.window.showWarningMessage( - vscode.l10n.t('Prompt size exceeded'), { modal: true, detail: vscode.l10n.t('Your prompt will be truncated to fit within coding agent\'s context window. This may affect the quality of the response.') }, CONTINUE_TRUNCATION); - const userCancelled = token?.isCancellationRequested || !truncationResult || truncationResult !== CONTINUE_TRUNCATION; - /* __GDPR__ - "remoteAgent.truncation" : { - "isCancelled" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetry.sendTelemetryEvent('remoteAgent.truncation', { - isCancelled: String(userCancelled), - }); - if (userCancelled) { - return { error: vscode.l10n.t('User cancelled due to truncation.'), state: 'error' }; - } - } - - const payload: RemoteAgentJobPayload = { - problem_statement: problemStatement, - event_type: 'visual_studio_code_remote_agent_tool_invoked', - pull_request: { - title, - body_placeholder: formatBodyPlaceholder(title), - base_ref, - body_suffix, - ...(head_ref && { head_ref }) - } - }; - - try { - chatStream?.progress(vscode.l10n.t('Delegating to coding agent')); - const response = await capiClient.postRemoteAgentJob(owner, repo, payload, isTruncated); - - // For v1 API, we need to fetch the job details to get the PR info - // Since the PR might not be created immediately, we need to poll for it - chatStream?.progress(vscode.l10n.t('Creating pull request')); - const jobInfo = await this.waitForJobWithPullRequest(capiClient, owner, repo, response.job_id, token); - if (!jobInfo || !jobInfo.pull_request) { - return { error: vscode.l10n.t('Failed to retrieve pull request information from job'), state: 'error' }; - } - - const { number } = jobInfo.pull_request; - - // Find the actual PR to get the HTML URL - const pullRequest = await this.findPullRequestById(number, true); - const htmlUrl = pullRequest?.html_url || `https://github.com/${owner}/${repo}/pull/${number}`; - - const webviewUri = await toOpenPullRequestWebviewUri({ owner, repo, pullRequestNumber: number }); - const prLlmString = `The remote agent has begun work and has created a pull request. Details about the pull request are being shown to the user. If the user wants to track progress or iterate on the agent's work, they should use the pull request.`; - - return { - state: 'success', - number, - link: htmlUrl, - webviewUri, - llmDetails: head_ref ? `Local pending changes have been pushed to branch '${head_ref}'. ${prLlmString}` : prLlmString, - sessionId: response.session_id - }; - } catch (error) { - return { error: vscode.l10n.t('Failed delegating to coding agent. Please try again later.'), innerError: error.message, state: 'error' }; - } - } - - async getSessionLogsFromAction(pullRequest: PullRequestModel) { - const capi = await this.copilotApi; - if (!capi) { - return []; - } - const lastRun = await this.getLatestCodingAgentFromAction(pullRequest); - if (!lastRun) { - return []; - } - - return await capi.getLogsFromZipUrl(lastRun.logs_url); - } - - async getWorkflowStepsFromAction(pullRequest: PullRequestModel): Promise { - const lastRun = await this.getLatestCodingAgentFromAction(pullRequest, 0, false); - if (!lastRun) { - return []; - } - - try { - const jobs = await pullRequest.githubRepository.getWorkflowJobs(lastRun.id); - const steps: SessionSetupStep[] = []; - - for (const job of jobs) { - if (job.steps) { - for (const step of job.steps) { - steps.push({ name: step.name, status: step.status }); - } - } - } - - return steps; - } catch (error) { - Logger.error(`Failed to get workflow steps: ${error}`, CopilotRemoteAgentManager.ID); - return []; - } - } - - async getLatestCodingAgentFromAction(pullRequest: PullRequestModel, sessionIndex = 0, completedOnly = true): Promise { - const capi = await this.copilotApi; - if (!capi) { - return; - } - const runs = await pullRequest.githubRepository.getWorkflowRunsFromAction(pullRequest.createdAt); - const workflowRuns = runs.flatMap(run => run.workflow_runs); - const padawanRuns = workflowRuns - .filter(run => run.path && run.path.startsWith(`dynamic/${COPILOT_SWE_AGENT}`)) - .filter(run => run.pull_requests?.some(pr => pr.id === pullRequest.id)); - - const session = padawanRuns.filter(s => !completedOnly || s.status === 'completed').at(sessionIndex); - if (!session) { - return; - } - - return this.getLatestRun(padawanRuns); - } - - async getSessionLogFromPullRequest(pullRequest: PullRequestModel, sessionIndex = 0, completedOnly = true): Promise { - const capi = await this.copilotApi; - if (!capi) { - return undefined; - } - - const sessions = await capi.getAllSessions(pullRequest.id); - const session = sessions.filter(s => !completedOnly || s.state === 'completed').at(sessionIndex); - if (!session) { - return undefined; - } - - const logs = await capi.getLogsFromSession(session.id); - - // If session is in progress, try to fetch workflow steps to show setup progress - let setupSteps: SessionSetupStep[] | undefined; - if (session.state === 'in_progress' || logs.trim().length === 0) { - try { - // Get workflow steps instead of logs - setupSteps = await this.getWorkflowStepsFromAction(pullRequest); - } catch (error) { - // If we can't fetch workflow steps, don't fail the entire request - Logger.warn(`Failed to fetch workflow steps for session ${session.id}: ${error}`, CopilotRemoteAgentManager.ID); - } - } - - return { info: session, logs, setupSteps }; - } - - async getSessionUrlFromPullRequest(pullRequest: PullRequestModel): Promise { - const capi = await this.copilotApi; - if (!capi) { - return; - } - - const sessions = await this.getLatestCodingAgentFromAction(pullRequest); - if (!sessions) { - return; - } - return sessions.html_url; - } - - private getLatestRun(runs: T[]): T { - return runs - .slice() - .sort((a, b) => { - const dateA = new Date(a.last_updated_at ?? a.updated_at ?? 0).getTime(); - const dateB = new Date(b.last_updated_at ?? b.updated_at ?? 0).getTime(); - return dateB - dateA; - })[0]; - } - - async extractHistory(history: ReadonlyArray): Promise { - if (!history) { - return; - } - const parts: string[] = []; - for (const turn of history) { - if (turn instanceof vscode.ChatRequestTurn) { - parts.push(`User: ${turn.prompt}`); - } else if (turn instanceof vscode.ChatResponseTurn) { - const textParts = turn.response - .filter(part => part instanceof vscode.ChatResponseMarkdownPart) - .map(part => part.value); - if (textParts.length > 0) { - parts.push(`Copilot: ${textParts.join('\n')}`); - } - } - } - const fullText = parts.join('\n'); // TODO: Summarization if too long - return fullText; - } - - private extractFileReferences(references: readonly ChatPromptReference[] | undefined): string | undefined { - if (!references || references.length === 0) { - return; - } - // 'file:///Users/jospicer/dev/joshbot/.github/workflows/build-vsix.yml' -> '.github/workflows/build-vsix.yml' - const parts: string[] = []; - for (const ref of references) { - if (ref.value instanceof vscode.Uri && ref.value.scheme === 'file') { // TODO: Add support for more kinds of references - const repositoryForFile = getRepositoryForFile(this.gitAPI, ref.value); - if (repositoryForFile) { - const relativePath = pathLib.relative(repositoryForFile.rootUri.fsPath, ref.value.fsPath); - parts.push(` - ${relativePath}`); - } - } - } - - if (!parts.length) { - return; - } - - parts.unshift('The user has attached the following files as relevant context:'); - return parts.join('\n'); - } - - public async provideChatSessions(token: vscode.CancellationToken): Promise { - try { - const capi = await this.copilotApi; - if (!capi) { - return []; - } - - // Check if the token is already cancelled - if (token.isCancellationRequested) { - return []; - } - - await this.waitRepoManagerInitialization(); - - let codingAgentPRs: CodingAgentPRAndStatus[] = await this.prsTreeModel.getCopilotPullRequests(); - Logger.debug(`Fetched PRs from API: ${codingAgentPRs.length}`, CopilotRemoteAgentManager.ID); - - return await Promise.all(codingAgentPRs.map(async prAndStatus => { - const timestampNumber = new Date(prAndStatus.item.createdAt).getTime(); - const status = copilotPRStatusToSessionStatus(prAndStatus.status); - const pullRequest = prAndStatus.item; - const tooltip = await issueMarkdown(pullRequest, this.context, this.repositoriesManager); - - const uri = await toOpenPullRequestWebviewUri({ owner: pullRequest.remote.owner, repo: pullRequest.remote.repositoryName, pullRequestNumber: pullRequest.number }); - const prLinkTitle = vscode.l10n.t('Open pull request in VS Code'); - - // If we have multiple repositories, include the repo name in the link text - // e.g., 'owner/repo #123' instead of just '#123' - let repoInfo = ''; - if (this.repositoriesManager.folderManagers.length > 1) { - const owner = pullRequest.remote.owner; - const repo = pullRequest.remote.repositoryName; - repoInfo = `${owner}/${repo} `; - } - const description = new vscode.MarkdownString(`[${repoInfo}#${pullRequest.number}](${uri.toString()} "${prLinkTitle}")`); // pullRequest.base.ref === defaultBranch ? `PR #${pullRequest.number}`: `PR #${pullRequest.number} → ${pullRequest.base.ref}`; - const chatSession: ChatSessionWithPR = { - id: `${pullRequest.number}`, - label: pullRequest.title || `Session ${pullRequest.number}`, - iconPath: this.getIconForSession(status), - pullRequest: pullRequest, - description: description, - tooltip, - status, - timing: { - startTime: timestampNumber - }, - statistics: pullRequest.item.additions !== undefined && pullRequest.item.deletions !== undefined && (pullRequest.item.additions > 0 || pullRequest.item.deletions > 0) ? { - insertions: pullRequest.item.additions, - deletions: pullRequest.item.deletions - } : undefined - } as unknown as ChatSessionWithPR; - return chatSession; - })); - } catch (error) { - Logger.error(`Failed to provide coding agents information: ${error}`, CopilotRemoteAgentManager.ID); - } - return []; - } - - public async provideChatSessionContent(id: string, token: vscode.CancellationToken): Promise { - try { - const capi = await this.copilotApi; - if (!capi || token.isCancellationRequested) { - return this.createEmptySession(); - } - - await this.waitRepoManagerInitialization(); - - let pullRequestNumber: number | undefined; - let sessionIndex: number | undefined; - - const indexedSessionId = SessionIdForPr.parse(id); - if (indexedSessionId) { - pullRequestNumber = indexedSessionId.prNumber; - sessionIndex = indexedSessionId.sessionIndex; - } - - if (typeof pullRequestNumber === 'undefined') { - pullRequestNumber = parseInt(id); - if (isNaN(pullRequestNumber)) { - Logger.error(`Invalid pull request number: ${id}`, CopilotRemoteAgentManager.ID); - return this.createEmptySession(); - } - } - - const pullRequest = await this.findPullRequestById(pullRequestNumber, true); - if (!pullRequest) { - Logger.error(`Pull request not found: ${pullRequestNumber}`, CopilotRemoteAgentManager.ID); - return this.createEmptySession(); - } - - // Parallelize independent operations - const timelineEvents = pullRequest.getTimelineEvents(); - const changeModels = this.getChangeModels(pullRequest); - let sessions = await capi.getAllSessions(pullRequest.id); - - if (!sessions || sessions.length === 0) { - Logger.warn(`No sessions found for pull request ${pullRequestNumber}`, CopilotRemoteAgentManager.ID); - return this.createEmptySession(); - } - - if (!Array.isArray(sessions)) { - Logger.error(`getAllSessions returned non-array: ${typeof sessions}`, CopilotRemoteAgentManager.ID); - return this.createEmptySession(); - } - - if (typeof sessionIndex === 'number') { - const target = sessions.at(sessionIndex); - if (!target) { - Logger.error(`Session not found: ${sessionIndex}`, CopilotRemoteAgentManager.ID); - return this.createEmptySession(); - } - - sessions = [target]; - } - - // Create content builder with pre-fetched change models - const contentBuilder = new ChatSessionContentBuilder(CopilotRemoteAgentManager.ID, COPILOT, changeModels); - - // Parallelize operations that don't depend on each other - const history = await contentBuilder.buildSessionHistory(sessions, pullRequest, capi, timelineEvents); - return { - history, - activeResponseCallback: this.findActiveResponseCallback(sessions, pullRequest), - requestHandler: undefined // TODO(jospicer): chatSessionsProvider@2 uses a single chat participant to handle requests - }; - } catch (error) { - Logger.error(`Failed to provide chat session content: ${error}`, CopilotRemoteAgentManager.ID); - return this.createEmptySession(); - } - } - - private findActiveResponseCallback( - sessions: SessionInfo[], - pullRequest: PullRequestModel - ): ((stream: vscode.ChatResponseStream, token: vscode.CancellationToken) => Thenable) | undefined { - // Only the latest in-progress or queued session gets activeResponseCallback - const pendingSession = sessions - .slice() - .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) - .find(session => session.state === 'in_progress' || session.state === 'queued'); - - if (pendingSession) { - return this.createActiveResponseCallback(pullRequest, pendingSession.id); - } - return undefined; - } - - private createEmptySession(): vscode.ChatSession { - return { - history: [], - requestHandler: undefined - }; - } - - private createActiveResponseCallback(pullRequest: PullRequestModel, sessionId: string): (stream: vscode.ChatResponseStream, token: vscode.CancellationToken) => Thenable { - return async (stream: vscode.ChatResponseStream, token: vscode.CancellationToken) => { - // Use the shared streaming logic - await this.waitForQueuedToInProgress(sessionId, stream, token); - this._onDidCreatePullRequest.fire(pullRequest.number); - return this.streamSessionLogs(stream, pullRequest, sessionId, token); - }; - } - - private async streamNewLogContent(pullRequest: PullRequestModel, stream: vscode.ChatResponseStream, newLogContent: string): Promise<{ hasStreamedContent: boolean; hasSetupStepProgress: boolean }> { - try { - if (!newLogContent.trim()) { - return { hasStreamedContent: false, hasSetupStepProgress: false }; - } - - // Parse the new log content - const logChunks = parseSessionLogs(newLogContent); - let hasStreamedContent = false; - let hasSetupStepProgress = false; - - for (const chunk of logChunks) { - for (const choice of chunk.choices) { - const delta = choice.delta; - - if (delta.role === 'assistant') { - // Handle special case for run_custom_setup_step/run_setup - if (choice.finish_reason === 'tool_calls' && delta.tool_calls?.length && (delta.tool_calls[0].function.name === 'run_custom_setup_step' || delta.tool_calls[0].function.name === 'run_setup')) { - const toolCall = delta.tool_calls[0]; - let args: any = {}; - try { - args = JSON.parse(toolCall.function.arguments); - } catch { - // fallback to empty args - } - - if (delta.content && delta.content.trim()) { - // Finished setup step - create/update tool part - const toolPart = this.createToolInvocationPart(pullRequest, toolCall, args.name || delta.content); - if (toolPart) { - stream.push(toolPart); - hasStreamedContent = true; - } - } else { - // Running setup step - just track progress - hasSetupStepProgress = true; - Logger.appendLine(`Setup step in progress: ${args.name || 'Unknown step'}`, CopilotRemoteAgentManager.ID); - } - } else { - if (delta.content) { - if (!delta.content.startsWith('')) { - stream.markdown(delta.content); - hasStreamedContent = true; - } - } - - if (delta.tool_calls) { - for (const toolCall of delta.tool_calls) { - const toolPart = this.createToolInvocationPart(pullRequest, toolCall, delta.content || ''); - if (toolPart) { - stream.push(toolPart); - hasStreamedContent = true; - } - } - } - } - } - - // Handle finish reasons - if (choice.finish_reason && choice.finish_reason !== 'null') { - Logger.appendLine(`Streaming finish_reason: ${choice.finish_reason}`, CopilotRemoteAgentManager.ID); - } - } - } - - if (hasStreamedContent) { - Logger.appendLine(`Streamed content (markdown or tool parts), progress should be cleared`, CopilotRemoteAgentManager.ID); - } else if (hasSetupStepProgress) { - Logger.appendLine(`Setup step progress detected, keeping progress indicator`, CopilotRemoteAgentManager.ID); - } else { - Logger.appendLine(`No actual content streamed, progress may still be showing`, CopilotRemoteAgentManager.ID); - } - return { hasStreamedContent, hasSetupStepProgress }; - } catch (error) { - Logger.error(`Error streaming new log content: ${error}`, CopilotRemoteAgentManager.ID); - return { hasStreamedContent: false, hasSetupStepProgress: false }; - } - } - - private async streamSessionLogs(stream: vscode.ChatResponseStream, pullRequest: PullRequestModel, sessionId: string, token: vscode.CancellationToken): Promise { - const capi = await this.copilotApi; - if (!capi || token.isCancellationRequested) { - return; - } - - let lastLogLength = 0; - let lastProcessedLength = 0; - let hasActiveProgress = false; - const pollingInterval = 3000; // 3 seconds - - return new Promise((resolve, reject) => { - let isCompleted = false; - - const complete = async () => { - if (isCompleted) { - return; - } - isCompleted = true; - - await pullRequest.getFileChangesInfo(); - const multiDiffPart = await this.getFileChangesMultiDiffPart(pullRequest); - if (multiDiffPart) { - stream.push(multiDiffPart); - } - - resolve(); - }; - - const pollForUpdates = async (): Promise => { - try { - if (token.isCancellationRequested) { - complete(); - return; - } - - // Get the specific session info - const sessionInfo = await capi.getSessionInfo(sessionId); - if (!sessionInfo || token.isCancellationRequested) { - complete(); - return; - } - - // Get session logs - const logs = await capi.getLogsFromSession(sessionId); - - // Check if session is still in progress - if (sessionInfo.state !== 'in_progress') { - if (logs.length > lastProcessedLength) { - const newLogContent = logs.slice(lastProcessedLength); - const streamResult = await this.streamNewLogContent(pullRequest, stream, newLogContent); - if (streamResult.hasStreamedContent) { - hasActiveProgress = false; - } - } - hasActiveProgress = false; - complete(); - return; - } - - if (logs.length > lastLogLength) { - Logger.appendLine(`New logs detected, attempting to stream content`, CopilotRemoteAgentManager.ID); - const newLogContent = logs.slice(lastProcessedLength); - const streamResult = await this.streamNewLogContent(pullRequest, stream, newLogContent); - lastProcessedLength = logs.length; - - if (streamResult.hasStreamedContent) { - Logger.appendLine(`Content was streamed, resetting hasActiveProgress to false`, CopilotRemoteAgentManager.ID); - hasActiveProgress = false; - } else if (streamResult.hasSetupStepProgress) { - Logger.appendLine(`Setup step progress detected, keeping progress active`, CopilotRemoteAgentManager.ID); - // Keep hasActiveProgress as is, don't reset it - } else { - Logger.appendLine(`No content was streamed, keeping hasActiveProgress as ${hasActiveProgress}`, CopilotRemoteAgentManager.ID); - } - } - - lastLogLength = logs.length; - - if (!token.isCancellationRequested && sessionInfo.state === 'in_progress') { - if (!hasActiveProgress) { - Logger.appendLine(`Showing progress indicator (hasActiveProgress was false)`, CopilotRemoteAgentManager.ID); - stream.progress('Working...'); - hasActiveProgress = true; - } else { - Logger.appendLine(`NOT showing progress indicator (hasActiveProgress was true)`, CopilotRemoteAgentManager.ID); - } - setTimeout(pollForUpdates, pollingInterval); - } else { - complete(); - } - } catch (error) { - Logger.error(`Error polling for session updates: ${error}`, CopilotRemoteAgentManager.ID); - if (!token.isCancellationRequested) { - setTimeout(pollForUpdates, pollingInterval); - } else { - reject(error); - } - } - }; - - // Start polling - setTimeout(pollForUpdates, pollingInterval); - }); - } - - private async getChangeModels(pullRequest: PullRequestModel) { - try { - const repoInfo = await this.repoInfo(); - if (!repoInfo) { - return []; - } - - const { fm: folderManager } = repoInfo; - return await PullRequestModel.getChangeModels(folderManager, pullRequest); - } catch (error) { - Logger.error(`Failed to get change models: ${error}`, CopilotRemoteAgentManager.ID); - return []; - } - } - - private async getFileChangesMultiDiffPart(pullRequest: PullRequestModel): Promise { - try { - const changeModels = await this.getChangeModels(pullRequest); - Logger.warn('No file changes found for pull request, not showing diff.', CopilotRemoteAgentManager.ID); - if (changeModels.length === 0) { - return undefined; - } - - const diffEntries: vscode.ChatResponseDiffEntry[] = []; - for (const changeModel of changeModels) { - const { added, removed } = await changeModel.calculateChangedLinesCount(); - Logger.trace(`DiffEntry -> original='${changeModel.parentFilePath}' modified='${changeModel.filePath}' (+${added} -${removed})`, CopilotRemoteAgentManager.ID); - diffEntries.push({ - originalUri: changeModel.parentFilePath, - modifiedUri: changeModel.filePath, - goToFileUri: changeModel.filePath, - added, - removed, - }); - } - - const title = `Changes in Pull Request #${pullRequest.number}`; - return new vscode.ChatResponseMultiDiffPart(diffEntries, title); - } catch (error) { - Logger.error(`Failed to get file changes multi diff part: ${error}`, CopilotRemoteAgentManager.ID); - return undefined; - } - } - - private async findPullRequestById(number: number, fetch: boolean): Promise { - for (const folderManager of this.repositoriesManager.folderManagers) { - for (const githubRepo of folderManager.gitHubRepositories) { - const pullRequest = githubRepo.pullRequestModels.find(pr => pr.number === number); - if (pullRequest) { - return pullRequest; - } - - if (fetch) { - try { - const pullRequest = await githubRepo.getPullRequest(number, false); - if (pullRequest) { - return pullRequest; - } - } catch (error) { - // Continue to next repository if this one doesn't have the PR - Logger.debug(`PR ${number} not found in ${githubRepo.remote.owner}/${githubRepo.remote.repositoryName} (remote=${githubRepo.remote.url}): ${error}`, CopilotRemoteAgentManager.ID); - } - } - } - } - return undefined; - } - - private createToolInvocationPart(pullRequest: PullRequestModel, toolCall: any, deltaContent: string = ''): vscode.ChatToolInvocationPart | vscode.ChatResponseThinkingProgressPart | undefined { - if (!toolCall.function?.name || !toolCall.id) { - return undefined; - } - - // Hide reply_to_comment tool - if (toolCall.function.name === 'reply_to_comment') { - return undefined; - } - - const toolPart = new vscode.ChatToolInvocationPart(toolCall.function.name, toolCall.id); - toolPart.isComplete = true; - toolPart.isError = false; - toolPart.isConfirmed = true; - - try { - const toolDetails = parseToolCallDetails(toolCall, deltaContent); - toolPart.toolName = toolDetails.toolName; - - if (toolCall.toolName === 'think') { - return new vscode.ChatResponseThinkingProgressPart(toolCall.invocationMessage); - } - - if (toolCall.function.name === 'bash') { - toolPart.invocationMessage = new vscode.MarkdownString(`\`\`\`bash\n${toolDetails.invocationMessage}\n\`\`\``); - } else { - toolPart.invocationMessage = new vscode.MarkdownString(toolDetails.invocationMessage); - } - - if (toolDetails.pastTenseMessage) { - toolPart.pastTenseMessage = new vscode.MarkdownString(toolDetails.pastTenseMessage); - } - if (toolDetails.originMessage) { - toolPart.originMessage = new vscode.MarkdownString(toolDetails.originMessage); - } - if (toolDetails.toolSpecificData) { - if (StrReplaceEditorToolData.is(toolDetails.toolSpecificData)) { - if ((toolDetails.toolSpecificData.command === 'view' || toolDetails.toolSpecificData.command === 'edit') && toolDetails.toolSpecificData.fileLabel) { - const uri = vscode.Uri.file(pathLib.join(pullRequest.githubRepository.rootUri.fsPath, toolDetails.toolSpecificData.fileLabel)); - toolPart.invocationMessage = new vscode.MarkdownString(`${toolPart.toolName} [](${uri.toString()})`); - toolPart.invocationMessage.supportHtml = true; - toolPart.pastTenseMessage = new vscode.MarkdownString(`${toolPart.toolName} [](${uri.toString()})`); - } - } else { - toolPart.toolSpecificData = toolDetails.toolSpecificData; - } - } - } catch (error) { - toolPart.toolName = toolCall.function.name || 'unknown'; - toolPart.invocationMessage = new vscode.MarkdownString(`Tool: ${toolCall.function.name}`); - toolPart.isError = true; - } - - return toolPart; - } - - private async waitForQueuedToInProgress( - sessionId: string, - stream?: vscode.ChatResponseStream, - token?: vscode.CancellationToken - ): Promise { - const capi = await this.copilotApi; - if (!capi) { - return undefined; - } - - let sessionInfo: SessionInfo | undefined; - - const waitForQueuedMaxRetries = 3; - const waitForQueuedDelay = 5_000; // 5 seconds - - // Allow for a short delay before the session is marked as 'queued' - let waitForQueuedCount = 0; - do { - sessionInfo = await capi.getSessionInfo(sessionId); - if (sessionInfo && sessionInfo.state === 'queued') { - stream?.progress(vscode.l10n.t('Attaching to session')); - Logger.trace('Queued session found', CopilotRemoteAgentManager.ID); - break; - } - if (waitForQueuedCount < waitForQueuedMaxRetries) { - Logger.trace('Session not yet queued, waiting...', CopilotRemoteAgentManager.ID); - await new Promise(resolve => setTimeout(resolve, waitForQueuedDelay)); - } - ++waitForQueuedCount; - } while (waitForQueuedCount <= waitForQueuedMaxRetries && (!token || !token.isCancellationRequested)); - - if (!sessionInfo || sessionInfo.state !== 'queued') { - if (sessionInfo?.state === 'in_progress') { - Logger.trace('Session already in progress', CopilotRemoteAgentManager.ID); - return sessionInfo; - } - // Failure - Logger.trace('Failed to find queued session', CopilotRemoteAgentManager.ID); - return; - } - - const maxWaitTime = 2 * 60 * 1_000; // 2 minutes - const pollInterval = 3_000; // 3 seconds - const startTime = Date.now(); - - Logger.appendLine(`Session ${sessionInfo.id} is queued, waiting for transition to in_progress...`, CopilotRemoteAgentManager.ID); - while (Date.now() - startTime < maxWaitTime && (!token || !token.isCancellationRequested)) { - const sessionInfo = await capi.getSessionInfo(sessionId); - if (sessionInfo?.state === 'in_progress') { - Logger.appendLine(`Session ${sessionInfo.id} now in progress.`, CopilotRemoteAgentManager.ID); - return sessionInfo; - } - await new Promise(resolve => setTimeout(resolve, pollInterval)); - } - Logger.warn(`Timed out waiting for session ${sessionId} to transition from queued to in_progress`, CopilotRemoteAgentManager.ID); - } - - private async waitForNewSession( - pullRequest: PullRequestModel, - stream: vscode.ChatResponseStream, - token: vscode.CancellationToken, - waitForTransitionToInProgress: boolean = false - ): Promise { - // Get the current number of sessions - const capi = await this.copilotApi; - if (!capi) { - stream.markdown(vscode.l10n.t('Failed to connect to Copilot API.')); - return; - } - - const initialSessions = await capi.getAllSessions(pullRequest.id); - const initialSessionCount = initialSessions.length; - - // Poll for a new session to start - const maxWaitTime = 5 * 60 * 1000; // 5 minutes - const pollInterval = 3000; // 3 seconds - const startTime = Date.now(); - - while (Date.now() - startTime < maxWaitTime && !token.isCancellationRequested) { - const currentSessions = await capi.getAllSessions(pullRequest.id); - - // Check if a new session has started - if (currentSessions.length > initialSessionCount) { - const newSession = currentSessions - .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]; - if (!waitForTransitionToInProgress) { - return newSession; - } - const inProgressSession = await this.waitForQueuedToInProgress(newSession.id, stream, token); - if (!inProgressSession) { - stream.markdown(vscode.l10n.t('Timed out waiting for coding agent to begin work. Please try again shortly.')); - return; - } - return inProgressSession; - } - - await new Promise(resolve => setTimeout(resolve, pollInterval)); - } - - stream.markdown(vscode.l10n.t('Timed out waiting for the coding agent to respond. The agent may still be processing your request.')); - return; - } - - private getIconForSession(status: vscode.ChatSessionStatus): vscode.Uri | vscode.ThemeIcon { - // Fallback to theme icons if no theme data available - switch (status) { - case vscode.ChatSessionStatus.Completed: - return new vscode.ThemeIcon('issues', new vscode.ThemeColor('testing.iconPassed')); - case vscode.ChatSessionStatus.Failed: - return new vscode.ThemeIcon('error', new vscode.ThemeColor('testing.iconFailed')); - default: - return new vscode.ThemeIcon('issue-reopened', new vscode.ThemeColor('editorLink.activeForeground')); - } - } - - public refreshChatSessions(): void { - this.prsTreeModel.clearCopilotCaches(); - this._onDidChangeChatSessions.fire(); - } - - private async waitForJobWithPullRequest( - capiClient: CopilotApi, - owner: string, - repo: string, - jobId: string, - token?: vscode.CancellationToken - ): Promise { - const maxWaitTime = 30 * 1000; // 30 seconds - const pollInterval = 2000; // 2 seconds - const startTime = Date.now(); - - Logger.appendLine(`Waiting for job ${jobId} to have pull request information...`, CopilotRemoteAgentManager.ID); - - while (Date.now() - startTime < maxWaitTime && (!token || !token.isCancellationRequested)) { - const jobInfo = await capiClient.getJobByJobId(owner, repo, jobId); - if (jobInfo && jobInfo.pull_request && jobInfo.pull_request.number) { - Logger.appendLine(`Job ${jobId} now has pull request #${jobInfo.pull_request.number}`, CopilotRemoteAgentManager.ID); - return jobInfo; - } - await new Promise(resolve => setTimeout(resolve, pollInterval)); - } - - Logger.warn(`Timed out waiting for job ${jobId} to have pull request information`, CopilotRemoteAgentManager.ID); - return undefined; - } - - public async cancelMostRecentChatSession(pullRequest: PullRequestModel): Promise { - const capi = await this.copilotApi; - if (!capi) { - Logger.warn(`No Copilot API instance found`); - return; - } - - const folderManager = this.repositoriesManager.getManagerForIssueModel(pullRequest) ?? this.repositoriesManager.folderManagers[0]; - if (!folderManager) { - Logger.warn(`No folder manager found for pull request`); - return; - } - - const sessions = await capi.getAllSessions(pullRequest.id); - if (sessions.length > 0) { - const mostRecentSession = sessions[sessions.length - 1]; - const folder = folderManager.gitHubRepositories.find(repo => repo.remote.remoteName === pullRequest.remote.remoteName); - folder?.cancelWorkflow(mostRecentSession.workflow_run_id); - } else { - Logger.warn(`No active chat session found for pull request ${pullRequest.id}`); - } } } \ No newline at end of file diff --git a/src/github/copilotRemoteAgent/chatSessionContentBuilder.ts b/src/github/copilotRemoteAgent/chatSessionContentBuilder.ts deleted file mode 100644 index 626c3fccc1..0000000000 --- a/src/github/copilotRemoteAgent/chatSessionContentBuilder.ts +++ /dev/null @@ -1,468 +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 nodePath from 'path'; -import * as marked from 'marked'; -import vscode from 'vscode'; -import { parseSessionLogs, parseToolCallDetails, StrReplaceEditorToolData } from '../../../common/sessionParsing'; -import { COPILOT_SWE_AGENT } from '../../common/copilot'; -import Logger from '../../common/logger'; -import { CommentEvent, CopilotFinishedEvent, CopilotStartedEvent, EventType, ReviewEvent, TimelineEvent } from '../../common/timelineEvent'; -import { toOpenPullRequestWebviewUri } from '../../common/uri'; -import { InMemFileChangeModel, RemoteFileChangeModel } from '../../view/fileChangeModel'; -import { AssistantDelta, Choice, ToolCall } from '../common'; -import { CopilotApi, SessionInfo } from '../copilotApi'; -import { PlainTextRenderer } from '../markdownUtils'; -import { PullRequestModel } from '../pullRequestModel'; - -export class ChatSessionContentBuilder { - constructor( - private loggerId: string, - private readonly handler: string, - private getChangeModels: Promise<(RemoteFileChangeModel | InMemFileChangeModel)[]> - ) { } - - public async buildSessionHistory( - sessions: SessionInfo[], - pullRequest: PullRequestModel, - capi: CopilotApi, - timelineEventsPromise: Promise - ): Promise> { - const sortedSessions = sessions - .filter((session, index, array) => - array.findIndex(s => s.id === session.id) === index - ) - .slice().sort((a, b) => - new Date(a.created_at).getTime() - new Date(b.created_at).getTime() - ); - - // Process all sessions concurrently while maintaining order - const sessionResults = await Promise.all( - sortedSessions.map(async (session, sessionIndex) => { - const firstHistoryEntry = async () => { - const sessionPrompt = await this.determineSessionPrompt(session, sessionIndex, pullRequest, timelineEventsPromise, capi); - - // Create request turn for this session - const sessionRequest = new vscode.ChatRequestTurn2( - sessionPrompt, - undefined, // command - [], // references - COPILOT_SWE_AGENT, - [], // toolReferences - [] - ); - return sessionRequest; - }; - const secondHistoryEntry = async () => { - const logs = await capi.getLogsFromSession(session.id); - // Create response turn - const responseHistory = await this.createResponseTurn(pullRequest, logs, session); - return responseHistory; - }; - const [first, second] = await Promise.all([ - firstHistoryEntry(), - secondHistoryEntry(), - ]); - - return { first, second, sessionIndex }; - }) - ); - - const history: Array = []; - - // Build history array in the correct order - for (const { first, second, sessionIndex } of sessionResults) { - history.push(first); - - if (second) { - // if this is the first response, then also add the PR card - if (sessionIndex === 0) { - const uri = await toOpenPullRequestWebviewUri({ owner: pullRequest.remote.owner, repo: pullRequest.remote.repositoryName, pullRequestNumber: pullRequest.number }); - const plaintextBody = marked.parse(pullRequest.body, { renderer: new PlainTextRenderer(true), smartypants: true }).trim(); - - const card = new vscode.ChatResponsePullRequestPart(uri, pullRequest.title, plaintextBody, pullRequest.author.specialDisplayName ?? pullRequest.author.login, `#${pullRequest.number}`); - const cardTurn = new vscode.ChatResponseTurn2([card], {}, COPILOT_SWE_AGENT); - history.push(cardTurn); - } - history.push(second); - } - } - return history; - } - - private async createResponseTurn(pullRequest: PullRequestModel, logs: string, session: SessionInfo): Promise { - if (logs.trim().length > 0) { - return await this.parseSessionLogsIntoResponseTurn(pullRequest, logs, session); - } else if (session.state === 'in_progress' || session.state === 'queued') { - // For in-progress sessions without logs, create a placeholder response - const placeholderParts = [new vscode.ChatResponseProgressPart('Initializing session')]; - const responseResult: vscode.ChatResult = {}; - return new vscode.ChatResponseTurn2(placeholderParts, responseResult, COPILOT_SWE_AGENT); - } else { - // For completed sessions without logs, add an empty response to maintain pairing - const emptyParts = [new vscode.ChatResponseMarkdownPart('_No logs available for this session_')]; - const responseResult: vscode.ChatResult = {}; - return new vscode.ChatResponseTurn2(emptyParts, responseResult, COPILOT_SWE_AGENT); - } - } - - private async determineSessionPrompt( - session: SessionInfo, - sessionIndex: number, - pullRequest: PullRequestModel, - timelineEventsPromise: Promise, - capi: CopilotApi - ): Promise { - let sessionPrompt = session.name || `Session ${sessionIndex + 1} (ID: ${session.id})`; - - if (sessionIndex === 0) { - sessionPrompt = await this.getInitialSessionPrompt(session, pullRequest, capi, sessionPrompt); - } else { - sessionPrompt = await this.getFollowUpSessionPrompt(sessionIndex, timelineEventsPromise, sessionPrompt); - } - - // TODO: @rebornix, remove @copilot prefix from session prompt for now - sessionPrompt = sessionPrompt.replace(/@copilot\s*/gi, '').trim(); - return sessionPrompt; - } - - private async getFollowUpSessionPrompt( - sessionIndex: number, - timelineEventsPromise: Promise, - defaultPrompt: string - ): Promise { - const timelineEvents = await timelineEventsPromise; - Logger.appendLine(`Found ${timelineEvents.length} timeline events`, this.loggerId); - const copilotStartedEvents = timelineEvents - .filter((event): event is CopilotStartedEvent => event.event === EventType.CopilotStarted) - .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); - - const copilotFinishedEvents = timelineEvents - .filter((event): event is CopilotFinishedEvent => event.event === EventType.CopilotFinished) - .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); - - Logger.appendLine(`Session ${sessionIndex}: Found ${copilotStartedEvents.length} CopilotStarted events and ${copilotFinishedEvents.length} CopilotFinished events`, this.loggerId); - - const copilotStartedEvent = copilotStartedEvents[sessionIndex]; - if (!copilotStartedEvent) { - Logger.appendLine(`Session ${sessionIndex}: No CopilotStarted event found at index ${sessionIndex}`, this.loggerId); - return defaultPrompt; - } - - const currentSessionStartTime = new Date(copilotStartedEvent.createdAt).getTime(); - const previousSessionEndTime = this.getPreviousSessionEndTime(sessionIndex, copilotFinishedEvents); - - const relevantEvents = this.findRelevantTimelineEvents(timelineEvents, previousSessionEndTime, currentSessionStartTime); - - const matchingEvent = relevantEvents[0]; - if (matchingEvent) { - const prompt = this.extractPromptFromEvent(matchingEvent); - Logger.appendLine(`Session ${sessionIndex}: Found matching event - ${matchingEvent.event}`, this.loggerId); - return prompt; - } else { - Logger.appendLine(`Session ${sessionIndex}: No matching event found between times ${previousSessionEndTime} and ${currentSessionStartTime}`, this.loggerId); - Logger.appendLine(`Session ${sessionIndex}: Relevant events found: ${relevantEvents.length}`, this.loggerId); - return defaultPrompt; - } - } - - private getPreviousSessionEndTime(sessionIndex: number, copilotFinishedEvents: CopilotFinishedEvent[]): number { - if (sessionIndex > 0 && copilotFinishedEvents[sessionIndex - 1]) { - return new Date(copilotFinishedEvents[sessionIndex - 1].createdAt).getTime(); - } - return 0; - } - - private findRelevantTimelineEvents( - timelineEvents: readonly TimelineEvent[], - previousSessionEndTime: number, - currentSessionStartTime: number - ): TimelineEvent[] { - return timelineEvents - .filter(event => { - if (event.event !== EventType.Commented && event.event !== EventType.Reviewed) { - return false; - } - - const eventTime = new Date( - event.event === EventType.Commented ? (event as CommentEvent).createdAt : - event.event === EventType.Reviewed ? (event as ReviewEvent).submittedAt : '' - ).getTime(); - - // Must be after previous session and before current session - return eventTime > previousSessionEndTime && eventTime < currentSessionStartTime; - }) - .filter(event => { - if (event.event === EventType.Commented) { - const comment = event as CommentEvent; - return comment.body.includes('@copilot') || comment.body.includes(this.handler); - } else if (event.event === EventType.Reviewed) { - const review = event as ReviewEvent; - return review.body.includes('@copilot') || review.body.includes(this.handler); - } - return false; - }) - .sort((a, b) => { - const timeA = new Date( - a.event === EventType.Commented ? (a as CommentEvent).createdAt : - a.event === EventType.Reviewed ? (a as ReviewEvent).submittedAt : '' - ).getTime(); - const timeB = new Date( - b.event === EventType.Commented ? (b as CommentEvent).createdAt : - b.event === EventType.Reviewed ? (b as ReviewEvent).submittedAt : '' - ).getTime(); - return timeB - timeA; // Most recent first (closest to session start) - }); - } - - private extractPromptFromEvent(event: TimelineEvent): string { - let body = ''; - if (event.event === EventType.Commented) { - body = (event as CommentEvent).body; - } else if (event.event === EventType.Reviewed) { - body = (event as ReviewEvent).body; - } - - // Extract the prompt before any separator pattern (used in addFollowUpToExistingPR) - // but keep the @copilot mention - const separatorMatch = body.match(/^(.*?)\s*\n\n\s*---\s*\n\n/s); - if (separatorMatch) { - return separatorMatch[1].trim(); - } - - return body.trim(); - } - - private async getInitialSessionPrompt( - session: SessionInfo, - pullRequest: PullRequestModel, - capi: CopilotApi, - defaultPrompt: string - ): Promise { - try { - const jobInfo = await capi.getJobBySessionId( - pullRequest.base.repositoryCloneUrl.owner, - pullRequest.base.repositoryCloneUrl.repositoryName, - session.id - ); - if (jobInfo && jobInfo.problem_statement) { - let prompt = jobInfo.problem_statement; - const titleMatch = jobInfo.problem_statement.match(/TITLE: \s*(.*)/i); - if (titleMatch && titleMatch[1]) { - prompt = titleMatch[1].trim(); - } else { - const split = jobInfo.problem_statement.split('\n'); - if (split.length > 0) { - prompt = split[0].trim(); - } - } - Logger.appendLine(`Session 0: Found problem_statement from Jobs API: ${prompt}`, this.loggerId); - return prompt; - } - } catch (error) { - Logger.warn(`Failed to get job info for session ${session.id}: ${error}`, this.loggerId); - } - return defaultPrompt; - } - - private async parseSessionLogsIntoResponseTurn(pullRequest: PullRequestModel, logs: string, session: SessionInfo): Promise { - try { - const logChunks = parseSessionLogs(logs); - const responseParts: Array = []; - let currentResponseContent = ''; - - for (const chunk of logChunks) { - if (!chunk.choices || !Array.isArray(chunk.choices)) { - continue; - } - - for (const choice of chunk.choices) { - const delta = choice.delta; - if (delta.role === 'assistant') { - this.processAssistantDelta(delta, choice, pullRequest, responseParts, currentResponseContent); - } - - } - } - - if (currentResponseContent.trim()) { - responseParts.push(new vscode.ChatResponseMarkdownPart(currentResponseContent.trim())); - } - - if (session.state === 'completed' || session.state === 'failed' /** session can fail with proposed changes */) { - const fileChangesPart = await this.getFileChangesMultiDiffPart(pullRequest); - if (fileChangesPart) { - responseParts.push(fileChangesPart); - } - } - - if (responseParts.length > 0) { - const responseResult: vscode.ChatResult = {}; - return new vscode.ChatResponseTurn2(responseParts, responseResult, COPILOT_SWE_AGENT); - } - - return undefined; - } catch (error) { - Logger.error(`Failed to parse session logs into response turn: ${error}`, this.loggerId); - return undefined; - } - } - - private processAssistantDelta( - delta: AssistantDelta, - choice: Choice, - pullRequest: PullRequestModel, - responseParts: Array, - currentResponseContent: string, - ): string { - if (delta.role === 'assistant') { - // Handle special case for run_custom_setup_step - if ( - choice.finish_reason === 'tool_calls' && - delta.tool_calls?.length && - (delta.tool_calls[0].function.name === 'run_custom_setup_step' || delta.tool_calls[0].function.name === 'run_setup') - ) { - const toolCall = delta.tool_calls[0]; - let args: { name?: string } = {}; - try { - args = JSON.parse(toolCall.function.arguments); - } catch { - // fallback to empty args - } - - // Ignore if delta.content is empty/undefined (running state) - if (delta.content && delta.content.trim()) { - // Add any accumulated content as markdown first - if (currentResponseContent.trim()) { - responseParts.push(new vscode.ChatResponseMarkdownPart(currentResponseContent.trim())); - currentResponseContent = ''; - } - - const toolPart = this.createToolInvocationPart(pullRequest, toolCall, args.name || delta.content); - if (toolPart) { - responseParts.push(toolPart); - } - } - // Skip if content is empty (running state) - } else { - if (delta.content) { - if (!delta.content.startsWith('') && !delta.content.startsWith('')) { - currentResponseContent += delta.content; - } - } - - const isError = delta.content?.startsWith(''); - if (delta.tool_calls) { - // Add any accumulated content as markdown first - if (currentResponseContent.trim()) { - responseParts.push(new vscode.ChatResponseMarkdownPart(currentResponseContent.trim())); - currentResponseContent = ''; - } - - for (const toolCall of delta.tool_calls) { - const toolPart = this.createToolInvocationPart(pullRequest, toolCall, delta.content || ''); - if (toolPart) { - responseParts.push(toolPart); - } - } - - if (isError) { - const toolPart = new vscode.ChatToolInvocationPart('Command', 'command'); - // Remove at the start and at the end - const cleaned = (delta.content ?? '').replace(/^\s*\s*/i, '').replace(/\s*<\/error>\s*$/i, ''); - toolPart.invocationMessage = cleaned; - toolPart.isError = true; - responseParts.push(toolPart); - } - } - } - } - return currentResponseContent; - } - - private createToolInvocationPart(pullRequest: PullRequestModel, toolCall: ToolCall, deltaContent: string = ''): vscode.ChatToolInvocationPart | vscode.ChatResponseThinkingProgressPart | undefined { - if (!toolCall.function?.name || !toolCall.id) { - return undefined; - } - - // Hide reply_to_comment tool - if (toolCall.function.name === 'reply_to_comment') { - return undefined; - } - - const toolPart = new vscode.ChatToolInvocationPart(toolCall.function.name, toolCall.id); - toolPart.isComplete = true; - toolPart.isError = false; - toolPart.isConfirmed = true; - - try { - const toolDetails = parseToolCallDetails(toolCall, deltaContent); - toolPart.toolName = toolDetails.toolName; - - if (toolPart.toolName === 'think') { - return new vscode.ChatResponseThinkingProgressPart(toolDetails.invocationMessage); - } - - if (toolCall.function.name === 'bash') { - toolPart.invocationMessage = new vscode.MarkdownString(`\`\`\`bash\n${toolDetails.invocationMessage}\n\`\`\``); - } else { - toolPart.invocationMessage = new vscode.MarkdownString(toolDetails.invocationMessage); - } - - if (toolDetails.pastTenseMessage) { - toolPart.pastTenseMessage = new vscode.MarkdownString(toolDetails.pastTenseMessage); - } - if (toolDetails.originMessage) { - toolPart.originMessage = new vscode.MarkdownString(toolDetails.originMessage); - } - if (toolDetails.toolSpecificData) { - if (StrReplaceEditorToolData.is(toolDetails.toolSpecificData)) { - if ((toolDetails.toolSpecificData.command === 'view' || toolDetails.toolSpecificData.command === 'edit') && toolDetails.toolSpecificData.fileLabel) { - const uri = vscode.Uri.file(nodePath.join(pullRequest.githubRepository.rootUri.fsPath, toolDetails.toolSpecificData.fileLabel)); - toolPart.invocationMessage = new vscode.MarkdownString(`${toolPart.toolName} [](${uri.toString()})` + (toolDetails.toolSpecificData?.viewRange ? `, lines ${toolDetails.toolSpecificData.viewRange?.start} to ${toolDetails.toolSpecificData.viewRange?.end}` : '')); - toolPart.invocationMessage.supportHtml = true; - toolPart.pastTenseMessage = new vscode.MarkdownString(`${toolPart.toolName} [](${uri.toString()})` + (toolDetails.toolSpecificData?.viewRange ? `, lines ${toolDetails.toolSpecificData.viewRange?.start} to ${toolDetails.toolSpecificData.viewRange?.end}` : '')); - } - } else { - toolPart.toolSpecificData = toolDetails.toolSpecificData; - } - } - } catch (error) { - toolPart.toolName = toolCall.function.name || 'unknown'; - toolPart.invocationMessage = new vscode.MarkdownString(`Tool: ${toolCall.function.name}`); - toolPart.isError = true; - } - - return toolPart; - } - - private async getFileChangesMultiDiffPart(pullRequest: PullRequestModel): Promise { - try { - const changeModels = await this.getChangeModels; - - if (changeModels.length === 0) { - return undefined; - } - - const diffEntries: vscode.ChatResponseDiffEntry[] = []; - for (const changeModel of changeModels) { - const { added, removed } = await changeModel.calculateChangedLinesCount(); - diffEntries.push({ - originalUri: changeModel.parentFilePath, - modifiedUri: changeModel.filePath, - goToFileUri: changeModel.filePath, - added, - removed, - }); - } - - const title = `Changes in Pull Request #${pullRequest.number}`; - return new vscode.ChatResponseMultiDiffPart(diffEntries, title); - } catch (error) { - Logger.error(`Failed to get file changes multi diff part: ${error}`, this.loggerId); - return undefined; - } - } -} \ No newline at end of file diff --git a/src/github/copilotRemoteAgent/gitOperationsManager.ts b/src/github/copilotRemoteAgent/gitOperationsManager.ts deleted file mode 100644 index dd9d49640d..0000000000 --- a/src/github/copilotRemoteAgent/gitOperationsManager.ts +++ /dev/null @@ -1,193 +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 { adjectives, animals, colors, NumberDictionary, uniqueNamesGenerator } from '@joaomoreno/unique-names-generator'; -import vscode from 'vscode'; -import { Repository } from '../../api/api'; -import Logger from '../../common/logger'; -import { BRANCH_RANDOM_NAME_DICTIONARY, BRANCH_WHITESPACE_CHAR, GIT } from '../../common/settingKeys'; -import { RepoInfo } from '../common'; - -export class GitOperationsManager { - constructor(private loggerID: string) { } - - async commitAndPushChanges(repoInfo: RepoInfo) { - const { repository, remote, baseRef } = repoInfo; - const asyncBranch = await this.generateRandomBranchName(repository, 'copilot'); - - try { - await repository.createBranch(asyncBranch, true); - const commitMessage = 'Checkpoint from VS Code for coding agent session'; - - await this.performCommit(asyncBranch, repository, commitMessage); - await repository.push(remote.remoteName, asyncBranch, true); - this.showBranchSwitchNotification(repository, baseRef, asyncBranch); - return asyncBranch; // This is the new head ref - } catch (error) { - await this.rollbackToOriginalBranch(repository, baseRef); - Logger.error(`Failed to auto-commit and push pending changes: ${error}`, this.loggerID); - throw new Error(vscode.l10n.t('Could not auto-push pending changes. Manually commit or stash your changes and try again. ({0})', error.message)); - } - } - - private async performCommit(asyncBranch: string, repository: Repository, commitMessage: string): Promise { - try { - await repository.commit(commitMessage, { all: true }); - - if (repository.state.HEAD?.name !== asyncBranch || repository.state.workingTreeChanges.length > 0 || repository.state.indexChanges.length > 0) { - throw new Error(vscode.l10n.t('Uncommitted changes still detected.')); - } - } catch (error) { - // Fallback to interactive commit - const commitSuccessful = await this.handleInteractiveCommit(repository); - if (!commitSuccessful) { - throw new Error(vscode.l10n.t('Exclude your uncommitted changes and try again.')); - } - } - } - - private async handleInteractiveCommit(repository: Repository): Promise { - const COMMIT_YOUR_CHANGES = vscode.l10n.t('Commit your changes to continue coding agent session. Close integrated terminal to cancel.'); - - return vscode.window.withProgress({ - title: COMMIT_YOUR_CHANGES, - cancellable: true, - location: vscode.ProgressLocation.Notification - }, async (progress, token) => { - return new Promise((resolve) => { - const startingCommit = repository.state.HEAD?.commit; - const terminal = vscode.window.createTerminal({ - name: 'GitHub Coding Agent', - cwd: repository.rootUri.fsPath, - message: `\x1b[1m${COMMIT_YOUR_CHANGES}\x1b[0m` - }); - - terminal.show(); - - let disposed = false; - let timeoutId: NodeJS.Timeout; - let stateListener: vscode.Disposable | undefined; - let disposalListener: vscode.Disposable | undefined; - let cancellationListener: vscode.Disposable | undefined; - const cleanup = () => { - if (disposed) return; - disposed = true; - clearTimeout(timeoutId); - stateListener?.dispose(); - disposalListener?.dispose(); - cancellationListener?.dispose(); - terminal.dispose(); - }; - // Listen for cancellation if token is provided - if (token) { - cancellationListener = token.onCancellationRequested(() => { - cleanup(); - resolve(false); - }); - } - - // Listen for repository state changes - stateListener = repository.state.onDidChange(() => { - // Check if commit was successful (HEAD changed and no more staged changes) - if (repository.state.HEAD?.commit !== startingCommit) { - cleanup(); - resolve(true); - } - }); - // Set a timeout to avoid waiting forever - timeoutId = setTimeout(() => { - cleanup(); - resolve(false); - }, 5 * 60 * 1000); // 5 minutes timeout - // Listen for terminal disposal (user closed it) - disposalListener = vscode.window.onDidCloseTerminal((closedTerminal) => { - if (closedTerminal === terminal) { - setTimeout(() => { - if (!disposed) { - cleanup(); - // Check one more time if commit happened just before terminal was closed - resolve(repository.state.HEAD?.commit !== startingCommit); - } - }, 1000); - } - }); - }); - }); - } - - private showBranchSwitchNotification(repository: Repository, baseRef: string, newRef: string): void { - if (repository.state.HEAD?.name !== baseRef) { - const SWAP_BACK_TO_ORIGINAL_BRANCH = vscode.l10n.t(`Swap back to '{0}'`, baseRef); - vscode.window.showInformationMessage( - vscode.l10n.t(`Pending changes pushed to remote branch '{0}'.`, newRef), - SWAP_BACK_TO_ORIGINAL_BRANCH, - ).then(async (selection) => { - if (selection === SWAP_BACK_TO_ORIGINAL_BRANCH) { - await repository.checkout(baseRef); - } - }); - } - } - - private async rollbackToOriginalBranch(repository: Repository, baseRef: string): Promise { - if (repository.state.HEAD?.name !== baseRef) { - try { - await repository.checkout(baseRef); - } catch (checkoutError) { - Logger.error(`Failed to checkout back to original branch '${baseRef}': ${checkoutError}`, this.loggerID); - } - } - } - - // Adapted from https://github.com/microsoft/vscode/blob/e35e3b4e057450ea3d90c724fae5e3e9619b96fe/extensions/git/src/commands.ts#L3007 - private async generateRandomBranchName(repository: Repository, prefix: string): Promise { - const config = vscode.workspace.getConfiguration(GIT); - const branchWhitespaceChar = config.get(BRANCH_WHITESPACE_CHAR); - const branchRandomNameDictionary = config.get(BRANCH_RANDOM_NAME_DICTIONARY); - - // Default to legacy behaviour if config mismatches core - if (branchWhitespaceChar === undefined || branchRandomNameDictionary === undefined) { - return `copilot/vscode${Date.now()}`; - } - - const separator = branchWhitespaceChar; - const dictionaries: string[][] = []; - for (const dictionary of branchRandomNameDictionary) { - if (dictionary.toLowerCase() === 'adjectives') { - dictionaries.push(adjectives); - } - if (dictionary.toLowerCase() === 'animals') { - dictionaries.push(animals); - } - if (dictionary.toLowerCase() === 'colors') { - dictionaries.push(colors); - } - if (dictionary.toLowerCase() === 'numbers') { - dictionaries.push(NumberDictionary.generate({ length: 3 })); - } - } - - if (dictionaries.length === 0) { - return ''; - } - - // 5 attempts to generate a random branch name - for (let index = 0; index < 5; index++) { - const randomName = `${prefix}/${uniqueNamesGenerator({ - dictionaries, - length: dictionaries.length, - separator - })}`; - - // Check for local ref conflict - const refs = await repository.getRefs?.({ pattern: `refs/heads/${randomName}` }); - if (!refs || refs.length === 0) { - return randomName; - } - } - - return ''; - } -} diff --git a/src/github/copilotRemoteAgentUtils.ts b/src/github/copilotRemoteAgentUtils.ts deleted file mode 100644 index 306efb2449..0000000000 --- a/src/github/copilotRemoteAgentUtils.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 * as vscode from 'vscode'; -import { MAX_PROBLEM_STATEMENT_LENGTH } from './copilotApi'; -import Logger from '../common/logger'; - -/** - * Truncation utility to ensure the problem statement sent to Copilot API is under the maximum length. - * Truncation is not ideal. The caller providing the prompt/context should be summarizing so this is a no-op whenever possible. - * - * @param prompt The final message submitted by the user - * @param context Any additional context collected by the caller (chat history, open files, etc...) - * @returns A complete 'problem statement' string that is under the maximum length, and a flag indicating if truncation occurred - */ -export function truncatePrompt(prompt: string, context?: string): { problemStatement: string; isTruncated: boolean } { - // Prioritize the userPrompt - // Take the last n characters that fit within the limit - if (prompt.length >= MAX_PROBLEM_STATEMENT_LENGTH) { - Logger.warn(`Truncation: Prompt length ${prompt.length} exceeds max of ${MAX_PROBLEM_STATEMENT_LENGTH}`); - prompt = prompt.slice(-MAX_PROBLEM_STATEMENT_LENGTH); - return { problemStatement: prompt, isTruncated: true }; - } - - if (context && (prompt.length + context.length >= MAX_PROBLEM_STATEMENT_LENGTH)) { - const availableLength = MAX_PROBLEM_STATEMENT_LENGTH - prompt.length - 2 /* new lines */; - Logger.warn(`Truncation: Combined prompt and context length ${prompt.length + context.length} exceeds max of ${MAX_PROBLEM_STATEMENT_LENGTH}`); - context = context.slice(-availableLength); - return { - problemStatement: prompt + (context ? `\n\n${context}` : ''), - isTruncated: true - }; - } - - // No truncation occurred - return { - problemStatement: prompt + (context ? `\n\n${context}` : ''), - isTruncated: false - }; -} - -export function extractTitle(prompt: string, context: string | undefined): string | undefined { - const fromTitle = () => { - if (!prompt) { - return; - } - if (prompt.length <= 20) { - return prompt; - } - return prompt.substring(0, 20) + '...'; - }; - const titleMatch = context?.match(/TITLE: \s*(.*)/i); - if (titleMatch && titleMatch[1]) { - return titleMatch[1].trim(); - } - return fromTitle(); - -} - -export function formatBodyPlaceholder(title: string | undefined): string { - return vscode.l10n.t('Coding agent has begun work on **{0}** and will update this pull request as work progresses.', title || vscode.l10n.t('your request')); -} \ No newline at end of file diff --git a/src/github/createPRViewProvider.ts b/src/github/createPRViewProvider.ts index bb29a5b147..5281bbe673 100644 --- a/src/github/createPRViewProvider.ts +++ b/src/github/createPRViewProvider.ts @@ -10,17 +10,18 @@ import { PullRequestDefaults, titleAndBodyFrom, } from './folderRepositoryManager'; -import { GitHubRepository } from './githubRepository'; +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 { getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, getProjectFromQuickPick, reviewersQuickPick } from './quickPicks'; -import { getIssueNumberLabelFromParsed, ISSUE_EXPRESSION, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput, variableSubstitution } from './utils'; -import { DisplayLabel, PreReviewState } from './views'; +import { 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, Ref } from '../api/api'; +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'; @@ -38,8 +39,9 @@ import { } from '../common/settingKeys'; import { ITelemetry } from '../common/telemetry'; import { asPromise, compareIgnoreCase, formatError, promiseWithTimeout } from '../common/utils'; -import { getNonce, IRequestMessage, WebviewViewBase } from '../common/webview'; -import { PREVIOUS_CREATE_METHOD } from '../extensionState'; +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 @@ -127,11 +129,35 @@ export abstract class BaseCreatePullRequestViewProvider; - protected async getMergeConfiguration(owner: string, name: string, refetch: boolean = false): Promise { + 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) { @@ -163,6 +189,10 @@ export abstract class BaseCreatePullRequestViewProvider; + // 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); } @@ -188,17 +218,54 @@ export abstract class BaseCreatePullRequestViewProvider(DEFAULT_CREATE_OPTION, 'lastUsed'); const lastCreateMethod: { autoMerge: boolean, mergeMethod: MergeMethod | undefined, isDraft: boolean } | undefined = this._folderRepositoryManager.context.workspaceState.get<{ autoMerge: boolean, mergeMethod: MergeMethod, isDraft } | undefined>(PREVIOUS_CREATE_METHOD, undefined); - const repoMergeMethod = getDefaultMergeMethod(mergeConfiguration.mergeMethodsAvailability); + const repoMergeMethod = mergeConfiguration ? getDefaultMergeMethod(mergeConfiguration.mergeMethodsAvailability) : 'merge' as MergeMethod; // default values are for 'create' let defaultMergeMethod: MergeMethod = repoMergeMethod; @@ -209,15 +276,17 @@ export abstract class BaseCreatePullRequestViewProvider(PULL_REQUEST_DESCRIPTION) === 'Copilot'); + 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__ @@ -242,8 +311,8 @@ export abstract class BaseCreatePullRequestViewProvider { + 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) { @@ -538,6 +612,9 @@ export abstract class BaseCreatePullRequestViewProvider repo.remote.owner === this.model.compareOwner)?.remote.remoteName; - const branchRemoteChanged = compareBranch && (compareBranch.upstream?.remote !== currentCompareRemote); - if (branchChanged || branchRemoteChanged) { - this._defaultCompareBranch = compareBranch!.name!; - this.model.setCompareBranch(compareBranch!.name); - this.changeBranch(compareBranch!.name!, false).then(async titleAndDescription => { - const params: Partial = { - defaultTitle: titleAndDescription.title, - defaultDescription: titleAndDescription.description, - compareBranch: compareBranch?.name, - defaultCompareBranch: compareBranch?.name, - warning: await this.existingPRMessage(), - }; - if (!branchRemoteChanged) { - return this._postMessage({ - command: 'pr.initialize', - params, - }); - } - }); + 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 { @@ -687,8 +761,22 @@ export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProv protected async getTitleAndDescription(compareBranch: Branch, baseBranch: string): Promise<{ title: string, description: string }> { let title: string = ''; let description: string = ''; - const descrptionSource = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'commit' | 'template' | 'none' | 'Copilot'>(PULL_REQUEST_DESCRIPTION); - if (descrptionSource === 'none') { + 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 }; } @@ -700,19 +788,30 @@ export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProv let useBranchName = this._pullRequestDefaults.base === compareBranch.name; Logger.debug(`Compare branch name: ${compareBranch.name}, Base branch name: ${this._pullRequestDefaults.base}`, CreatePullRequestViewProvider.ID); try { - const name = compareBranch.name; const [totalCommits, lastCommit, pullRequestTemplate] = await Promise.all([ this.getTotalGitHubCommits(compareBranch, baseBranch), name ? titleAndBodyFrom(promiseWithTimeout(this._folderRepositoryManager.getTipCommitMessage(name), 5000)) : undefined, - descrptionSource === 'template' ? this.getPullRequestTemplate() : undefined + descriptionSource === 'template' ? this.getPullRequestTemplate() : undefined ]); - const totalNonMergeCommits = totalCommits?.filter(commit => commit.parents.length < 2); + 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: ${totalNonMergeCommits?.length}`, CreatePullRequestViewProvider.ID); - if (totalNonMergeCommits === undefined) { - // There is no upstream branch. Use the last commit as the title and description. + 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 (totalNonMergeCommits && totalNonMergeCommits.length > 1) { + } else if (totalNonMergeCommitsCount > 1) { const defaultBranch = await origin.getDefaultBranch(); useBranchName = defaultBranch !== compareBranch.name; } @@ -726,17 +825,18 @@ export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProv } // Set title if (useBranchName && name) { - title = `${name.charAt(0).toUpperCase()}${name.slice(1)}`; + title = branchNameTitle(name); } else if (name && lastCommit) { title = lastCommit.title; } // Set description - if (pullRequestTemplate && lastCommit?.body) { + // 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 && (this._pullRequestDefaults.base !== compareBranch.name)) { + } else if (lastCommit?.body && !useBranchName && (this._pullRequestDefaults.base !== compareBranch.name)) { description = lastCommit.body; } @@ -765,6 +865,140 @@ export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProv 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; @@ -795,13 +1029,29 @@ export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProv } } + 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(); - this.model.baseOwner = params.defaultBaseRemote!.owner; - this.model.baseBranch = params.defaultBaseBranch!; + // 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); @@ -817,42 +1067,19 @@ export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProv }); } - private async branchPicks(githubRepository: GitHubRepository, changeRepoMessage: string, isBase: boolean): 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); - } else { - // For the compare, we only want to show local branches. - branches = (await this._folderRepositoryManager.repository.getBranches({ remote: false })).filter(branch => branch.name); - } - // TODO: @alexr00 - Add sorting so that the most likely to be used branch (ex main or release if base) is at the top of the list. - const branchPicks: (vscode.QuickPickItem & { remote?: RemoteInfo, branch?: string })[] = branches.map(branch => { - const branchName = typeof branch === 'string' ? branch : branch.name!; - const pick: (vscode.QuickPickItem & { remote: RemoteInfo, branch: string }) = { - iconPath: new vscode.ThemeIcon('git-branch'), - label: branchName, - remote: { - owner: githubRepository.remote.owner, - repositoryName: githubRepository.remote.repositoryName - }, - branch: branchName - }; - return pick; - }); - branchPicks.unshift({ - kind: vscode.QuickPickItemKind.Separator, - label: `${githubRepository.remote.owner}/${githubRepository.remote.repositoryName}` - }); - branchPicks.unshift({ - iconPath: new vscode.ThemeIcon('repo'), - label: changeRepoMessage - }); - return branchPicks; - } - private async processRemoteAndBranchResult(githubRepository: GitHubRepository, result: { remote: RemoteInfo, branch: string }, isBase: boolean) { - const [defaultBranch, viewerPermission] = await Promise.all([githubRepository.getDefaultBranch(), githubRepository.getViewerPermission()]); + 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; @@ -861,13 +1088,17 @@ export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProv 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) { + 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'); @@ -877,10 +1108,10 @@ export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProv baseRemote: result.remote, baseBranch: result.branch, defaultBaseBranch: defaultBranch, - defaultMergeMethod: getDefaultMergeMethod(mergeConfiguration.mergeMethodsAvailability), - allowAutoMerge: mergeConfiguration.viewerCanAutoMerge, + defaultMergeMethod: mergeConfiguration ? getDefaultMergeMethod(mergeConfiguration.mergeMethodsAvailability) : 'merge' as MergeMethod, + allowAutoMerge: mergeConfiguration?.viewerCanAutoMerge ?? false, baseHasMergeQueue: !!mergeQueueMethodForBranch, - mergeMethodsAvailability: mergeConfiguration.mergeMethodsAvailability, + mergeMethodsAvailability: mergeConfiguration?.mergeMethodsAvailability ?? { merge: true, squash: true, rebase: true }, autoMergeDefault, defaultTitle: titleAndDescription.title, defaultDescription: titleAndDescription.description @@ -924,19 +1155,46 @@ export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProv 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; - quickPick.items = githubRepository ? await this.branchPicks(githubRepository, chooseDifferentRemote, isBase) : 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; + 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 () => { - if (quickPick.selectedItems.length === 0) { + const selectedPick = quickPick.selectedItems[0] ?? quickPick.activeItems[0]; + if (!selectedPick) { return; } - const selectedPick = quickPick.selectedItems[0]; if (selectedPick.label === chooseDifferentRemote) { quickPick.busy = true; quickPick.items = await this.remotePicks(isBase); @@ -946,7 +1204,8 @@ export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProv 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)!; - quickPick.items = await this.branchPicks(githubRepository, chooseDifferentRemote, isBase); + await updateItems(githubRepository, undefined); + addValueChangeListener(); quickPick.placeholder = branchPlaceholder; quickPick.busy = false; } else if (selectedPick.branch && selectedPick.remote) { @@ -956,10 +1215,19 @@ export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProv }); }); 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; } @@ -968,72 +1236,25 @@ export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProv quickPick.hide(); quickPick.dispose(); + onDidChangeValueDisposable?.dispose(); return this._replyMessage(message, chooseResult); } - private async findIssueContext(commits: string[]): Promise<{ content: string, reference: string }[] | undefined> { - const issues: Promise<{ content: string, reference: string } | undefined>[] = []; - for (const commit of commits) { - const tryParse = parseIssueExpressionOutput(commit.match(ISSUE_OR_URL_EXPRESSION)); - if (tryParse) { - const owner = tryParse.owner ?? this.model.baseOwner; - const name = tryParse.name ?? this.model.repositoryName; - issues.push(new Promise(resolve => { - this._folderRepositoryManager.resolveIssue(owner, name, tryParse.issueNumber).then(issue => { - if (issue) { - resolve({ content: `${issue.title}\n${issue.body}`, reference: getIssueNumberLabelFromParsed(tryParse) }); - } else { - resolve(undefined); - } - }).catch(() => resolve(undefined)); - })); - } - } - if (issues.length) { - return (await Promise.all(issues)).filter(issue => !!issue) as { content: string, reference: string }[]; - } - return undefined; - } - private async getCommitsAndPatches(): Promise<{ commitMessages: string[], patches: { patch: string, fileUri: string, previousFileUri?: string }[] }> { - let commitMessages: string[]; - let patches: ({ patch: string, fileUri: string, previousFileUri?: string } | undefined)[] | undefined; - if (await this.model.getCompareHasUpstream()) { - [commitMessages, patches] = await Promise.all([ - this.model.gitHubCommits().then(rawCommits => rawCommits.map(commit => commit.commit.message)), - this.model.gitHubFiles().then(rawPatches => rawPatches?.map(file => { - if (!file.patch) { - return; - } - const fileUri = vscode.Uri.joinPath(this._folderRepositoryManager.repository.rootUri, file.filename).toString(); - const previousFileUri = file.previous_filename ? vscode.Uri.joinPath(this._folderRepositoryManager.repository.rootUri, file.previous_filename).toString() : undefined; - return { patch: file.patch, fileUri, previousFileUri }; - }))]); - } else { - [commitMessages, patches] = await Promise.all([ - this.model.gitCommits().then(rawCommits => rawCommits.filter(commit => commit.parents.length === 1).map(commit => commit.message)), - Promise.all((await this.model.gitFiles()).map(async (file) => { - return { - patch: await this._folderRepositoryManager.repository.diffBetween(this.model.baseBranch, this.model.compareBranch, file.uri.fsPath), - fileUri: file.uri.toString(), - }; - }))]); - } - const filteredPatches: { patch: string, fileUri: string, previousFileUri?: string }[] = - patches?.filter<{ patch: string, fileUri: string, previousFileUri?: string }>((patch): patch is { patch: string, fileUri: string, previousFileUri?: string } => !!patch) ?? []; - return { commitMessages, patches: filteredPatches }; + 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.findIssueContext(commitMessages); + 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 }, token); - + const result = await provider?.provider.provideTitleAndDescription({ commitMessages, patches, issues, template, compareBranch: this.model.compareBranch }, token); if (provider) { this.lastGeneratedTitleAndDescription = { ...result, providerTitle: provider.title }; /* __GDPR__ @@ -1203,7 +1424,42 @@ export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProv const compareRepositoryName = message.args.compareRepo; const compareBranchName = message.args.compareBranch; const compareGithubRemoteName = `${compareOwner}/${compareRepositoryName}`; - const compareBranch = await this._folderRepositoryManager.repository.getBranch(compareBranchName); + let compareBranch = await this._folderRepositoryManager.repository.getBranch(compareBranchName); + + // Fetch upstream to get accurate ahead/behind count + if (compareBranch.upstream) { + await this._folderRepositoryManager.repository.fetch(compareBranch.upstream.remote, compareBranch.upstream.name); + // Re-fetch branch info after fetch to get accurate ahead count + compareBranch = await this._folderRepositoryManager.repository.getBranch(compareBranchName); + } + + // Check for unpushed commits when there's an upstream + if (compareBranch.upstream && compareBranch.ahead && compareBranch.ahead > 0) { + const pushCommits = vscode.l10n.t('Push Commits'); + const continueWithoutPushing = vscode.l10n.t('Continue Without Pushing'); + const commitCount = compareBranch.ahead; + const messageResult = await vscode.window.showInformationMessage( + vscode.l10n.t({ + message: 'You have {0} unpushed commit(s) on \'{1}\'.\n\nDo you want to push them before creating the pull request?', + comment: ['{0} is the number of commits, {1} is the branch name'], + args: [commitCount, compareBranchName] + }), + { modal: true }, + pushCommits, + continueWithoutPushing + ); + if (messageResult === pushCommits) { + progress.report({ message: vscode.l10n.t('Pushing commits'), increment: 10 }); + totalIncrement += 10; + await this._folderRepositoryManager.repository.push(compareBranch.upstream.remote, compareBranchName); + } else if (messageResult !== continueWithoutPushing) { + // User cancelled (clicked X or pressed Escape) + progress.report({ message: vscode.l10n.t('Pull request cancelled'), increment: 100 - totalIncrement }); + return; + } + // If continueWithoutPushing was selected, just continue with PR creation + } + let headRepo = compareBranch.upstream ? this._folderRepositoryManager.findRepo((githubRepo) => { return (githubRepo.remote.owner === compareOwner) && (githubRepo.remote.repositoryName === compareRepositoryName); }) : undefined; @@ -1262,6 +1518,8 @@ export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProv 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) { @@ -1334,6 +1592,9 @@ export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProv case 'pr.cancelGenerateTitleAndDescription': return this.cancelGenerateTitleAndDescription(); + case 'pr.changeTemplate': + return this.changeTemplate(message); + case 'pr.preReview': return this.preReview(message); @@ -1345,4 +1606,4 @@ export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProv vscode.window.showErrorMessage('Unsupported webview message'); } } -} \ No newline at end of file +} diff --git a/src/github/credentials.ts b/src/github/credentials.ts index ce2f62b102..b633428403 100644 --- a/src/github/credentials.ts +++ b/src/github/credentials.ts @@ -61,6 +61,12 @@ export class CredentialStore extends Disposable { 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; @@ -130,6 +136,32 @@ export class CredentialStore extends Disposable { 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)) { @@ -139,6 +171,11 @@ export class CredentialStore extends Disposable { } } + const envResult = await this.tryInitializeFromEnvironmentToken(authProviderId); + if (envResult) { + return envResult; + } + if (getAuthSessionOptions.createIfNone === undefined && getAuthSessionOptions.forceNewSession === undefined) { getAuthSessionOptions.createIfNone = false; } @@ -254,6 +291,51 @@ export class CredentialStore extends Disposable { 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; this._githubEnterpriseAPI = undefined; @@ -530,7 +612,9 @@ export class CredentialStore extends Disposable { }, }); - const rateLogger = new RateLogger(this._telemetry, isEnterprise(authProviderId)); + const rateLogger = new RateLogger(this._telemetry, isEnterprise(authProviderId), (_e) => { + void this.handleAuthError(authProviderId); + }); const github: GitHub = { octokit: new LoggingOctokit(octokit, rateLogger), graphql: new LoggingApolloClient(graphql, rateLogger), 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 9ac0cd2832..b93563ce4c 100644 --- a/src/github/folderRepositoryManager.ts +++ b/src/github/folderRepositoryManager.ts @@ -11,8 +11,8 @@ import { ConflictModel } from './conflictGuide'; import { ConflictResolutionCoordinator } from './conflictResolutionCoordinator'; import { Conflict, ConflictResolutionModel } from './conflictResolutionModel'; import { CredentialStore } from './credentials'; -import { CopilotWorkingStatus, GitHubRepository, ItemsData, PULL_REQUEST_PAGE_SIZE, PullRequestChangeEvent, PullRequestData, TeamReviewerRefreshKind, ViewerPermission } from './githubRepository'; -import { PullRequestResponse, PullRequestState } from './graphql'; +import { 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'; @@ -22,8 +22,8 @@ import { convertRESTPullRequestToRawPullRequest, getOverrideBranch, getPRFetchQuery, + getStateFromQuery, loginComparator, - parseGraphQLPullRequest, teamComparator, variableSubstitution, } from './utils'; @@ -34,19 +34,26 @@ 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 { Protocol, ProtocolType } from '../common/protocol'; -import { GitHubRemote, parseRemote, parseRepositoryRemotes, Remote } from '../common/remote'; +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'; @@ -54,7 +61,7 @@ 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 { LAST_USED_EMAIL, NEVER_SHOW_PULL_NOTIFICATION, REPO_KEYS, ReposState } from '../extensionState'; +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'; @@ -220,6 +227,7 @@ export class FolderRepositoryManager extends Disposable { readonly onDidDispose: vscode.Event = this._onDidDispose.event; private _sessionIgnoredRemoteNames: Set = new Set(); + private _inaccessibleRepos: Set = new Set(); constructor( private readonly _id: number, @@ -233,7 +241,7 @@ export class FolderRepositoryManager extends Disposable { ) { super(); this._githubRepositories = []; - this._githubManager = new GitHubManager(); + this._githubManager = new GitHubManager(this.telemetry); this._register( vscode.workspace.onDidChangeConfiguration(async e => { @@ -276,7 +284,7 @@ export class FolderRepositoryManager extends Disposable { } public async computeAllUnknownRemotes(): Promise { - const remotes = parseRepositoryRemotes(this.repository); + 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()!)), @@ -297,7 +305,7 @@ export class FolderRepositoryManager extends Disposable { } public async computeAllGitHubRemotes(): Promise { - const remotes = parseRepositoryRemotes(this.repository); + 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()!)), @@ -489,8 +497,10 @@ export class FolderRepositoryManager extends Disposable { const activeRemotes = await this.getActiveRemotes(); const isAuthenticated = this.checkForAuthMatch(activeRemotes); if (this.credentialStore.isAnyAuthenticated() && (activeRemotes.length === 0)) { - const areAllNeverGitHub = (await this.computeAllUnknownRemotes()).every(remote => GitHubManager.isNeverGitHub(vscode.Uri.parse(remote.normalizedHost).authority)); - if (areAllNeverGitHub) { + const allUnknownRemotes = await this.computeAllUnknownRemotes(); + const areAllNeverGitHub = allUnknownRemotes.every(remote => GitHubManager.isNeverGitHub(vscode.Uri.parse(remote.normalizedHost).authority)); + if ((allUnknownRemotes.length > 0) && areAllNeverGitHub) { + Logger.appendLine('No GitHub remotes found and all remotes are marked as never GitHub.', this.id); this.state = ReposManagerState.RepositoriesLoaded; return true; } @@ -574,6 +584,11 @@ export class FolderRepositoryManager extends Disposable { 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; } @@ -731,7 +746,7 @@ export class FolderRepositoryManager extends Disposable { return globalStateMentionableUsers ?? this._fetchMentionableUsersPromise; } - return this._fetchMentionableUsersPromise; + return globalStateMentionableUsers ?? this._fetchMentionableUsersPromise; } async getAssignableUsers(clearCache?: boolean): Promise<{ [key: string]: IAccount[] }> { @@ -768,7 +783,7 @@ export class FolderRepositoryManager extends Disposable { return globalStateAssignableUsers ?? this._fetchAssignableUsersPromise; } - return this._fetchAssignableUsersPromise; + return globalStateAssignableUsers ?? this._fetchAssignableUsersPromise; } async getTeamReviewers(refreshKind: TeamReviewerRefreshKind): Promise<{ [key: string]: ITeam[] }> { @@ -937,7 +952,7 @@ export class FolderRepositoryManager extends Disposable { ); if (githubRepo) { - const pullRequest: PullRequestModel | undefined = await githubRepo.getPullRequest(prNumber); + const pullRequest: PullRequestModel | undefined = await githubRepo.getPullRequest(prNumber, 'FolderRepositoryManager.getLocalPullRequests'); if (pullRequest) { pullRequest.localBranchName = localBranchName; @@ -951,6 +966,112 @@ export class FolderRepositoryManager extends Disposable { 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 @@ -1082,7 +1203,17 @@ export class FolderRepositoryManager extends Disposable { } }; + 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); @@ -1140,15 +1271,17 @@ export class FolderRepositoryManager extends Disposable { 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 || - ((options.fetchNextPage === false) && !options.fetchOnePagePerRepo && (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); @@ -1165,7 +1298,7 @@ export class FolderRepositoryManager extends Disposable { return { items: itemData.items, - hasMorePages: false, + hasMorePages: itemData.hasMorePages, hasUnsearchedRepositories: false, totalCount: itemData.totalCount }; @@ -1174,7 +1307,7 @@ export class FolderRepositoryManager extends Disposable { async getPullRequestsForCategory(githubRepository: GitHubRepository, categoryQuery: string, page?: number): Promise { try { Logger.debug(`Fetch pull request category ${categoryQuery} - enter`, this.id); - const { octokit, query, schema } = await githubRepository.ensure(); + const { octokit } = await githubRepository.ensure(); /* __GDPR__ "pr.search.category" : { @@ -1190,18 +1323,14 @@ export class FolderRepositoryManager extends Disposable { page: page || 1, }); - const promises: Promise<{ data: PullRequestResponse, repo: GitHubRepository } | undefined>[] = data.items.map(async (item) => { + 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); - const { data } = await query({ - query: schema.PullRequest, - variables: { - owner: prRepo.remote.owner, - name: prRepo.remote.repositoryName, - number: item.number - } - }); + if (!prRepo) { + return undefined; + } + const data = await prRepo.getPullRequest(item.number, 'FolderRepositoryManager.getPullRequestsForCategory', false, true); return { data, repo: prRepo }; }); @@ -1210,25 +1339,28 @@ export class FolderRepositoryManager extends Disposable { const pullRequests = (await Promise.all(pullRequestResponses .map(async response => { - if (!response?.data.repository) { + if (!response?.data) { Logger.appendLine('Pull request doesn\'t appear to exist.', this.id); return null; } - // Pull requests fetched with a query can be from any repo. - // We need to use the correct GitHubRepository for this PR. - return response.repo.createOrUpdatePullRequestModel( - await parseGraphQLPullRequest(response.data.repository.pullRequest, response.repo), true - ); + 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: pullRequests, + items: filteredPullRequests, hasMorePages, - totalCount: data.total_count + totalCount: data.total_count - (pullRequests.length - filteredPullRequests.length) }; } catch (e) { Logger.error(`Fetching pull request with query failed: ${e}`, this.id); @@ -1322,26 +1454,41 @@ export class FolderRepositoryManager extends Disposable { } } - 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); + 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 getIssueTemplates(): Promise { - const pattern = '{docs,.github}/ISSUE_TEMPLATE/*.md'; - return vscode.workspace.findFiles( - new vscode.RelativePattern(this._repository.rootUri, pattern), null - ); + 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 { @@ -1358,6 +1505,29 @@ export class FolderRepositoryManager extends Disposable { } } + 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()}`; @@ -1929,13 +2099,19 @@ export class FolderRepositoryManager extends Disposable { const deleteConfig = async (branch: string) => { await PullRequestGitHelper.associateBaseBranchWithBranch(this.repository, branch, undefined); await PullRequestGitHelper.associateBranchWithPullRequest(this.repository, undefined, branch); + if (this.repository.unsetConfig) { + const prefix = `branch.${branch}.`; + const remaining = (await this.repository.getConfigs()).filter(config => config.key.startsWith(prefix)); + for (const config of remaining) { + try { + await this.repository.unsetConfig(config.key); + } catch (e) { + Logger.error(`Failed to remove leftover git config ${config.key}: ${e}`, this.id); + } + } + } }; - // 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 { @@ -1956,11 +2132,50 @@ export class FolderRepositoryManager extends Disposable { } } }); + + // Git removes the whole config section when deleting a branch, including keys with multiple values. + // Recreate deleted branches when a provider leaves config behind so Git can perform that cleanup again. + const configsAfterDeletion = await this.repository.getConfigs(); + const branchesNeedingConfigCleanup = picks.filter(pick => { + const prefix = `branch.${pick.label}.`; + return configsAfterDeletion.some(config => config.key.startsWith(prefix)); + }); + const branchesNeedingRetry = new Set(needsRetry?.map(pick => pick.label)); + for (const pick of branchesNeedingConfigCleanup) { + if (!branchesNeedingRetry.has(pick.label)) { + try { + await this.repository.createBranch(pick.label, false, 'HEAD'); + await this.repository.deleteBranch(pick.label, true); + } catch (e) { + Logger.error(`Failed to remove leftover git config section for ${pick.label}: ${e}`, this.id); + } + } + } + + // Config operations can't be parallelized. Clean up any keys left by providers that don't remove sections. + for (const pick of picks) { + if (!branchesNeedingRetry.has(pick.label)) { + await deleteConfig(pick.label); + } + } if (needsRetry && needsRetry.length) { await this.deleteBranches(needsRetry, nonExistantBranches, progress, totalBranches, deletedBranches); } } + private async deleteRemotes(picks: readonly vscode.QuickPickItem[]): Promise<{ label: string; error: unknown }[]> { + const failures: { label: string; error: unknown }[] = []; + for (const pick of picks) { + try { + await this.repository.removeRemote(pick.label); + } catch (error) { + Logger.error(`Failed to delete remote ${pick.label}: ${formatError(error)}`, this.id); + failures.push({ label: pick.label, error }); + } + } + return failures; + } + async deleteLocalBranchesNRemotes() { return new Promise(async resolve => { const quickPick = vscode.window.createQuickPick(); @@ -1984,43 +2199,98 @@ export class FolderRepositoryManager extends Disposable { quickPick.items = [{ label: vscode.l10n.t('No local branches to delete'), picked: false }]; } - let firstStep = 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 this.deleteBranches(branchPicks, nonExistantBranches, progress, branchPicks.length, 0, []); + } catch (e) { + quickPick.hide(); + vscode.window.showErrorMessage(vscode.l10n.t('Deleting branches failed: {0} {1}', e.message, e.stderr)); + } + }); + } + + 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(); + } + }; + quickPick.onDidAccept(async () => { quickPick.busy = true; - if (firstStep) { - const picks = quickPick.selectedItems; - const nonExistantBranches = new Set(); - if (picks.length) { - await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Cleaning up') }, async (progress) => { - try { - await this.deleteBranches(picks, nonExistantBranches, progress, picks.length, 0, []); - } catch (e) { - quickPick.hide(); - vscode.window.showErrorMessage(vscode.l10n.t('Deleting branches failed: {0} {1}', e.message, e.stderr)); - } - }); + 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, + }); + } } - firstStep = false; - const remoteItems = await this.getRemoteDeletionItems(nonExistantBranches); - - if (remoteItems && remoteItems.length) { - quickPick.canSelectMany = true; - quickPick.placeholder = vscode.l10n.t('Choose remotes you want to delete permanently'); - quickPick.items = remoteItems; - quickPick.selectedItems = remoteItems.filter(item => item.picked); + 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 { - // batch deleting the remotes to avoid consuming all available resources const picks = quickPick.selectedItems; if (picks.length) { await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Deleting {0} remotes...', picks.length) }, async () => { - await batchPromiseAll(picks, 5, async pick => { - await this.repository.removeRemote(pick.label); - }); + const failures = await this.deleteRemotes(picks); + if (failures.length) { + const message = failures.map(({ label, error }) => `${label}: ${formatError(error)}`).join('\n'); + await vscode.window.showErrorMessage(vscode.l10n.t('Deleting remotes failed:\n{0}', message)); + } }); } quickPick.hide(); @@ -2082,17 +2352,26 @@ export class FolderRepositoryManager extends Disposable { } if (!pullRequest.mergeBase) { - const { data } = await octokit.call(octokit.api.repos.compareCommits, { - repo: remote.repositoryName, - owner: remote.owner, - base: `${pullRequest.base.repositoryCloneUrl.owner}:${pullRequest.base.ref}`, - head: `${pullRequest.head.repositoryCloneUrl.owner}:${pullRequest.head.ref}`, - }); + try { + const { data } = await octokit.call(octokit.api.repos.compareCommits, { + repo: remote.repositoryName, + owner: remote.owner, + base: `${pullRequest.base.repositoryCloneUrl.owner}:${pullRequest.base.ref}`, + head: `${pullRequest.head.repositoryCloneUrl.owner}:${pullRequest.head.ref}`, + }); - pullRequest.mergeBase = data.merge_base_commit.sha; + pullRequest.mergeBase = data.merge_base_commit.sha; + } catch (e) { + // Computing the merge base via the compare-commits API can fail (for example, + // returning 404 for cross-fork pull requests with divergent histories or when + // a fork has been deleted). This is non-fatal enrichment data, so log and fall + // back to the base sha rather than surfacing an error popup to the user. + Logger.warn(`Fetching Pull Request merge base failed: ${formatError(e)}`, this.id); + pullRequest.mergeBase = pullRequest.base.sha; + } } } catch (e) { - vscode.window.showErrorMessage(vscode.l10n.t('Fetching Pull Request merge base failed: {0}', formatError(e))); + Logger.error(`Fulfill pull request missing info failed: ${formatError(e)}`, this.id); } Logger.debug(`Fulfill pull request missing info - done`, this.id); } @@ -2132,7 +2411,7 @@ export class FolderRepositoryManager extends Disposable { const githubRepo = await this.resolveItem(owner, repositoryName); Logger.trace(`Found GitHub repo for pr #${pullRequestNumber}: ${githubRepo ? 'yes' : 'no'}`, this.id); if (githubRepo) { - const pr = await githubRepo.getPullRequest(pullRequestNumber, useCache); + 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; } @@ -2159,7 +2438,7 @@ export class FolderRepositoryManager extends Disposable { async resolveUser(owner: string, repositoryName: string, login: string): Promise { Logger.debug(`Fetch user ${login}`, this.id); const githubRepository = await this.createGitHubRepositoryFromOwnerName(owner, repositoryName); - return githubRepository.resolveUser(login); + return githubRepository?.resolveUser(login); } async getMatchingPullRequestMetadataForBranch() { @@ -2286,6 +2565,35 @@ export class FolderRepositoryManager extends Disposable { return await PullRequestGitHelper.getBranchNRemoteForPullRequest(this.repository, 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); } @@ -2300,6 +2608,21 @@ export class FolderRepositoryManager extends Disposable { } const isBrowser = (vscode.env.appHost === 'vscode.dev' || vscode.env.appHost === 'github.dev'); + + 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 (!pullRequest.isActive || isBrowser) { const conflictModel = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Finding conflicts...') }, () => createConflictResolutionModel(pullRequest)); if (conflictModel === undefined) { @@ -2308,7 +2631,8 @@ export class FolderRepositoryManager extends Disposable { } let continueWithMerge = true; if (pullRequest.item.mergeable === PullRequestMergeability.Conflict) { - const githubRepos = await Promise.all([this.createGitHubRepositoryFromOwnerName(pullRequest.head!.owner, pullRequest.head!.repositoryCloneUrl.repositoryName), this.createGitHubRepositoryFromOwnerName(pullRequest.base.owner, pullRequest.base.repositoryCloneUrl.repositoryName)]); + const 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(); @@ -2367,7 +2691,7 @@ export class FolderRepositoryManager extends Disposable { } async fetchById(githubRepo: GitHubRepository, id: number): Promise { - const pullRequest = await githubRepo.getPullRequest(id); + const pullRequest = await githubRepo.getPullRequest(id, 'FolderRepositoryManager.fetchById'); if (pullRequest) { return pullRequest; } else { @@ -2375,21 +2699,29 @@ export class FolderRepositoryManager extends Disposable { } } - public async checkoutDefaultBranch(branch: string): Promise { - const CHECKOUT_DEFAULT_BRANCH = 'checkoutDefaultBranch'; - const CHECKOUT_DEFAULT_BRANCH_AND_PULL = 'checkoutDefaultBranchAndPull'; + 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); + 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 === CHECKOUT_DEFAULT_BRANCH_AND_PULL) { - await this.checkoutDefaultBranchAndPull(branch); + if (postDoneAction.endsWith(AND_PULL)) { + await this.checkoutDoneBranchAndPull(targetBranch, remoteName); } else { - await this.checkoutDefaultBranchOnly(branch); + await this.checkoutDoneBranchOnly(targetBranch, remoteName); } } - private async checkoutDefaultBranchAndPull(branch: string): Promise { - await this.checkoutDefaultBranchOnly(branch); + 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); @@ -2403,8 +2735,28 @@ export class FolderRepositoryManager extends Disposable { } } - private async checkoutDefaultBranchOnly(branch: string): Promise { + 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); @@ -2491,7 +2843,7 @@ export class FolderRepositoryManager extends Disposable { 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(pr); + const copilotStatus = await pr.copilotWorkingStatus(); if (copilotStatus === CopilotWorkingStatus.InProgress) { return; } @@ -2607,15 +2959,39 @@ export class FolderRepositoryManager extends Disposable { }); } - async createGitHubRepositoryFromOwnerName(owner: string, repositoryName: string): Promise { + async createGitHubRepositoryFromOwnerName(owner: string, repositoryName: string): Promise { const existing = this.findExistingGitHubRepository({ owner, repositoryName }); if (existing) { return existing; } - const gitRemotes = parseRepositoryRemotes(this.repository); + 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}`; - return this.createAndAddGitHubRepository(new Remote(gitRemote?.remoteName ?? repositoryName, uri, new Protocol(uri)), this._credentialStore); + 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: { @@ -2749,7 +3125,16 @@ export class FolderRepositoryManager extends Disposable { pushRemote, this.credentialStore, ); - const permission = await githubRepo.getViewerPermission(); + 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 || @@ -2832,10 +3217,16 @@ export class FolderRepositoryManager extends Disposable { } 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(); } @@ -2875,9 +3266,11 @@ export const titleAndBodyFrom = async (promise: Promise): Pr 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).trim(), + body: unwrapCommitMessageBody(rawBody), }; }; diff --git a/src/github/githubRepository.ts b/src/github/githubRepository.ts index fad9265b2b..d3aad7ed7b 100644 --- a/src/github/githubRepository.ts +++ b/src/github/githubRepository.ts @@ -11,6 +11,7 @@ import { mergeQuerySchemaWithShared, OctokitCommon } from './common'; import { CredentialStore, GitHub } from './credentials'; import { AssignableUsersResponse, + CheckSuiteForRollup, CreatePullRequestResponse, FileContentResponse, ForkDetailsResponse, @@ -27,6 +28,8 @@ import { OrganizationTeamsCountResponse, OrganizationTeamsResponse, OrgProjectsResponse, + PullRequestNumberData, + PullRequestNumbersResponse, PullRequestParticipantsResponse, PullRequestResponse, PullRequestsResponse, @@ -47,12 +50,13 @@ import { MergeMethod, PullRequest, PullRequestChecks, + PullRequestCheckStatus, PullRequestReviewRequirement, RepoAccessAndMergeMethods, User, } from './interface'; import { IssueChangeEvent, IssueModel } from './issueModel'; -import { LoggingOctokit } from './loggingOctokit'; +import { getErrorCode, GraphQLError, LoggingOctokit } from './loggingOctokit'; import { PullRequestModel } from './pullRequestModel'; import defaultSchema from './queries.gql'; import * as extraSchema from './queriesExtra.gql'; @@ -121,6 +125,26 @@ export enum ViewerPermission { 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, @@ -139,18 +163,6 @@ export interface ForkDetails { export type IMetadata = OctokitCommon.ReposGetResponseData; -export enum GraphQLErrorType { - Unprocessable = 'UNPROCESSABLE', -} - -export interface GraphQLError { - extensions?: { - code: string; - }; - type?: GraphQLErrorType; - message?: string; -} - export enum CopilotWorkingStatus { NotCopilotIssue = 'NotCopilotIssue', InProgress = 'InProgress', @@ -165,6 +177,8 @@ export interface PullRequestChangeEvent { 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; @@ -187,8 +201,15 @@ export class GitHubRepository extends Disposable { // 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()); @@ -227,13 +248,12 @@ export class GitHubRepository extends Disposable { public async ensureCommentsController(): Promise { try { + await this.ensure(); if (this.commentsController) { return; } - - await this.ensure(); this.commentsController = vscode.comments.createCommentController( - `${PullRequestCommentController.PREFIX}-${this.remote.gitProtocol.normalizeUri()?.authority}-${this.remote.owner}-${this.remote.repositoryName}`, + `${PullRequestCommentController.PREFIX}-${this.remote.gitProtocol.normalizeUri()?.authority}-${this.remote.remoteName}-${this.remote.owner}-${this.remote.repositoryName}`, `Pull Request (${this.remote.owner}/${this.remote.repositoryName})`, ); this.commentsHandler = new PRCommentControllerRegistry(this.commentsController, this.telemetry); @@ -246,6 +266,10 @@ export class GitHubRepository extends Disposable { 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; } @@ -267,6 +291,7 @@ export class GitHubRepository extends Disposable { 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) { @@ -500,7 +525,18 @@ export class GitHubRepository extends Disposable { Logger.debug('Fetch pull request templates - done', this.id); return result.data.repository.pullRequestTemplates.map(template => template.body); } catch (e) { - // The template was not found. + 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); } } @@ -669,6 +705,40 @@ export class GitHubRepository extends Disposable { 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 { @@ -840,13 +910,17 @@ export class GitHubRepository extends Disposable { }); Logger.debug(`Fetch issues with query - done`, this.id); - const issues: Issue[] = []; + let issues: Issue[] = []; if (data && data.search.edges) { - await Promise.all(data.search.edges.map(async raw => { + // 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) { - issues.push(await parseGraphQLIssue(raw.node, this)); + return parseGraphQLIssue(raw.node, this); } + return undefined; })); + issues = parsed.filter((issue): issue is Issue => issue !== undefined); } return { items: issues, @@ -890,6 +964,62 @@ export class GitHubRepository extends Disposable { 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); @@ -905,6 +1035,9 @@ export class GitHubRepository extends Disposable { return parseGraphQLViewerPermission(data); } catch (e) { Logger.error(`Unable to fetch viewer permission: ${e}`, this.id); + if (isRateLimitError(e)) { + throw new RateLimitError(); + } return ViewerPermission.Unknown; } } @@ -933,6 +1066,54 @@ export class GitHubRepository extends Disposable { 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); @@ -1098,12 +1279,17 @@ export class GitHubRepository extends Disposable { } } - async getPullRequest(id: number, useCache: boolean = false): Promise { + 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); @@ -1122,11 +1308,40 @@ export class GitHubRepository extends Disposable { } Logger.debug(`Fetch pull request ${id} - done`, this.id); - const pr = this.createOrUpdatePullRequestModel(await parseGraphQLPullRequest(data.repository.pullRequest, this)); + 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; } } @@ -1139,6 +1354,10 @@ export class GitHubRepository extends Disposable { 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(); @@ -1234,7 +1453,15 @@ export class GitHubRepository extends Disposable { return data.repository?.ref?.target.oid; } - async listBranches(owner: string, repositoryName: string): Promise { + 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); @@ -1254,6 +1481,7 @@ export class GitHubRepository extends Disposable { name: remote.repositoryName, first: 100, after: after, + query: prefix ? prefix : null, }, }); @@ -1274,6 +1502,10 @@ export class GitHubRepository extends Disposable { if (!branches.includes(defaultBranch)) { branches.unshift(defaultBranch); } + // Cache results for unprefixed queries + if (!prefix) { + this._branchesCache.set(this.branchesCacheKey(owner, repositoryName), branches); + } return branches; } @@ -1366,11 +1598,14 @@ export class GitHubRepository extends Disposable { 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 } | undefined; - if (schema.GetSuggestedActors) { + let result: { data: AssignableUsersResponse | SuggestedActorsResponse | null } | undefined; + if (schema.GetSuggestedActors && !useLegacyAssignableUsers) { result = await query({ query: schema.GetSuggestedActors, variables: { @@ -1402,13 +1637,20 @@ export class GitHubRepository extends Disposable { }, true); // we ignore SAML errors here because this query can happen at startup } - if (result.data.repository === null) { + 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); @@ -1419,6 +1661,20 @@ export class GitHubRepository extends Disposable { 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 && @@ -1632,7 +1888,7 @@ export class GitHubRepository extends Disposable { }); } catch (e) { // There's an issue with the GetChecks that can result in SAML errors. - if (isSamlError(e)) { + 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) { @@ -1652,51 +1908,76 @@ export class GitHubRepository extends Disposable { // 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; - const checks: PullRequestChecks = !statusCheckRollup - ? { + let checks: PullRequestChecks; + if (!statusCheckRollup) { + checks = { state: CheckState.Success, statuses: [] - } - : { - state: this.mapStateAsCheckState(statusCheckRollup.state), - statuses: statusCheckRollup.contexts.nodes.map(context => { - if (isCheckRun(context)) { - return { - id: context.id, - 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, - }; - } else { - return { - id: context.id, - 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, - }; - } - }), }; + } 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; @@ -1708,6 +1989,7 @@ export class GitHubRepository extends Disposable { checks.state = CheckState.Pending; checks.statuses.push({ id: '', + databaseId: undefined, url: undefined, avatarUrl: undefined, state: CheckState.Pending, @@ -1716,7 +1998,8 @@ export class GitHubRepository extends Disposable { workflowName: undefined, event: undefined, targetUrl: prUrl, - isRequired: true + isRequired: true, + isCheckRun: false }); } } @@ -1771,4 +2054,252 @@ export class GitHubRepository extends Disposable { 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 ad48671e85..9515920a5c 100644 --- a/src/github/graphql.ts +++ b/src/github/graphql.ts @@ -39,7 +39,7 @@ export interface CrossReferencedEvent { id: string; actor: Actor; createdAt: string; - source: { + source?: { __typename: string; number: number; url: string; @@ -68,6 +68,15 @@ export interface ReopenedEvent { createdAt: string; } +export interface BaseRefChangedEvent { + __typename: string; + id: string; + actor: Actor; + createdAt: string; + currentRefName: string; + previousRefName: string; +} + export interface AbbreviatedIssueComment { author: Account; body: string; @@ -122,12 +131,17 @@ export interface Account extends Actor { export function isAccount(x: Actor | Team | Node | undefined | null): x is Account { const asAccount = x as Partial; - return !!asAccount && !!asAccount?.name && (asAccount?.email !== undefined); + 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; + 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 { @@ -188,6 +202,22 @@ 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; @@ -262,7 +292,7 @@ export interface TimelineEventsResponse { repository: { pullRequest: { timelineItems: { - nodes: (MergedEvent | Review | IssueComment | Commit | AssignedEvent | HeadRefDeletedEvent | null)[]; + nodes: (MergedEvent | Review | IssueComment | Commit | AssignedEvent | HeadRefDeletedEvent | BaseRefChangedEvent | null)[]; }; }; } | null; @@ -277,9 +307,9 @@ export interface LatestCommit { export interface LatestReviewThread { comments: { - nodes: { + nodes: ({ createdAt: string; - }[]; + } | null)[]; } } @@ -287,25 +317,25 @@ export interface LatestUpdatesResponse { repository: { pullRequest: { reactions: { - nodes: { + nodes: ({ createdAt: string; - }[]; + } | null)[]; } updatedAt: string; comments: { nodes: { updatedAt: string; reactions: { - nodes: { + nodes: ({ createdAt: string; - }[]; + } | null)[]; } }[]; } timelineItems: { nodes: ({ createdAt: string; - } | LatestCommit | LatestReviewThread)[]; + } | LatestCommit | LatestReviewThread | null)[]; } } } @@ -314,10 +344,12 @@ export interface LatestUpdatesResponse { export interface LatestReviewCommitResponse { repository: { pullRequest: { - viewerLatestReview: { - commit: { - oid: string; - } + reviews: { + nodes: { + commit: { + oid: string; + } + }[]; }; }; } | null; @@ -344,6 +376,14 @@ export interface GetReviewRequestsResponse { } | null; } +export interface AddReviewRequestResponse { + requestReviews: { + pullRequest: { + id: string; + }; + } | null; +} + export interface PullRequestState { repository: { pullRequest: { @@ -488,6 +528,16 @@ export interface MarkPullRequestReadyForReviewResponse { }; } +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?: { @@ -508,6 +558,15 @@ export interface EnqueuePullRequestResponse { } } +export interface UpdatePullRequestBranchResponse { + updatePullRequestBranch: { + pullRequest: { + id: string; + headRefOid: string; + } + } +} + export interface SubmittedReview extends Review { comments: { nodes: ReviewComment[]; @@ -687,6 +746,9 @@ export interface Issue { totalCount: number; } reactionGroups: ReactionGroup[]; + issueType?: { + name: string; + } | null; } @@ -722,6 +784,15 @@ export interface PullRequest extends Issue { suggestedReviewers: SuggestedReviewerResponse[]; additions?: number; deletions?: number; + closingIssuesReferences?: { + nodes: { + databaseId: number, + title: string, + number: number, + state: 'CLOSED' | 'OPEN', + url: string, + }[]; + }; } export enum DefaultCommitTitle { @@ -841,6 +912,23 @@ export interface PullRequestsResponse { } | null; } +export interface PullRequestNumbersResponse { + repository: { + pullRequests: { + nodes: PullRequestNumberData[] + } + } | null; + rateLimit: RateLimit; +} + +export interface PullRequestNumberData { + number: number; + title: string; + author: { + login: string; + }; +} + export interface MaxIssueResponse { repository: { issues: { @@ -931,6 +1019,7 @@ export interface StatusContext { export interface CheckRun { __typename: string; id: string; + databaseId: number | null; conclusion: | 'ACTION_REQUIRED' | 'CANCELLED' @@ -963,6 +1052,30 @@ export function isCheckRun(x: CheckRun | StatusContext): x is CheckRun { 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 @@ -999,6 +1112,9 @@ export interface GetChecksResponse { nodes: (StatusContext | CheckRun)[]; }; }; + checkSuites?: { + nodes: CheckSuiteForRollup[]; + } | null; }; }[] | undefined; }; @@ -1048,7 +1164,7 @@ export interface MergePullRequestResponse { mergePullRequest: { pullRequest: PullRequest & { timelineItems: { - nodes: (MergedEvent | Review | IssueComment | Commit | AssignedEvent | HeadRefDeletedEvent)[] + nodes: (MergedEvent | Review | IssueComment | Commit | AssignedEvent | HeadRefDeletedEvent | BaseRefChangedEvent)[] } }; } diff --git a/src/github/interface.ts b/src/github/interface.ts index 39a69fa8bc..30fa68149d 100644 --- a/src/github/interface.ts +++ b/src/github/interface.ts @@ -50,6 +50,11 @@ export interface ReadyForReview { allowAutoMerge: boolean; } +export interface ConvertToDraft { + isDraft: boolean; + mergeable: PullRequestMergeability; +} + export interface IActor { login: string; avatarUrl?: string; @@ -216,6 +221,15 @@ export interface Issue { 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 { @@ -237,6 +251,7 @@ export interface PullRequest extends Issue { mergeCommitMeta?: { title: string, description: string }; squashCommitMeta?: { title: string, description: string }; suggestedReviewers?: ISuggestedReviewer[]; + closingIssues?: IssueReference[]; hasComments?: boolean; additions?: number; deletions?: number; @@ -341,6 +356,7 @@ export enum CheckState { export interface PullRequestCheckStatus { id: string; + databaseId: number | null | undefined; url: string | undefined; avatarUrl: string | undefined; state: CheckState; @@ -350,6 +366,7 @@ export interface PullRequestCheckStatus { workflowName: string | undefined; event: string | undefined; isRequired: boolean; + isCheckRun: boolean; } export interface PullRequestChecks { diff --git a/src/github/issueModel.ts b/src/github/issueModel.ts index 2793661eee..ff6b910ac7 100644 --- a/src/github/issueModel.ts +++ b/src/github/issueModel.ts @@ -41,6 +41,7 @@ export interface IssueChangeEvent { draft?: true; reviewers?: true; + base?: true; } export class IssueModel extends Disposable { @@ -85,8 +86,8 @@ export class IssueModel extends Disposable { } } - get timelineEvents(): readonly TimelineEvent[] { - return this._timelineEvents ?? []; + get timelineEvents(): readonly TimelineEvent[] | undefined { + return this._timelineEvents; } protected set timelineEvents(timelineEvents: readonly TimelineEvent[]) { @@ -445,32 +446,33 @@ export class IssueModel extends Disposable { const times = [ time, new Date(data.repository.pullRequest.updatedAt), - ...(data.repository.pullRequest.reactions.nodes.map(node => new Date(node.createdAt))), + ...(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.map(reaction => new Date(reaction.createdAt)))), + ...(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; - if (latestCommit.commit?.committedDate) { + const latestCommit = node as (Partial | null); + if (latestCommit?.commit?.committedDate) { return new Date(latestCommit.commit.committedDate); } - const latestReviewThread = node as Partial; - if ((latestReviewThread.comments?.nodes.length ?? 0) > 0) { - return new Date(latestReviewThread.comments!.nodes[0].createdAt); + 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); } - return new Date((node as { createdAt: string }).createdAt); })) ]; // Sort times and return the most recent one - return new Date(Math.max(...times.map(t => t.getTime()))); + 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(issueModel: IssueModel): Promise { - Logger.debug(`Fetch timeline events of issue #${issueModel.number} - enter`, GitHubRepository.ID); + async getIssueTimelineEvents(): Promise { + Logger.debug(`Fetch timeline events of issue #${this.number} - enter`, GitHubRepository.ID); const { query, remote, schema } = await this.githubRepository.ensure(); try { @@ -479,7 +481,7 @@ export class IssueModel extends Disposable { variables: { owner: remote.owner, name: remote.repositoryName, - number: issueModel.number, + number: this.number, }, }); @@ -489,11 +491,11 @@ export class IssueModel extends Disposable { } const ret = data.repository.pullRequest.timelineItems.nodes; - const events = await parseCombinedTimelineEvents(ret, await this.getCopilotTimelineEvents(issueModel, true), this.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, issueModel.remote.owner) === 0 && compareIgnoreCase(event.source.repo, issueModel.remote.repositoryName) === 0); + 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; @@ -501,10 +503,10 @@ export class IssueModel extends Disposable { for (const unseenPrs of crossRefs) { // Kick off getting the new PRs so that the system knows about them (and refreshes the tree when they're found) - this.githubRepository.getPullRequest(unseenPrs.source.number); + this.githubRepository.getPullRequest(unseenPrs.source.number, 'IssueModel.getTimelineEvents'); } - issueModel.timelineEvents = events; + this.timelineEvents = events; return events; } catch (e) { console.log(e); @@ -515,15 +517,15 @@ export class IssueModel extends Disposable { /** * TODO: @alexr00 we should delete this https://github.com/microsoft/vscode-pull-request-github/issues/6965 */ - async getCopilotTimelineEvents(issueModel: IssueModel, skipMerge: boolean = false, useCache: boolean = false): Promise { - if (!COPILOT_ACCOUNTS[issueModel.author.login]) { + async getCopilotTimelineEvents(skipMerge: boolean = false, useCache: boolean = false): Promise { + if (!COPILOT_ACCOUNTS[this.author.login]) { return []; } - Logger.debug(`Fetch Copilot timeline events of issue #${issueModel.number} - enter`, GitHubRepository.ID); + 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 #${issueModel.number} (used cache) - exit`, GitHubRepository.ID); + Logger.debug(`Fetch Copilot timeline events of issue #${this.number} (used cache) - exit`, GitHubRepository.ID); return this._copilotTimelineEvents; } @@ -531,39 +533,39 @@ export class IssueModel extends Disposable { const { octokit, remote } = await this.githubRepository.ensure(); try { const timeline = await restPaginate(octokit.api.issues.listEventsForTimeline, { - issue_number: issueModel.number, + issue_number: this.number, owner: remote.owner, repo: remote.repositoryName, per_page: 100 }); - const timelineEvents = parseSelectRestTimelineEvents(issueModel, timeline); + const timelineEvents = parseSelectRestTimelineEvents(this, timeline); this._copilotTimelineEvents = timelineEvents; if (timelineEvents.length === 0) { return []; } if (!skipMerge) { - const oldLastEvent = issueModel.timelineEvents.length > 0 ? issueModel.timelineEvents[issueModel.timelineEvents.length - 1] : undefined; + 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 = [...issueModel.timelineEvents, ...newEvents]; + allEvents = [...(this.timelineEvents ?? []), ...newEvents]; } - issueModel.timelineEvents = allEvents; + this.timelineEvents = allEvents; } - Logger.debug(`Fetch Copilot timeline events of issue #${issueModel.number} - exit`, GitHubRepository.ID); + 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 #${issueModel.number} - ${formatError(e)}`, GitHubRepository.ID); + Logger.error(`Error fetching Copilot timeline events of issue #${this.number} - ${formatError(e)}`, GitHubRepository.ID); return []; } } - async copilotWorkingStatus(issueModel: IssueModel): Promise { - const copilotEvents = await this.getCopilotTimelineEvents(issueModel); + async copilotWorkingStatus(): Promise { + const copilotEvents = await this.getCopilotTimelineEvents(); if (copilotEvents.length > 0) { const lastEvent = copilotEvents[copilotEvents.length - 1]; if (lastEvent.event === EventType.CopilotFinished) { diff --git a/src/github/issueOverview.ts b/src/github/issueOverview.ts index 15ad546288..57b9f51e7d 100644 --- a/src/github/issueOverview.ts +++ b/src/github/issueOverview.ts @@ -5,14 +5,15 @@ 'use strict'; import * as vscode from 'vscode'; -import { CloseResult } from '../../common/views'; +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'; import { getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, getProjectFromQuickPick } from './quickPicks'; -import { isInCodespaces, vscodeDevPrLink } from './utils'; -import { ChangeAssigneesReply, DisplayLabel, Issue, ProjectItemsReply, SubmitReviewReply } from './views'; +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'; @@ -20,45 +21,57 @@ import { PR_SETTINGS_NAMESPACE, WEBVIEW_REFRESH_INTERVAL } from '../common/setti import { ITelemetry } from '../common/telemetry'; import { CommentEvent, EventType, ReviewStateValue, TimelineEvent } from '../common/timelineEvent'; import { asPromise, formatError } from '../common/utils'; -import { getNonce, IRequestMessage, WebviewBase } from '../common/webview'; +import { 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 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(); public static readonly viewType: string = 'IssueOverview'; protected readonly _panel: vscode.WebviewPanel; protected _item: TItem; + protected _identity: UnresolvedIdentity; protected _folderRepositoryManager: FolderRepositoryManager; protected _scrollPosition = { x: 0, y: 0 }; + 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, - issue: IssueModel, - toTheSide: Boolean = false, + identity: UnresolvedIdentity, + issue?: IssueModel, + toTheSide: boolean = false, _preserveFocus: boolean = true, existingPanel?: vscode.WebviewPanel ) { await ensureEmojis(folderRepositoryManager.context); - const activeColumn = toTheSide - ? vscode.ViewColumn.Beside - : vscode.window.activeTextEditor - ? 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); + + 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( + const title = `#${identity.number.toString()}`; + panel = new IssueOverviewPanel( telemetry, extensionUri, activeColumn || vscode.ViewColumn.Active, @@ -68,15 +81,52 @@ export class IssueOverviewPanel extends W existingPanel, undefined ); + this._panels.set(key, panel); } - await IssueOverviewPanel.currentPanel!.update(folderRepositoryManager, issue); + 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(); + } } - public static refresh(): void { - if (this.currentPanel) { - this.currentPanel.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)); + } + + /** + * 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}`; } protected setPanelTitle(title: string): void { @@ -96,10 +146,13 @@ export class IssueOverviewPanel extends W folderRepositoryManager: FolderRepositoryManager, private readonly type: string = IssueOverviewPanel.viewType, existingPanel?: vscode.WebviewPanel, - iconSubpath?: { + iconSubpath: { light: string, dark: string, - } + } = { + light: 'resources/icons/issue_webview.svg', + dark: 'resources/icons/dark/issue_webview.svg', + } ) { super(); this._folderRepositoryManager = folderRepositoryManager; @@ -115,12 +168,10 @@ export class IssueOverviewPanel extends W enableFindWidget: true })); - if (iconSubpath) { - this._panel.iconPath = { - dark: vscode.Uri.joinPath(_extensionUri, iconSubpath.dark), - light: vscode.Uri.joinPath(_extensionUri, iconSubpath.light) - }; - } + this._panel.iconPath = { + dark: vscode.Uri.joinPath(_extensionUri, iconSubpath.dark), + light: vscode.Uri.joinPath(_extensionUri, iconSubpath.light) + }; this._webview = this._panel.webview; super.initialize(); @@ -191,7 +242,7 @@ export class IssueOverviewPanel extends W public async refreshPanel(): Promise { if (this._panel && this._panel.visible) { - await this.update(this._folderRepositoryManager, this._item); + await this.updateItem(this._item); } } @@ -199,7 +250,7 @@ export class IssueOverviewPanel extends W return isInCodespaces(); } - protected getInitializeContext(currentUser: IAccount, issue: IssueModel, timelineEvents: TimelineEvent[], repositoryAccess: RepoAccessAndMergeMethods, viewerCanEdit: boolean, assignableUsers: IAccount[]): Issue { + 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 => ({ @@ -216,12 +267,12 @@ export class IssueOverviewPanel extends W url: issue.html_url, createdAt: issue.createdAt, body: issue.body, - bodyHTML: issue.bodyHTML, + bodyHTML: await this.processLinksInBodyHtml(issue.bodyHTML), labels: labels, author: issue.author, state: issue.state, stateReason: issue.stateReason, - events: timelineEvents, + events: await this.processTimelineEvents(timelineEvents), continueOnGitHub: this.continueOnGitHub(), canEdit, hasWritePermission, @@ -232,6 +283,7 @@ export class IssueOverviewPanel extends W 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, }; @@ -254,7 +306,7 @@ export class IssueOverviewPanel extends W issueModel.remote.repositoryName, issueModel.number, ), - issueModel.getIssueTimelineEvents(issueModel), + issueModel.getIssueTimelineEvents(), this._folderRepositoryManager.getPullRequestRepositoryAccessAndMergeMethods(issueModel), issueModel.canEdit(), this._folderRepositoryManager.getAssignableUsers(), @@ -268,12 +320,15 @@ export class IssueOverviewPanel extends W } this._item = issue as TItem; - this.setPanelTitle(`Issue #${issueModel.number.toString()}`); + 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: this.getInitializeContext(currentUser, issue, timelineEvents, repositoryAccess, viewerCanEdit, assignableUsers[this._item.remote.remoteName] ?? []), + pullrequest: context, }); } catch (e) { @@ -285,25 +340,58 @@ export class IssueOverviewPanel extends W // none for issues } - public async update(foldersManager: FolderRepositoryManager, issueModel: TItem, progressLocation?: string): Promise { - if (this._folderRepositoryManager !== foldersManager) { - this._folderRepositoryManager = foldersManager; - this.registerPrListeners(); - } + /** + * Resolve a model from an unresolved identity. + * Subclasses can override to resolve different types (e.g., pull requests vs issues). + */ + protected async resolveModel(identity: UnresolvedIdentity): Promise { + return this._folderRepositoryManager.resolveIssue( + identity.owner, + identity.repo, + identity.number + ) as Promise; + } + + /** + * Get the display name for the item type (for error messages). + */ + protected getItemTypeName(): string { + return 'issue'; + } + + /** + * Update the panel with an unresolved identity and optional model. + * If no model is provided, it will be resolved from the identity. + */ + public async updateWithIdentity(foldersManager: FolderRepositoryManager, identity: UnresolvedIdentity, issueModel?: TItem, progressLocation?: string): Promise { + this._identity = identity; + this._folderRepositoryManager = foldersManager; this._postMessage({ command: 'set-scroll', scrollPosition: this._scrollPosition, }); - if (!this._item || (this._item.number !== issueModel.number) || !this._panel.webview.html) { + if (!this._panel.webview.html) { this._panel.webview.html = this.getHtmlForWebview(); - this._postMessage({ command: 'pr.clear' }); + if (this._item) { + this._postMessage({ command: 'pr.clear' }); + } + } + // If no model provided, resolve it from the identity + if (!issueModel) { + const resolvedModel = await this.resolveModel(identity); + if (!resolvedModel) { + throw new Error( + `Failed to resolve ${this.getItemTypeName()} #${identity.number} in ${identity.owner}/${identity.repo}`, + ); + } + issueModel = resolvedModel; } if (progressLocation) { - return vscode.window.withProgress({ location: { viewId: progressLocation } }, () => this.updateItem(issueModel)); + return vscode.window.withProgress({ location: { viewId: progressLocation } }, () => this.updateItem(issueModel!)); } else { return this.updateItem(issueModel); } @@ -361,15 +449,22 @@ export class IssueOverviewPanel extends W 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 'pr.upload-files': + return this.uploadFiles(message); + case 'pr.upload-pasted-files': + return this.uploadPastedFiles(message); default: return this.MESSAGE_UNHANDLED; } } - protected async submitReviewMessage(message: IRequestMessage) { - const comment = await this._item.createIssueComment(message.args); + protected async submitReviewMessage(message: IRequestMessage) { + const body = message.args?.body ?? ''; + const comment = await this._item.createIssueComment(body); const commentedEvent: CommentEvent = { ...comment, event: EventType.Commented @@ -484,16 +579,122 @@ export class IssueOverviewPanel extends W 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 => { - this._throwError(message, e); - vscode.window.showErrorMessage(`Editing description failed: ${formatError(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 }>) { return this._item @@ -507,8 +708,9 @@ export class IssueOverviewPanel extends W }); } - protected _getTimeline(): Promise { - return this._item.getIssueTimelineEvents(this._item); + protected async _getTimeline(): Promise { + const events = await this._item.getIssueTimelineEvents(); + return this.processTimelineEvents(events); } private async changeAssignees(message: IRequestMessage): Promise { @@ -642,18 +844,15 @@ export class IssueOverviewPanel extends W return this._item.editIssueComment(comment, text); } - 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, - }); - }) - .catch(e => { - this._throwError(message, e); - vscode.window.showErrorMessage(formatError(e)); - }); + 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)); + } } protected deleteCommentPromise(comment: IComment): Promise { @@ -677,6 +876,29 @@ export class IssueOverviewPanel extends W }); } + 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 async close(message: IRequestMessage) { let comment: IComment | undefined; if (message.args) { @@ -694,18 +916,22 @@ export class IssueOverviewPanel extends W this._replyMessage(message, result); } - protected set _currentPanel(panel: IssueOverviewPanel | undefined) { - IssueOverviewPanel.currentPanel = panel; + 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); + } } public override dispose() { super.dispose(); - this._currentPanel = undefined; + this._removeFromPanels(); this._webview = undefined; } protected getHtmlForWebview() { - const nonce = getNonce(); + const nonce = generateUuid(); const uri = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview-pr-description.js'); diff --git a/src/github/loggingOctokit.ts b/src/github/loggingOctokit.ts index 09813b75ae..a6c9295ab6 100644 --- a/src/github/loggingOctokit.ts +++ b/src/github/loggingOctokit.ts @@ -28,14 +28,120 @@ interface RateLimitResult { } | 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'); + } - constructor(private readonly telemetry: ITelemetry, private readonly errorOnFlood: boolean) { } + 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. @@ -94,6 +200,33 @@ export class RateLogger { } } + 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 { @@ -122,16 +255,18 @@ export class LoggingApolloClient { 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 = options.context; + 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; } } @@ -146,6 +281,7 @@ export class LoggingOctokit { 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; } } diff --git a/src/github/markdownUtils.ts b/src/github/markdownUtils.ts index cf4c766af7..4d5f77cce9 100644 --- a/src/github/markdownUtils.ts +++ b/src/github/markdownUtils.ts @@ -346,6 +346,8 @@ export function getStatusDecoration(status: UnsatisfiedChecks): vscode.FileDecor 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, diff --git a/src/github/overviewRestorer.ts b/src/github/overviewRestorer.ts index 5092d0063f..550379fb75 100644 --- a/src/github/overviewRestorer.ts +++ b/src/github/overviewRestorer.ts @@ -53,20 +53,26 @@ export class OverviewRestorer extends Disposable implements vscode.WebviewPanelS 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, issueModel, undefined, true, webviewPanel); + return IssueOverviewPanel.createOrShow(this._telemetry, this._extensionUri, folderManager, identity, issueModel, undefined, true, webviewPanel); } else { - const pullRequestModel = await repo.getPullRequest(state.number, true); + const pullRequestModel = await repo.getPullRequest(state.number, 'OverviewRestorer.deserializeWebviewPanel', true); if (!pullRequestModel) { webviewPanel.dispose(); return; } - return PullRequestOverviewPanel.createOrShow(this._telemetry, this._extensionUri, folderManager, pullRequestModel, undefined, true, webviewPanel); + return PullRequestOverviewPanel.createOrShow(this._telemetry, this._extensionUri, folderManager, identity, pullRequestModel, undefined, true, webviewPanel); } } diff --git a/src/github/prComment.ts b/src/github/prComment.ts index f35cebc8b6..eec7df13a0 100644 --- a/src/github/prComment.ts +++ b/src/github/prComment.ts @@ -107,6 +107,11 @@ abstract class CommentBase implements vscode.Comment { */ public contextValue: string; + /** + * The state of the comment (Published or Draft) + */ + public state?: vscode.CommentState; + constructor( parent: GHPRCommentThread, ) { @@ -170,9 +175,10 @@ export class TemporaryComment extends CommentBase { this.mode = vscode.CommentMode.Preview; this.originalAuthor = { name: currentUser.specialDisplayName ?? currentUser.login, - iconPath: currentUser.avatarUrl ? vscode.Uri.parse(`${currentUser.avatarUrl}&s=64`) : undefined, + 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; @@ -190,7 +196,9 @@ export class TemporaryComment extends CommentBase { } get body(): string | vscode.MarkdownString { - return new vscode.MarkdownString(this.input); + const s = new vscode.MarkdownString(this.input); + s.supportAlertSyntax = true; + return s; } get author(): vscode.CommentAuthorInformation { @@ -207,8 +215,10 @@ export class TemporaryComment extends CommentBase { } const SUGGESTION_EXPRESSION = /```suggestion(\u0020*(\r\n|\n))((?[\s\S]*?)(\r\n|\n))?```/; -const IMG_EXPRESSION = /.+?)['"].*?>/g; +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.avatarCirclesAsImageDataUris(context, [comment.user], 28, 28) : Promise.resolve([]); + 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) { @@ -247,6 +257,7 @@ export class GHPRComment extends CommentBase { updateCommentReactions(this, comment.reactions); this.label = comment.isDraft ? vscode.l10n.t('Pending') : undefined; + this.state = comment.isDraft ? vscode.CommentState.Draft : vscode.CommentState.Published; const contextValues: string[] = []; if (comment.canEdit) { @@ -286,7 +297,9 @@ export class GHPRComment extends CommentBase { const oldLabel = this.label; this.label = comment.isDraft ? vscode.l10n.t('Pending') : undefined; - if (this.label !== oldLabel) { + const newState = comment.isDraft ? vscode.CommentState.Draft : vscode.CommentState.Published; + if (this.label !== oldLabel || this.state !== newState) { + this.state = newState; refresh = true; } @@ -340,8 +353,11 @@ export class GHPRComment extends CommentBase { } private replaceImg(body: string) { - return body.replace(IMG_EXPRESSION, (_substring, _1, _2, _3, { src }) => { - return `![image](${src})`; + 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})`; }); } @@ -410,6 +426,36 @@ ${lineContents} return replaceImages(body, html, this.githubRepository?.remote.host); } + private replaceCommitShas(body: string): string { + const githubRepository = this.githubRepository; + if (!githubRepository) { + return body; + } + + // Match commit SHAs that are: + // - Either 7 or 40 hex characters + // - Not already part of a URL or markdown link + // - Not inside code blocks (backticks) + return body.replace(COMMIT_SHA_EXPRESSION, (match, shortSha, remaining, offset) => { + // Don't replace if inside code blocks + const beforeMatch = body.substring(0, offset); + const backtickCount = (beforeMatch.match(/`/g)?.length ?? 0); + if (backtickCount % 2 === 1) { + return match; + } + + // Don't replace if already part of a markdown link + if (beforeMatch.endsWith('[') || body.substring(offset + match.length).startsWith(']')) { + return match; + } + + const owner = githubRepository.remote.owner; + const repo = githubRepository.remote.repositoryName; + const commitUrl = `https://${githubRepository.remote.host}/${owner}/${repo}/commit/${match}`; + return `[${shortSha}](${commitUrl})`; + }); + } + private replaceNewlines(body: string) { return body.replace(/(? { + ): Promise { let branchName: string | null = null; try { const key = PullRequestGitHelper.buildPullRequestMetadata(pullRequest); @@ -329,8 +332,20 @@ export class PullRequestGitHelper { ): Promise { try { const configKey = this.getMetadataKeyForBranch(branchName); - const configValue = await repository.getConfig(configKey); - return PullRequestGitHelper.parsePullRequestMetadata(configValue); + const allConfigs = await repository.getConfigs(); + // 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; } @@ -339,14 +354,14 @@ 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, }); @@ -386,10 +401,10 @@ 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)) { diff --git a/src/github/pullRequestModel.ts b/src/github/pullRequestModel.ts index 459e9a3f31..12baa0c246 100644 --- a/src/github/pullRequestModel.ts +++ b/src/github/pullRequestModel.ts @@ -11,12 +11,15 @@ import * as vscode from 'vscode'; import { OctokitCommon } from './common'; import { ConflictResolutionModel } from './conflictResolutionModel'; import { CredentialStore } from './credentials'; +import { showEmptyCommitWebview } from './emptyCommitWebview'; import { FolderRepositoryManager } from './folderRepositoryManager'; -import { GitHubRepository, GraphQLError, GraphQLErrorType } from './githubRepository'; +import { GitHubRepository } from './githubRepository'; import { AddCommentResponse, AddReactionResponse, + AddReviewRequestResponse as AddReviewsResponse, AddReviewThreadResponse, + ConvertPullRequestToDraftResponse, DeleteReactionResponse, DeleteReviewResponse, DequeuePullRequestResponse, @@ -40,14 +43,18 @@ import { SubmitReviewResponse, TimelineEventsResponse, UnresolveReviewThreadResponse, + UpdateIssueResponse, + UpdatePullRequestBranchResponse, } from './graphql'; import { AccountType, + ConvertToDraft, GithubItemStateEnum, IAccount, IGitTreeItem, IRawFileChange, IRawFileContent, + IssueReference, ISuggestedReviewer, ITeam, MergeMethod, @@ -60,7 +67,7 @@ import { ReviewEventEnum, } from './interface'; import { IssueChangeEvent, IssueModel } from './issueModel'; -import { compareCommits } from './loggingOctokit'; +import { compareCommits, GraphQLError, GraphQLErrorType } from './loggingOctokit'; import { convertRESTPullRequestToRawPullRequest, convertRESTReviewEvent, @@ -88,7 +95,7 @@ import Logger from '../common/logger'; import { Remote } from '../common/remote'; import { DEFAULT_MERGE_METHOD, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; import { ITelemetry } from '../common/telemetry'; -import { ClosedEvent, EventType, ReviewEvent, TimelineEvent } from '../common/timelineEvent'; +import { 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'; @@ -131,12 +138,13 @@ export class PullRequestModel extends IssueModel implements IPullRe public conflicts?: string[]; public suggestedReviewers?: ISuggestedReviewer[]; public hasChangesSinceLastReview?: boolean; + public closingIssues: IssueReference[] = []; private _showChangesSinceReview: boolean; private _hasPendingReview: boolean = false; private _onDidChangePendingReviewState: vscode.EventEmitter = this._register(new vscode.EventEmitter()); public onDidChangePendingReviewState = this._onDidChangePendingReviewState.event; - private _reviewThreadsCache: IReviewThread[] = []; + private _reviewThreadsCache: IReviewThread[] | undefined; private _reviewThreadsCacheInitialized = false; private _onDidChangeReviewThreads = this._register(new vscode.EventEmitter()); public onDidChangeReviewThreads = this._onDidChangeReviewThreads.event; @@ -183,16 +191,17 @@ export class PullRequestModel extends IssueModel implements IPullRe public clear() { this.comments = []; this._reviewThreadsCacheInitialized = false; - this._reviewThreadsCache = []; + this._reviewThreadsCache = undefined; } - public async initializeReviewThreadCache(): Promise { - await this.getReviewThreads(); + public async initializeReviewThreadCache(): Promise { + const threads = await this.getReviewThreads(); this._reviewThreadsCacheInitialized = true; + return threads; } public get reviewThreadsCache(): IReviewThread[] { - return this._reviewThreadsCache; + return this._reviewThreadsCache ?? []; } public get reviewThreadsCacheReady(): boolean { @@ -258,7 +267,7 @@ export class PullRequestModel extends IssueModel implements IPullRe } this.suggestedReviewers = item.suggestedReviewers; - + this.closingIssues = item.closingIssues ?? []; if (item.isRemoteHeadDeleted != null) { this.isRemoteHeadDeleted = item.isRemoteHeadDeleted; } @@ -329,22 +338,41 @@ export class PullRequestModel extends IssueModel implements IPullRe /** * 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(repository: Repository, message?: string): Promise { + 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))?.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 pull the latest changes from the remote branch before approving.'); } else { localHead = this.head?.sha; - remoteHead = (await this.githubRepository.getPullRequest(this.number))?.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); } @@ -458,7 +486,7 @@ export class PullRequestModel extends IssueModel implements IPullRe */ this._telemetry.sendTelemetryEvent('pr.merge.success'); this._onDidChange.fire({ state: true }); - return { merged: true, message: '', timeline: await parseCombinedTimelineEvents(result.data?.mergePullRequest.pullRequest.timelineItems.nodes ?? [], await this.getCopilotTimelineEvents(this), this.githubRepository) }; + return { merged: true, message: '', timeline: await parseCombinedTimelineEvents(result.data?.mergePullRequest.pullRequest.timelineItems.nodes ?? [], await this.getCopilotTimelineEvents(), this.githubRepository) }; }) .catch(e => { /* __GDPR__ @@ -559,7 +587,7 @@ export class PullRequestModel extends IssueModel implements IPullRe await this.updateDraftModeContext(); const reviewEvent = parseGraphQLReviewEvent(data!.submitPullRequestReview.pullRequestReview, this.githubRepository); - const threadWithComment = this._reviewThreadsCache.find(thread => + const threadWithComment = (this._reviewThreadsCache ?? []).find(thread => thread.comments.length ? (thread.comments[0].pullRequestReviewId === reviewEvent.id) : undefined, ); if (threadWithComment) { @@ -597,6 +625,7 @@ export class PullRequestModel extends IssueModel implements IPullRe async getViewerLatestReviewCommit(): Promise<{ sha: string } | undefined> { Logger.debug(`Fetch viewers latest review commit`, IssueModel.ID); const { query, remote, schema } = await this.githubRepository.ensure(); + const currentUser = (await this.githubRepository.getAuthenticatedUser()).login; try { const { data } = await query({ @@ -605,6 +634,7 @@ export class PullRequestModel extends IssueModel implements IPullRe owner: remote.owner, name: remote.repositoryName, number: this.number, + author: currentUser, }, }); @@ -612,8 +642,9 @@ export class PullRequestModel extends IssueModel implements IPullRe Logger.error('Unexpected null repository while getting last review commit', PullRequestModel.ID); } - return data.repository?.pullRequest.viewerLatestReview ? { - sha: data.repository?.pullRequest.viewerLatestReview.commit.oid, + const latestReview = data.repository?.pullRequest.reviews.nodes[0]; + return latestReview ? { + sha: latestReview.commit.oid, } : undefined; } catch (e) { @@ -639,12 +670,15 @@ export class PullRequestModel extends IssueModel implements IPullRe }); const { comments, databaseId } = data!.deletePullRequestReview.pullRequestReview; - const deletedReviewComments = comments.nodes.map(comment => parseGraphQLComment(comment, false, this.githubRepository)); + 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; @@ -738,7 +772,7 @@ export class PullRequestModel extends IssueModel implements IPullRe pullRequestId: this.graphNodeId, pullRequestReviewId: pendingReviewId, startLine: startLine === endLine ? undefined : startLine, - line: (endLine === undefined) ? 0 : endLine, + line: endLine, side, subjectType: (startLine === undefined || endLine === undefined) ? SubjectType.FILE : SubjectType.LINE } @@ -760,6 +794,9 @@ export class PullRequestModel extends IssueModel implements IPullRe const thread = data.addPullRequestReviewThread.thread; const newThread = parseGraphQLReviewThread(thread, this.githubRepository); + if (!this._reviewThreadsCache) { + this._reviewThreadsCache = []; + } this._reviewThreadsCache.push(newThread); this._onDidChangeReviewThreads.fire({ added: [newThread], changed: [], removed: [] }); this._onDidChange.fire({ timeline: true }); @@ -808,13 +845,13 @@ export class PullRequestModel extends IssueModel implements IPullRe } const { comment } = data.addPullRequestReviewComment; - const newComment = parseGraphQLComment(comment, false, this.githubRepository); + const newComment = parseGraphQLComment(comment, false, false, this.githubRepository); if (isSingleComment) { newComment.isDraft = false; } - const threadWithComment = this._reviewThreadsCache.find(thread => + const threadWithComment = this._reviewThreadsCache?.find(thread => thread.comments.some(comment => comment.graphNodeId === inReplyTo), ); if (threadWithComment) { @@ -849,7 +886,7 @@ export class PullRequestModel extends IssueModel implements IPullRe /** * Get the timeline events of a pull request, including comments, reviews, commits, merges, deletes, and assigns. */ - async getTimelineEvents(): Promise { + 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(); @@ -884,7 +921,7 @@ export class PullRequestModel extends IssueModel implements IPullRe const ret = data?.repository?.pullRequest.timelineItems.nodes ?? []; - const events = await parseCombinedTimelineEvents(ret, await this.getCopilotTimelineEvents(this, true), this.githubRepository); + const events = await parseCombinedTimelineEvents(ret, await this.getCopilotTimelineEvents(true), this.githubRepository); this.addReviewTimelineEventComments(events, reviewThreads); insertNewCommitsSinceReview(events, latestReviewCommitInfo?.sha, currentUser, this.head); @@ -893,6 +930,31 @@ export class PullRequestModel extends IssueModel implements IPullRe 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[]; @@ -937,13 +999,20 @@ export class PullRequestModel extends IssueModel implements IPullRe return; } const prReviewThreadEvent = reviewEventsById[thread.prReviewDatabaseId]; - prReviewThreadEvent.reviewThread = { + 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; }); const pendingReview = reviewEvents.filter(r => r.state?.toLowerCase() === 'pending')[0]; @@ -960,7 +1029,7 @@ export class PullRequestModel extends IssueModel implements IPullRe */ async editReviewComment(comment: IComment, text: string): Promise { const { mutate, schema } = await this.githubRepository.ensure(); - let threadWithComment = this._reviewThreadsCache.find(thread => + let threadWithComment = this._reviewThreadsCache?.find(thread => thread.comments.some(c => c.graphNodeId === comment.graphNodeId), ); @@ -985,6 +1054,7 @@ export class PullRequestModel extends IssueModel implements IPullRe const newComment = parseGraphQLComment( data.updatePullRequestReviewComment.pullRequestReviewComment, !!comment.isResolved, + !!comment.isOutdated, this.githubRepository ); if (threadWithComment) { @@ -1005,7 +1075,7 @@ export class PullRequestModel extends IssueModel implements IPullRe try { const { octokit, remote } = await this.githubRepository.ensure(); const id = Number(commentId); - const threadIndex = this._reviewThreadsCache.findIndex(thread => thread.comments.some(c => c.id === id)); + const threadIndex = this._reviewThreadsCache?.findIndex(thread => thread.comments.some(c => c.id === id)) ?? -1; if (threadIndex === -1) { this.deleteIssueComment(commentId); @@ -1017,11 +1087,11 @@ export class PullRequestModel extends IssueModel implements IPullRe }); if (threadIndex > -1) { - const threadWithComment = this._reviewThreadsCache[threadIndex]; + const threadWithComment = this._reviewThreadsCache![threadIndex]; const index = threadWithComment.comments.findIndex(c => c.id === id); threadWithComment.comments.splice(index, 1); if (threadWithComment.comments.length === 0) { - this._reviewThreadsCache.splice(threadIndex, 1); + this._reviewThreadsCache?.splice(threadIndex, 1); this._onDidChangeReviewThreads.fire({ added: [], changed: [], removed: [threadWithComment] }); } else { this._onDidChangeReviewThreads.fire({ added: [], changed: [threadWithComment], removed: [] }); @@ -1161,11 +1231,55 @@ export class PullRequestModel extends IssueModel implements IPullRe } async updateBranch(model: ConflictResolutionModel): Promise { - if (this.item.mergeable === PullRequestMergeability.Conflict && (!model.resolvedConflicts || model.resolvedConflicts.size === 0)) { + 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(); @@ -1185,13 +1299,53 @@ export class PullRequestModel extends IssueModel implements IPullRe 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} to ${model.prBaseBranchName} failed: ${e}`, GitHubRepository.ID); + Logger.error(`Updating branch ${model.prHeadBranchName} with conflict resolution failed: ${e}`, GitHubRepository.ID); return false; } - Logger.debug(`Updating branch ${model.prHeadBranchName} to ${model.prBaseBranchName} - done`, GitHubRepository.ID); + 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. */ @@ -1240,21 +1394,26 @@ export class PullRequestModel extends IssueModel implements IPullRe input.botIds = reviewers.filter(r => r.accountType === AccountType.Bot).map(r => r.id); } - const { data } = await mutate({ + const { data } = await mutate({ mutation: schema.AddReviewers, variables: { input }, }); - if (!data?.repository) { + if (!data?.requestReviews) { Logger.error('Unexpected null repository while getting review requests', PullRequestModel.ID); return; } - const newReviewers: (IAccount | ITeam)[] = parseGraphQLReviewers(data, this.githubRepository); - if (this.reviewers?.length !== newReviewers.length || (this.reviewers.some(r => !newReviewers.some(rr => rr.id === r.id)))) { - this.reviewers = newReviewers; + 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 }); } } @@ -1264,6 +1423,9 @@ export class PullRequestModel extends IssueModel implements IPullRe * @param reviewer A GitHub Login */ async deleteReviewRequest(reviewers: IAccount[], teamReviewers: ITeam[]): Promise { + if (reviewers.length === 0 && teamReviewers.length === 0) { + return; + } const { octokit, remote } = await this.githubRepository.ensure(); await octokit.call(octokit.api.pulls.removeRequestedReviewers, { owner: remote.owner, @@ -1309,12 +1471,9 @@ export class PullRequestModel extends IssueModel implements IPullRe } async initializeReviewThreadCacheAndReviewComments(): Promise { - const { remote } = await this.githubRepository.ensure(); - const raw = await this.getRawReviewComments(); - - this.setReviewThreadCacheFromRaw(raw); + const threads = await this.initializeReviewThreadCache(); - this.comments = raw.map(node => node.comments.nodes.map(comment => parseGraphQLComment(comment, node.isResolved, this.githubRepository), remote)) + 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; @@ -1323,7 +1482,7 @@ export class PullRequestModel extends IssueModel implements IPullRe private setReviewThreadCacheFromRaw(raw: ReviewThread[]): IReviewThread[] { const reviewThreads: IReviewThread[] = raw.map(thread => parseGraphQLReviewThread(thread, this.githubRepository)); - const oldReviewThreads = this._reviewThreadsCache; + const oldReviewThreads = this._reviewThreadsCache ?? []; this._reviewThreadsCache = reviewThreads; this.diffThreads(oldReviewThreads, reviewThreads); return reviewThreads; @@ -1462,7 +1621,7 @@ export class PullRequestModel extends IssueModel implements IPullRe return vscode.commands.executeCommand('vscode.changes', vscode.l10n.t('Changes in Pull Request #{0}', pullRequestModel.number), args); } - static async openCommitChanges(githubRepository: GitHubRepository, commitSha: string) { + static async openCommitChanges(extensionUri: vscode.Uri, githubRepository: GitHubRepository, commitSha: string) { try { const parentCommit = await githubRepository.getCommitParent(commitSha); if (!parentCommit) { @@ -1472,7 +1631,8 @@ export class PullRequestModel extends IssueModel implements IPullRe const changes = await githubRepository.compareCommits(parentCommit, commitSha); if (!changes?.files || changes.files.length === 0) { - vscode.window.showInformationMessage(vscode.l10n.t('No changes found in commit {0}', commitSha.substring(0, 7))); + // Show a webview with the empty commit message instead of a notification + showEmptyCommitWebview(extensionUri, commitSha); return; } @@ -1609,6 +1769,11 @@ export class PullRequestModel extends IssueModel implements IPullRe return this._fileChanges; } + private _rawFileChangesCache: IRawFileChange[] | undefined; + get rawFileChanges(): IRawFileChange[] | undefined { + return this._rawFileChangesCache; + } + async getFileChangesInfo() { this._fileChanges.clear(); const data = await this.getRawFileChangesInfo(); @@ -1659,7 +1824,7 @@ export class PullRequestModel extends IssueModel implements IPullRe /** * List the changed files in a pull request. */ - private async getRawFileChangesInfo(): 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; @@ -1695,7 +1860,7 @@ export class PullRequestModel extends IssueModel implements IPullRe // Use the original base to compare against for merged PRs this.mergeBase = this.base.sha; - + this._rawFileChangesCache = response; return response; } @@ -1708,6 +1873,7 @@ export class PullRequestModel extends IssueModel implements IPullRe } Logger.debug(`Fetch file changes and merge base of PR #${this.number} - done, total files ${files.length} `, PullRequestModel.ID,); + this._rawFileChangesCache = files; return files; } @@ -1820,8 +1986,46 @@ export class PullRequestModel extends IssueModel implements IPullRe } } + /** + * Convert a pull request to draft. + */ + async convertToDraft(): Promise { + try { + const { mutate, schema } = await this.githubRepository.ensure(); + + const { data } = await mutate({ + mutation: schema.ConvertToDraft, + variables: { + input: { + pullRequestId: this.graphNodeId, + }, + }, + }); + + /* __GDPR__ + "pr.convertToDraft.success" : {} + */ + this._telemetry.sendTelemetryEvent('pr.convertToDraft.success'); + + const result: ConvertToDraft = { + isDraft: data!.convertPullRequestToDraft.pullRequest.isDraft, + mergeable: parseMergeability(data!.convertPullRequestToDraft.pullRequest.mergeable, data!.convertPullRequestToDraft.pullRequest.mergeStateStatus), + }; + this.item.isDraft = result.isDraft; + this.item.mergeable = result.mergeable; + this._onDidChange.fire({ draft: true }); + return result; + } catch (e) { + /* __GDPR__ + "pr.convertToDraft.failure" : {} + */ + this._telemetry.sendTelemetryErrorEvent('pr.convertToDraft.failure'); + throw e; + } + } + private updateCommentReactions(graphNodeId: string, reactionGroups: ReactionGroup[]) { - const reviewThread = this._reviewThreadsCache.find(thread => + const reviewThread = this._reviewThreadsCache?.find(thread => thread.comments.some(c => c.graphNodeId === graphNodeId), ); if (reviewThread) { @@ -1898,7 +2102,7 @@ export class PullRequestModel extends IssueModel implements IPullRe } async resolveReviewThread(threadId: string): Promise { - const oldThread = this._reviewThreadsCache.find(thread => thread.id === threadId); + const oldThread = this._reviewThreadsCache?.find(thread => thread.id === threadId); try { Logger.debug(`Resolve review thread - enter`, PullRequestModel.ID); @@ -1927,10 +2131,10 @@ export class PullRequestModel extends IssueModel implements IPullRe throw new Error('Resolve review thread failed.'); } - const index = this._reviewThreadsCache.findIndex(thread => thread.id === threadId); + 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._reviewThreadsCache?.splice(index, 1, thread); this._onDidChangeReviewThreads.fire({ added: [], changed: [thread], removed: [] }); } Logger.debug(`Resolve review thread - done`, PullRequestModel.ID); @@ -1941,7 +2145,7 @@ export class PullRequestModel extends IssueModel implements IPullRe } async unresolveReviewThread(threadId: string): Promise { - const oldThread = this._reviewThreadsCache.find(thread => thread.id === threadId); + const oldThread = this._reviewThreadsCache?.find(thread => thread.id === threadId); try { Logger.debug(`Unresolve review thread - enter`, PullRequestModel.ID); @@ -1970,10 +2174,10 @@ export class PullRequestModel extends IssueModel implements IPullRe throw new Error('Unresolve review thread failed.'); } - const index = this._reviewThreadsCache.findIndex(thread => thread.id === threadId); + 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._reviewThreadsCache?.splice(index, 1, thread); this._onDidChangeReviewThreads.fire({ added: [], changed: [thread], removed: [] }); } Logger.debug(`Unresolve review thread - done`, PullRequestModel.ID); @@ -2143,7 +2347,7 @@ export class PullRequestModel extends IssueModel implements IPullRe async markFiles(filePathOrSubpaths: string[], event: boolean, state: 'viewed' | 'unviewed'): Promise { const allFilenames = filePathOrSubpaths .map((f) => - isDescendant(this.githubRepository.rootUri.path, f, '/') + isDescendant(this.githubRepository.rootUri.path, f, false, '/') ? f.substring(this.githubRepository.rootUri.path.length + 1) : f ); diff --git a/src/github/pullRequestOverview.ts b/src/github/pullRequestOverview.ts index 74d71b35f8..c39e55f05d 100644 --- a/src/github/pullRequestOverview.ts +++ b/src/github/pullRequestOverview.ts @@ -4,9 +4,11 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; +import * as crypto from 'crypto'; import * as vscode from 'vscode'; -import { OpenCommitChangesArgs } from '../../common/views'; +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'; @@ -17,35 +19,39 @@ import { ITeam, MergeMethod, MergeMethodsAvailability, + PullRequestCheckStatus, PullRequestMergeability, - reviewerId, ReviewEventEnum, ReviewState, } from './interface'; -import { IssueOverviewPanel } from './issueOverview'; +import { IssueOverviewPanel, panelKey } from './issueOverview'; import { isCopilotOnMyBehalf, PullRequestModel } from './pullRequestModel'; -import { PullRequestView } from './pullRequestOverviewCommon'; -import { pickEmail, reviewersQuickPick } from './quickPicks'; -import { parseReviewers } from './utils'; -import { CancelCodingAgentReply, DeleteReviewResult, MergeArguments, MergeResult, PullRequest, ReviewType, SubmitReviewReply } from './views'; -import { IComment } from '../common/comment'; -import { COPILOT_SWE_AGENT, copilotEventToStatus, CopilotPRStatus, mostRecentCopilotEvent } from '../common/copilot'; +import { 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 { DEFAULT_MERGE_METHOD, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; +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; + /** - * 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 override currentPanel?: PullRequestOverviewPanel; + protected static override _panels: Map = new Map(); /** * Event emitter for when a PR overview becomes active @@ -56,15 +62,18 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel | undefined; + private _resolveCommentThreadQueue: Promise = Promise.resolve(); public static override async createOrShow( telemetry: ITelemetry, extensionUri: vscode.Uri, folderRepositoryManager: FolderRepositoryManager, - issue: PullRequestModel, + identity: UnresolvedIdentity, + issue?: PullRequestModel, toTheSide: boolean = false, preserveFocus: boolean = true, existingPanel?: vscode.WebviewPanel @@ -75,21 +84,18 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { + 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); + } + }), + ); } protected constructor( @@ -134,23 +207,13 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel this.approvePullRequestCommand(e))); - this._register(vscode.commands.registerCommand('review.commentDescription', (e) => this.submitReviewCommand(e))); - this._register(vscode.commands.registerCommand('review.requestChangesDescription', (e) => this.requestChangesCommand(e))); - this._register(vscode.commands.registerCommand('review.approveOnDotComDescription', () => { - return openPullRequestOnGitHub(this._item, this._telemetry); - })); - this._register(vscode.commands.registerCommand('review.requestChangesOnDotComDescription', () => { - return openPullRequestOnGitHub(this._item, this._telemetry); - })); } protected override registerPrListeners() { @@ -167,13 +230,45 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { - if ((e.state || e.comments) && !this._isUpdating) { + 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(); @@ -194,13 +289,37 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel 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; + 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 { - return this._item.isActive || vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get('experimentalUpdateBranchWithGitHub', false); + // 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; } protected override continueOnGitHub() { @@ -217,29 +336,14 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { - if (this._isUpdating) { - throw new Error('Already updating pull request webview'); + if (this._updatingPromise) { + Logger.error('Already updating pull request webview', PullRequestOverviewPanel.ID); + return; } - this._isUpdating = true; + this._item = pullRequestModel; + try { - const [ - pullRequest, - timelineEvents, - defaultBranch, - status, - requestedReviewers, - repositoryAccess, - branchInfo, - currentUser, - viewerCanEdit, - orgTeamsCount, - mergeQueueMethod, - isBranchUpToDateWithBase, - mergeability, - emailForCommit, - coAuthors, - hasReviewDraft - ] = await Promise.all([ + const updatingPromise = Promise.all([ this._folderRepositoryManager.resolvePullRequest( pullRequestModel.remote.owner, pullRequestModel.remote.repositoryName, @@ -260,7 +364,35 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { + 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; + if (!pullRequest) { throw new Error( `Fail to resolve Pull Request #${pullRequestModel.number} in ${pullRequestModel.remote.owner}/${pullRequestModel.remote.repositoryName}`, @@ -271,31 +403,42 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel 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.label, + base: `${pullRequest.base.owner}/${pullRequest.remote.repositoryName}:${pullRequest.base.ref}`, isRemoteHeadDeleted: pullRequest.isRemoteHeadDeleted, isLocalHeadDeleted: !branchInfo, - head: pullRequest.head?.label ?? '', + 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, @@ -308,7 +451,7 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { + 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', @@ -327,25 +483,34 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { + return this._folderRepositoryManager.resolvePullRequest( + identity.owner, + identity.repo, + identity.number + ); + } + + protected override getItemTypeName(): string { + return 'Pull Request'; + } + + public override async updateWithIdentity( folderRepositoryManager: FolderRepositoryManager, - pullRequestModel: PullRequestModel, + identity: UnresolvedIdentity, + pullRequestModel?: PullRequestModel, + progressLocation?: string ): Promise { - const result = super.update(folderRepositoryManager, pullRequestModel, 'pr:github'); - if (this._folderRepositoryManager !== folderRepositoryManager) { - this.registerPrListeners(); - } + await super.updateWithIdentity(folderRepositoryManager, identity, pullRequestModel, progressLocation); - await result; // Notify that this PR overview is now active - PullRequestOverviewPanel._onVisible.fire(pullRequestModel); - - return result; + PullRequestOverviewPanel._onVisible.fire(this._item); } protected override async _onDidReceiveMessage(message: IRequestMessage) { @@ -364,6 +529,10 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel): Promise { + const result = await addAttestationCommit(this._folderRepositoryManager, this._item); + this._replyMessage(message, { success: !!result, sha: result?.sha }); + } + private gotoChangesSinceReview(message: IRequestMessage): Promise { if (!this._item.showChangesSinceReview) { this._item.showChangesSinceReview = true; @@ -494,8 +682,9 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { - return this._item.getTimelineEvents(); + protected override async _getTimeline(): Promise { + const events = await this._item.getTimelineEvents(); + return this.processTimelineEvents(events); } private async openDiff(message: IRequestMessage<{ comment: IComment }>): Promise { @@ -507,14 +696,66 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel): Promise { + try { + const { file, startLine } = message.args; + const fileChanges = await this._item.getFileChangesInfo(); + const change = fileChanges.find( + fileChange => fileChange.fileName === file || fileChange.previousFileName === file, + ); + + 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); + } + + // Fallback to opening external URL + await vscode.env.openExternal(vscode.Uri.parse(message.args.href)); + } + private async openSessionLog(message: IRequestMessage<{ link: SessionLinkInfo }>): Promise { try { - return vscode.window.showChatSession(COPILOT_SWE_AGENT, SessionIdForPr.getId(this._item.number, message.args.link.sessionIndex), {}); + const resource = SessionIdForPr.getResource(this._item.number, message.args.link.sessionIndex); + return vscode.commands.executeCommand('vscode.open', resource); } catch (e) { Logger.error(`Open session log view failed: ${formatError(e)}`, PullRequestOverviewPanel.ID); } } + private async viewCheckLogs(message: IRequestMessage<{ status: PullRequestCheckStatus }>): Promise { + try { + 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) }); + } + } + private async cancelCodingAgent(message: IRequestMessage): Promise { try { let result = false; @@ -553,7 +794,7 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel): Promise { try { const { commitSha } = message.args; - await PullRequestModel.openCommitChanges(this._item.githubRepository, commitSha); + await PullRequestModel.openCommitChanges(this._extensionUri, this._item.githubRepository, commitSha); this._replyMessage(message, {}); } catch (error) { Logger.error(`Failed to open commit changes: ${formatError(error)}`, PullRequestOverviewPanel.ID); @@ -566,20 +807,32 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel) { - 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) { - vscode.window.showErrorMessage(e); - this._replyMessage(message, undefined); - } + 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 { @@ -604,6 +857,13 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel(DELETE_BRANCH_AFTER_MERGE, false); + if (deleteBranchAfterMerge) { + // Automatically delete the branch after successful merge + await PullRequestReviewCommon.autoDeleteBranchesAfterMerge(this._folderRepositoryManager, this._item); + } } const mergeResult: MergeResult = { @@ -627,7 +887,7 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel) { - const result = await PullRequestView.deleteBranch(this._folderRepositoryManager, this._item); + const result = await PullRequestReviewCommon.deleteBranch(this._folderRepositoryManager, this._item); if (result.isReply) { this._replyMessage(message, result.message); } else { @@ -636,101 +896,108 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel): void { - this._item - .setReadyForReview() - .then(result => { - this._replyMessage(message, result); - }) - .catch(e => { - vscode.window.showErrorMessage(`Unable to set pull request ready for review. ${formatError(e)}`); - this._throwError(message, ''); - }); + private async setReadyForReview(message: IRequestMessage<{}>): Promise { + return PullRequestReviewCommon.setReadyForReview(this.getReviewContext(), message); + } + + private async setReadyForReviewAndMerge(message: IRequestMessage<{ mergeMethod: MergeMethod }>): Promise { + return PullRequestReviewCommon.setReadyForReviewAndMerge(this.getReviewContext(), message); + } + + private async setConvertToDraft(message: IRequestMessage<{}>): Promise { + return PullRequestReviewCommon.setConvertToDraft(this.getReviewContext(), message); + } + + private async readyForReviewCommand(): Promise { + return PullRequestReviewCommon.readyForReviewCommand(this.getReviewContext()); + } + + private async readyForReviewAndMergeCommand(context: { mergeMethod: MergeMethod }): Promise { + return PullRequestReviewCommon.readyForReviewAndMergeCommand(this.getReviewContext(), context); } private async checkoutDefaultBranch(message: IRequestMessage): Promise { - try { - const prBranch = this._folderRepositoryManager.repository.state.HEAD?.name; - await this._folderRepositoryManager.checkoutDefaultBranch(message.args); - if (prBranch) { - await this._folderRepositoryManager.cleanupAfterPullRequest(prBranch, this._item); - } - } finally { - // Complete webview promise so that button becomes enabled again - this._replyMessage(message, {}); - } + return PullRequestReviewCommon.checkoutDefaultBranch(this.getReviewContext(), message); } - private updateReviewers(review?: ReviewEvent): void { - if (review && review.state) { - const existingReviewer = this._existingReviewers.find( - reviewer => review.user.login === (reviewer.reviewer as IAccount).login, - ); - if (existingReviewer) { - existingReviewer.state = review.state; - } else { - this._existingReviewers.push({ - reviewer: review.user, - state: review.state, - }); - } + private async doReviewCommand(context: { body: string }, reviewType: ReviewType, action: (body: string) => Promise, additionalEvents?: TimelineEvent[]) { + const result = await PullRequestReviewCommon.doReviewCommand( + this.getReviewContext(), + context, + reviewType, + !additionalEvents?.length, + action, + additionalEvents, + ); + if (result) { + this.tryScheduleCopilotRefresh(result.body, result.state); } } - private async doReviewCommand(context: { body: string }, reviewType: ReviewType, action: (body: string) => Promise) { - const submittingMessage = { - command: 'pr.submitting-review', - lastReviewType: reviewType - }; - this._postMessage(submittingMessage); - try { - const review = await action(context.body); - this.updateReviewers(review); - const allEvents = await this._getTimeline(); - const reviewMessage: SubmitReviewReply & { command: string } = { - command: 'pr.append-review', - reviewedEvent: review, - events: allEvents, - reviewers: this._existingReviewers - }; - this.tryScheduleCopilotRefresh(review.body, review.state); - await this._postMessage(reviewMessage); - } catch (e) { - vscode.window.showErrorMessage(vscode.l10n.t('Submitting review failed. {0}', formatError(e))); - this._throwError(undefined, `${formatError(e)}`); - } finally { - this._postMessage({ command: 'pr.append-review' }); + 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?.length, + action, + additionalEvents, + ); + if (result) { + this.tryScheduleCopilotRefresh(result.body, result.state); } } - private async doReviewMessage(message: IRequestMessage, action: (body) => Promise) { - try { - const review = await action(message.args); - this.updateReviewers(review); - const allEvents = await this._getTimeline(); - const reply: SubmitReviewReply = { - reviewedEvent: review, - events: allEvents, - reviewers: this._existingReviewers, - }; - this.tryScheduleCopilotRefresh(review.body, review.state); - this._replyMessage(message, reply); - } catch (e) { - vscode.window.showErrorMessage(vscode.l10n.t('Submitting review failed. {0}', formatError(e))); - this._throwError(message, `${formatError(e)}`); + /** + * 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); } - private approvePullRequest(body: string): Promise { - return this._item.approve(this._folderRepositoryManager.repository, body); + private approvePullRequest(body: string, expectedRemoteHead?: string): Promise { + return this._item.approve(this._folderRepositoryManager.repository, body, expectedRemoteHead); } - private approvePullRequestMessage(message: IRequestMessage): Promise { - return this.doReviewMessage(message, (body) => this.approvePullRequest(body)); + private approvePullRequestMessage(message: IRequestMessage): Promise { + return this.doReviewMessageWithAttestation(message, (body, expectedRemoteHead) => this.approvePullRequest(body, expectedRemoteHead)); } - private approvePullRequestCommand(context: { body: string }): Promise { - return this.doReviewCommand(context, ReviewType.Approve, (body) => this.approvePullRequest(body)); + private async approvePullRequestCommand(context: SubmitReviewArgs): Promise { + const result = context.addAttestation + ? await addAttestationCommit(this._folderRepositoryManager, this._item) + : undefined; + if (context.addAttestation && !result) { + return; + } + await this.doReviewCommand( + context, + ReviewType.Approve, + (body) => this.approvePullRequest(body, result?.sha), + result ? [result.event] : undefined, + ); } private requestChanges(body: string): Promise { @@ -741,8 +1008,8 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel this.requestChanges(body)); } - private requestChangesMessage(message: IRequestMessage): Promise { - return this.doReviewMessage(message, (body) => this.requestChanges(body)); + private requestChangesMessage(message: IRequestMessage): Promise { + return this.doReviewMessageWithAttestation(message, (body) => this.requestChanges(body)); } private submitReview(body: string): Promise { @@ -753,38 +1020,32 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel this.submitReview(body)); } - protected override submitReviewMessage(message: IRequestMessage) { - return this.doReviewMessage(message, (body) => this.submitReview(body)); + protected override submitReviewMessage(message: IRequestMessage) { + return this.doReviewMessageWithAttestation(message, (body) => this.submitReview(body)); } private reRequestReview(message: IRequestMessage): void { - let targetReviewer: ReviewState | undefined; - const userReviewers: IAccount[] = []; - const teamReviewers: ITeam[] = []; - - for (const reviewer of this._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); - } + return PullRequestReviewCommon.reRequestReview(this.getReviewContext(), message); + } - this._item.requestReview(userReviewers, teamReviewers, true).then(() => { - if (targetReviewer) { - targetReviewer.state = 'REQUESTED'; + private async addReviewerCopilot(message: IRequestMessage): Promise { + try { + const copilotUser = this._assignableUsers[this._item.remote.remoteName]?.find(user => COPILOT_ACCOUNTS[user.login]); + if (copilotUser) { + await this._item.requestReview([COPILOT_REVIEWER_ACCOUNT], []); + const newReviewers = await this._item.getReviewRequests(); + this._existingReviewers = parseReviewers(newReviewers!, await this._item.getTimelineEvents(), this._item.author); + const reply: ChangeReviewersReply = { + reviewers: this._existingReviewers + }; + this._replyMessage(message, reply); + } else { + this._throwError(message, 'Copilot reviewer not found.'); } - this._replyMessage(message, { - reviewers: this._existingReviewers, - }); - }); + } catch (e) { + vscode.window.showErrorMessage(formatError(e)); + this._throwError(message, formatError(e)); + } } private async revert(message: IRequestMessage): Promise { @@ -818,39 +1079,29 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel): Promise { const result = await this._item.enqueuePullRequest(); - this._replyMessage(message, { mergeQueueEntry: result }); - } - private async updateBranch(message: IRequestMessage): Promise { - if (!this.isUpdateBranchWithGitHubEnabled()) { - await vscode.window.showErrorMessage(vscode.l10n.t('The pull request branch must be checked out to be updated.'), { modal: true }); - return this._replyMessage(message, {}); + // 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.')); + } } - if (this._folderRepositoryManager.repository.state.workingTreeChanges.length > 0 || this._folderRepositoryManager.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 this._replyMessage(message, {}); - } - const mergeSucceeded = await this._folderRepositoryManager.tryMergeBaseIntoHead(this._item, true); - if (!mergeSucceeded) { - this._replyMessage(message, {}); - } - // The mergability of the PR doesn't update immediately. Poll. - let mergability = PullRequestMergeability.Unknown; - let attemptsRemaining = 5; - do { - mergability = (await this._item.getMergeability()).mergeability; - attemptsRemaining--; - await new Promise(c => setTimeout(c, 1000)); - } while (attemptsRemaining > 0 && mergability === PullRequestMergeability.Unknown); - - const result: Partial = { - events: await this._getTimeline(), - mergeable: mergability, - }; - await this.refreshPanel(); + this._replyMessage(message, { mergeQueueEntry: result }); + } - this._replyMessage(message, result); + private async updateBranch(message: IRequestMessage): Promise { + return PullRequestReviewCommon.updateBranch( + this.getReviewContext(), + message, + () => this.refreshPanel(), + () => this.isUpdateBranchWithGitHubEnabled() + ); } protected override editCommentPromise(comment: IComment, text: string): Promise { @@ -872,6 +1123,133 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel): 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 }); + } + } + + 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 (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); diff --git a/src/github/pullRequestOverviewCommon.ts b/src/github/pullRequestOverviewCommon.ts deleted file mode 100644 index bd069dc329..0000000000 --- a/src/github/pullRequestOverviewCommon.ts +++ /dev/null @@ -1,148 +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 { FolderRepositoryManager } from './folderRepositoryManager'; -import { PullRequestModel } from './pullRequestModel'; -import { - DEFAULT_DELETION_METHOD, - PR_SETTINGS_NAMESPACE, - SELECT_LOCAL_BRANCH, - SELECT_REMOTE, -} from '../common/settingKeys'; -import { Schemes } from '../common/uri'; - -export namespace PullRequestView { - export async function deleteBranch(folderRepositoryManager: FolderRepositoryManager, item: PullRequestModel): Promise<{ isReply: boolean, message: any }> { - const branchInfo = await folderRepositoryManager.getBranchNameForPullRequest(item); - const actions: (vscode.QuickPickItem & { type: 'remoteHead' | 'local' | 'remote' | 'suspend' })[] = []; - const defaultBranch = await folderRepositoryManager.getPullRequestRepositoryDefaultBranch(item); - - if (item.isResolved()) { - const branchHeadRef = item.head.ref; - const headRepo = folderRepositoryManager.findRepo(repo => repo.remote.owner === item.head.owner && repo.remote.repositoryName === item.remote.repositoryName); - - const isDefaultBranch = defaultBranch === item.head.ref; - if (!isDefaultBranch && !item.isRemoteHeadDeleted) { - actions.push({ - label: vscode.l10n.t('Delete remote branch {0}', `${headRepo?.remote.remoteName}/${branchHeadRef}`), - description: `${item.remote.normalizedHost}/${item.head.repositoryCloneUrl.owner}/${item.remote.repositoryName}`, - type: 'remoteHead', - picked: true, - }); - } - } - - if (branchInfo) { - const preferredLocalBranchDeletionMethod = vscode.workspace - .getConfiguration(PR_SETTINGS_NAMESPACE) - .get(`${DEFAULT_DELETION_METHOD}.${SELECT_LOCAL_BRANCH}`); - actions.push({ - label: vscode.l10n.t('Delete local branch {0}', branchInfo.branch), - type: 'local', - picked: !!preferredLocalBranchDeletionMethod, - }); - - const preferredRemoteDeletionMethod = vscode.workspace - .getConfiguration(PR_SETTINGS_NAMESPACE) - .get(`${DEFAULT_DELETION_METHOD}.${SELECT_REMOTE}`); - - if (branchInfo.remote && branchInfo.createdForPullRequest && !branchInfo.remoteInUse) { - actions.push({ - label: vscode.l10n.t('Delete remote {0}, which is no longer used by any other branch', branchInfo.remote), - type: 'remote', - picked: !!preferredRemoteDeletionMethod, - }); - } - } - - if (vscode.env.remoteName === 'codespaces') { - actions.push({ - label: vscode.l10n.t('Suspend Codespace'), - type: 'suspend' - }); - } - - if (!actions.length) { - vscode.window.showWarningMessage( - vscode.l10n.t('There is no longer an upstream or local branch for Pull Request #{0}', item.number), - ); - return { - isReply: true, - message: { - cancelled: true - } - }; - } - - const selectedActions = await vscode.window.showQuickPick(actions, { - canPickMany: true, - ignoreFocusOut: true, - }); - - const deletedBranchTypes: string[] = []; - - if (selectedActions) { - const isBranchActive = item.equals(folderRepositoryManager.activePullRequest) || (folderRepositoryManager.repository.state.HEAD?.name && folderRepositoryManager.repository.state.HEAD.name === branchInfo?.branch); - - const promises = selectedActions.map(async action => { - switch (action.type) { - case 'remoteHead': - await folderRepositoryManager.deleteBranch(item); - deletedBranchTypes.push(action.type); - await folderRepositoryManager.repository.fetch({ prune: true }); - // If we're in a remote repository, then we should checkout the default branch. - if (folderRepositoryManager.repository.rootUri.scheme === Schemes.VscodeVfs) { - await folderRepositoryManager.repository.checkout(defaultBranch); - } - return; - case 'local': - if (isBranchActive) { - if (folderRepositoryManager.repository.state.workingTreeChanges.length) { - const yes = vscode.l10n.t('Yes'); - const response = await vscode.window.showWarningMessage( - vscode.l10n.t('Your local changes will be lost, do you want to continue?'), - { modal: true }, - yes, - ); - if (response === yes) { - await vscode.commands.executeCommand('git.cleanAll'); - } else { - return; - } - } - await folderRepositoryManager.checkoutDefaultBranch(defaultBranch); - } - 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 { - isReply: false, - message: { - command: 'pr.deleteBranch', - branchTypes: deletedBranchTypes - } - }; - } else { - return { - isReply: true, - message: { - cancelled: true - } - }; - } - } -} \ No newline at end of file diff --git a/src/github/pullRequestReviewCommon.ts b/src/github/pullRequestReviewCommon.ts new file mode 100644 index 0000000000..143f627702 --- /dev/null +++ b/src/github/pullRequestReviewCommon.ts @@ -0,0 +1,573 @@ +/*--------------------------------------------------------------------------------------------- + * 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); + // 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, + canUpdateBranch: !mergeSucceeded, + }; + 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 524ed6f65c..42c84317c6 100644 --- a/src/github/queries.gql +++ b/src/github/queries.gql @@ -110,6 +110,9 @@ fragment IssueBase on Issue { } url } + issueType { + name + } } fragment IssueFragment on Issue { @@ -224,6 +227,15 @@ fragment PullRequestFragment on PullRequest { mergeCommitMessage mergeCommitTitle } + closingIssuesReferences(first: 50) { + nodes { + databaseId + number + title + state + url + } + } merged mergeable mergeQueueEntry { @@ -763,4 +775,13 @@ mutation ReplaceActorsForAssignable($input: ReplaceActorsForAssignableInput!) { } } } +} + +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 index 45967950d4..3fa99a892f 100644 --- a/src/github/queriesExtra.gql +++ b/src/github/queriesExtra.gql @@ -119,6 +119,9 @@ fragment IssueBase on Issue { } } } + issueType { + name + } } fragment IssueFragment on Issue { @@ -237,6 +240,15 @@ fragment PullRequestFragment on PullRequest { mergeCommitMessage mergeCommitTitle } + closingIssuesReferences(first: 50) { + nodes { + databaseId + number + title + state + url + } + } merged mergeable mergeQueueEntry { @@ -806,4 +818,13 @@ mutation EnqueuePullRequest($input: EnqueuePullRequestInput!) { ...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 index 9ce0dffa9d..8c095dcf51 100644 --- a/src/github/queriesLimited.gql +++ b/src/github/queriesLimited.gql @@ -206,6 +206,15 @@ fragment PullRequestFragment on PullRequest { } url } + closingIssuesReferences(first: 50) { + nodes { + databaseId + number + title + state + url + } + } merged mergeable mergeStateStatus diff --git a/src/github/queriesShared.gql b/src/github/queriesShared.gql index 7716dd9a4f..833bd796f6 100644 --- a/src/github/queriesShared.gql +++ b/src/github/queriesShared.gql @@ -123,6 +123,28 @@ fragment Commit on PullRequestCommit { oid message committedDate + signature { + isValid + state + wasSignedByGitHub + signer { + login + name + avatarUrl + } + ... on GpgSignature { + keyId + } + ... on SshSignature { + keyFingerprint + } + ... on SmimeSignature { + email + } + } + statusCheckRollup { + state + } } url } @@ -207,6 +229,17 @@ fragment ReopenedEvent on ReopenedEvent { createdAt } +fragment BaseRefChangedEvent on BaseRefChangedEvent { + id + actor { + ...Node + ...Actor + } + createdAt + currentRefName + previousRefName +} + fragment Review on PullRequestReview { id databaseId @@ -309,6 +342,27 @@ query TimelineEvents($owner: String!, $name: String!, $number: Int!, $last: Int ...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 } } } @@ -339,12 +393,14 @@ query IssueTimelineEvents($owner: String!, $name: String!, $number: Int!, $last: } } -query LatestReviewCommit($owner: String!, $name: String!, $number: Int!) { +query LatestReviewCommit($owner: String!, $name: String!, $number: Int!, $author: String!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { - viewerLatestReview { - commit { - oid + reviews(last: 1, author: $author, states: [APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED]) { + nodes { + commit { + oid + } } } } @@ -683,6 +739,27 @@ query PullRequestTemplates($owner: String!, $name: String!) { } } +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 { @@ -735,6 +812,16 @@ mutation ReadyForReview($input: MarkPullRequestReadyForReviewInput!) { } } +mutation ConvertToDraft($input: ConvertPullRequestToDraftInput!) { + convertPullRequestToDraft(input: $input) { + pullRequest { + isDraft + mergeable + mergeStateStatus + } + } +} + mutation StartReview($input: AddPullRequestReviewInput!) { addPullRequestReview(input: $input) { pullRequestReview { @@ -877,9 +964,9 @@ query GetBranch($owner: String!, $name: String!, $qualifiedName: String!) { } } -query ListBranches($owner: String!, $name: String!, $first: Int!, $after: String) { +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/") { + refs(first: $first, after: $after, refPrefix: "refs/heads/", query: $query) { nodes { name } @@ -923,9 +1010,6 @@ query MaxPullRequest($owner: String!, $name: String!) { } } } - rateLimit { - ...RateLimit - } } query GetMilestones($owner: String!, $name: String!, $states: [MilestoneState!]!) { @@ -1020,6 +1104,7 @@ query GetChecks($owner: String!, $name: String!, $number: Int!) { } ... on CheckRun { id + databaseId conclusion title detailsUrl @@ -1042,6 +1127,22 @@ query GetChecks($owner: String!, $name: String!, $number: Int!) { } } } + checkSuites(first: 100) { + nodes { + status + conclusion + workflowRun { + event + workflow { + name + } + } + app { + logoUrl + url + } + } + } } } } @@ -1103,6 +1204,7 @@ query GetChecksWithoutSuite($owner: String!, $name: String!, $number: Int!) { } ... on CheckRun { id + databaseId conclusion title detailsUrl @@ -1113,6 +1215,18 @@ query GetChecksWithoutSuite($owner: String!, $name: String!, $number: Int!) { } } } + checkSuites(first: 100) { + nodes { + status + conclusion + workflowRun { + event + workflow { + name + } + } + } + } } } } @@ -1227,6 +1341,7 @@ mutation MergePullRequest($input: MergePullRequestInput!, $last: Int = 150) { ...CrossReferencedEvent ...ClosedEvent ...ReopenedEvent + ...BaseRefChangedEvent } } } diff --git a/src/github/quickPicks.ts b/src/github/quickPicks.ts index 78e723db45..52f681d4f4 100644 --- a/src/github/quickPicks.ts +++ b/src/github/quickPicks.ts @@ -8,15 +8,18 @@ import { Buffer } from 'buffer'; import * as vscode from 'vscode'; import { FolderRepositoryManager } from './folderRepositoryManager'; import { GitHubRepository, TeamReviewerRefreshKind } from './githubRepository'; -import { AccountType, IAccount, ILabel, IMilestone, IProject, isISuggestedReviewer, isITeam, ISuggestedReviewer, ITeam, reviewerId, ReviewState } from './interface'; +import { 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_ID, COPILOT_SWE_AGENT } from '../common/copilot'; +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[], @@ -191,15 +194,7 @@ async function getReviewersQuickPickItems(folderRepositoryManager: FolderReposit // If we removed the coding agent, add the Copilot reviewer instead if (hasCopilotSweAgent && !existingReviewers.find(user => (user.reviewer as IAccount).login === COPILOT_REVIEWER)) { - const copilotReviewer: IAccount = { - login: COPILOT_REVIEWER, - id: COPILOT_REVIEWER_ID, - url: '', - avatarUrl: '', - name: COPILOT_ACCOUNTS[COPILOT_REVIEWER]?.name ?? 'Copilot', - accountType: AccountType.Bot - }; - assignableUsers.push(copilotReviewer); + assignableUsers.push(COPILOT_REVIEWER_ACCOUNT); } // Suggested reviewers @@ -479,4 +474,97 @@ export async function pickEmail(githubRepository: GitHubRepository, current: str 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 e2fce7a325..beff98508b 100644 --- a/src/github/repositoriesManager.ts +++ b/src/github/repositoriesManager.ts @@ -91,10 +91,39 @@ export class RepositoriesManager extends Disposable { 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); } + 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); @@ -127,7 +156,7 @@ export class RepositoriesManager extends Disposable { const folderManager = this._folderManagers[existingFolderManagerIndex]; disposeAll(this._subs.get(folderManager)!); this._subs.delete(folderManager); - this._folderManagers.splice(existingFolderManagerIndex); + this._folderManagers.splice(existingFolderManagerIndex, 1); folderManager.dispose(); this.updateActiveReviewCount(); this._onDidChangeFolderRepositories.fire({}); diff --git a/src/github/utils.ts b/src/github/utils.ts index fba9d565ec..9188a4cea0 100644 --- a/src/github/utils.ts +++ b/src/github/utils.ts @@ -13,6 +13,7 @@ import { GitHubRepository, ViewerPermission } from './githubRepository'; import * as GraphQL from './graphql'; import { AccountType, + GithubItemStateEnum, IAccount, IActor, IGitHubRef, @@ -21,6 +22,7 @@ import { IMilestone, IProjectItem, Issue, + IssueReference, ISuggestedReviewer, ITeam, MergeMethod, @@ -39,12 +41,13 @@ import { } 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_SWE_AGENT } from '../common/copilot'; +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'; @@ -53,11 +56,25 @@ import { Remote } from '../common/remote'; import { GITHUB_ENTERPRISE, OVERRIDE_DEFAULT_BRANCH, PR_SETTINGS_NAMESPACE, URI } from '../common/settingKeys'; import * as Common from '../common/timelineEvent'; import { DataUri, toOpenIssueWebviewUri, toOpenPullRequestWebviewUri } from '../common/uri'; -import { escapeRegExp, gitHubLabelColor, stringReplaceAsync, uniqBy } from '../common/utils'; +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; } @@ -102,6 +119,10 @@ export function threadRange(startLine: number, endLine: number, endCharacter?: n 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] }; @@ -152,15 +173,22 @@ function isResolvedToResolvedState(isResolved: boolean) { export const COMMENT_EXPAND_STATE_SETTING = 'commentExpandState'; export const COMMENT_EXPAND_STATE_COLLAPSE_VALUE = 'collapseAll'; +export const COMMENT_EXPAND_STATE_COLLAPSE_PREEXISTING_VALUE = 'collapsePreexisting'; export const COMMENT_EXPAND_STATE_EXPAND_VALUE = 'expandUnresolved'; -export function getCommentCollapsibleState(thread: IReviewThread, expand?: boolean, currentUser?: string) { +export function getCommentCollapsibleState(thread: IReviewThread, expand?: boolean, currentUser?: string, isNewlyAdded?: boolean) { + const config = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE)?.get(COMMENT_EXPAND_STATE_SETTING); const isFromCurrent = (currentUser && (thread.comments[thread.comments.length - 1].user?.login === currentUser)); const isJustSuggestion = thread.comments.length === 1 && thread.comments[0].body.startsWith('```suggestion') && thread.comments[0].body.endsWith('```'); + + // When collapsePreexisting is set, keep newly added comments expanded + if (config === COMMENT_EXPAND_STATE_COLLAPSE_PREEXISTING_VALUE && isNewlyAdded && !isJustSuggestion) { + return vscode.CommentThreadCollapsibleState.Expanded; + } + if (thread.isResolved || (!thread.isOutdated && isFromCurrent && !isJustSuggestion)) { return vscode.CommentThreadCollapsibleState.Collapsed; } if (expand === undefined) { - const config = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE)?.get(COMMENT_EXPAND_STATE_SETTING); expand = config === COMMENT_EXPAND_STATE_EXPAND_VALUE; } return expand @@ -168,7 +196,7 @@ export function getCommentCollapsibleState(thread: IReviewThread, expand?: boole } -export function updateThreadWithRange(context: vscode.ExtensionContext, vscodeThread: GHPRCommentThread, reviewThread: IReviewThread, githubRepositories?: GitHubRepository[], expand?: boolean) { +export function updateThreadWithRange(context: vscode.ExtensionContext, vscodeThread: GHPRCommentThread, reviewThread: IReviewThread, githubRepositories?: GitHubRepository[], expand?: boolean, isNewlyAdded?: boolean) { if (!vscodeThread.range) { return; } @@ -177,13 +205,13 @@ export function updateThreadWithRange(context: vscode.ExtensionContext, vscodeTh 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); + 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) { +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) { @@ -202,7 +230,7 @@ export function updateThread(context: vscode.ExtensionContext, vscodeThread: GHP applicability: newApplicabilityState }; } - vscodeThread.collapsibleState = getCommentCollapsibleState(reviewThread, expand); + vscodeThread.collapsibleState = getCommentCollapsibleState(reviewThread, expand, currentUser, isNewlyAdded); if (range) { vscodeThread.range = range; } @@ -321,6 +349,61 @@ async function transformHtmlUrlsToExtensionUrls(body: string, githubRepository: }); } +/** + * 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 @@ -490,6 +573,8 @@ export function convertGraphQLEventType(text: string) { return Common.EventType.Closed; case 'ReopenedEvent': return Common.EventType.Reopened; + case 'BaseRefChangedEvent': + return Common.EventType.BaseRefChanged; default: return Common.EventType.Other; } @@ -511,12 +596,12 @@ export function parseGraphQLReviewThread(thread: GraphQL.ReviewThread, githubRep originalEndLine: thread.originalLine, diffSide: thread.diffSide, isOutdated: thread.isOutdated, - comments: thread.comments.nodes.map(comment => parseGraphQLComment(comment, thread.isResolved, githubRepository)), + comments: thread.comments.nodes.map(comment => parseGraphQLComment(comment, thread.isResolved, thread.isOutdated, githubRepository, thread.id)), subjectType: thread.subjectType ?? SubjectType.LINE }; } -export function parseGraphQLComment(comment: GraphQL.ReviewComment, isResolved: boolean, githubRepository: GitHubRepository): IComment { +export function parseGraphQLComment(comment: GraphQL.ReviewComment, isResolved: boolean, isOutdated: boolean, githubRepository: GitHubRepository, threadId?: string): IComment { const specialAuthor = COPILOT_ACCOUNTS[comment.author?.login ?? '']; const c: IComment = { id: comment.databaseId, @@ -541,6 +626,8 @@ export function parseGraphQLComment(comment: GraphQL.ReviewComment, isResolved: inReplyToId: comment.replyTo && comment.replyTo.databaseId, reactions: parseGraphQLReaction(comment.reactionGroups), isResolved, + isOutdated, + threadId }; const diffHunks = parseCommentDiffHunk(c); @@ -563,7 +650,7 @@ export function parseGraphQlIssueComment(comment: GraphQL.IssueComment, githubRe htmlUrl: comment.url, graphNodeId: comment.id, diffHunk: '', - reactions: parseGraphQLReaction(comment.reactionGroups), + reactions: parseGraphQLReaction(comment.reactionGroups) }; } @@ -695,7 +782,7 @@ export function parseGraphQLReviewers(data: GraphQL.GetReviewRequestsResponse, r if (GraphQL.isTeam(reviewer.requestedReviewer)) { const team: ITeam = parseTeam(reviewer.requestedReviewer, repository); reviewers.push(team); - } else if (GraphQL.isAccount(reviewer.requestedReviewer)) { + } else if (GraphQL.isAccount(reviewer.requestedReviewer) || GraphQL.isBot(reviewer.requestedReviewer)) { const account: IAccount = parseAccount(reviewer.requestedReviewer, repository); reviewers.push(account); } @@ -853,6 +940,7 @@ export async function parseGraphQLPullRequest( 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); @@ -965,7 +1053,8 @@ export async function parseGraphQLIssue(issue: GraphQL.Issue, githubRepository: comments: issue.comments.nodes?.map(comment => parseIssueComment(comment, githubRepository)), reactionCount: issue.reactions.totalCount, reactions: parseGraphQLReaction(issue.reactionGroups), - commentCount: issue.comments.totalCount + commentCount: issue.comments.totalCount, + issueType: issue.issueType?.name }; } @@ -997,6 +1086,22 @@ function parseSuggestedReviewers( 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 */ @@ -1020,7 +1125,7 @@ export function parseGraphQLReviewEvent( ): Common.ReviewEvent { return { event: Common.EventType.Reviewed, - comments: review.comments.nodes.map(comment => parseGraphQLComment(comment, false, githubRepository)).filter(c => !c.inReplyToId), + comments: review.comments.nodes.map(comment => parseGraphQLComment(comment, false, false, githubRepository)).filter(c => !c.inReplyToId), submittedAt: review.submittedAt, body: review.body, bodyHTML: review.bodyHTML, @@ -1049,9 +1154,9 @@ export function parseSelectRestTimelineEvents( let sessionIndex = 0; for (const event of events) { - const eventNode = event as { created_at?: string; node_id?: string; actor: RestAccount }; + 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') { + 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, @@ -1062,7 +1167,7 @@ export function parseSelectRestTimelineEvents( sessionIndex } }); - } else if (event.event === 'copilot_work_finished') { + } 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, @@ -1082,6 +1187,12 @@ export function parseSelectRestTimelineEvents( 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, + }); } } } @@ -1103,6 +1214,7 @@ export function eventTime(event: Common.TimelineEvent): Date | undefined { 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); @@ -1120,6 +1232,7 @@ export async function parseCombinedTimelineEvents( | GraphQL.AssignedEvent | GraphQL.HeadRefDeletedEvent | GraphQL.CrossReferencedEvent + | GraphQL.BaseRefChangedEvent | null )[], restEvents: Common.TimelineEvent[], @@ -1200,6 +1313,20 @@ export async function parseCombinedTimelineEvents( 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: @@ -1252,6 +1379,9 @@ export async function parseCombinedTimelineEvents( 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 }) @@ -1293,6 +1423,18 @@ export async function parseCombinedTimelineEvents( 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; } @@ -1410,11 +1552,11 @@ export async function restPaginate(r } export function getRelatedUsersFromTimelineEvents( - timelineEvents: Common.TimelineEvent[], + timelineEvents: Common.TimelineEvent[] | undefined, ): { login: string; name: string }[] { const ret: { login: string; name: string }[] = []; - timelineEvents.forEach(event => { + timelineEvents?.forEach(event => { if (event.event === Common.EventType.Committed) { ret.push({ login: event.author.login, @@ -1484,10 +1626,10 @@ export function getRepositoryForFile(gitAPI: GitApiImpl, file: vscode.Uri): Repo */ export function parseReviewers( requestedReviewers: (IAccount | ITeam)[], - timelineEvents: Common.TimelineEvent[], + 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'); + 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(); @@ -1570,37 +1712,56 @@ export function insertNewCommitsSinceReview( currentUser: string, head: GitHubRef | null ) { - if (latestReviewCommitOid && head && head.sha !== latestReviewCommitOid) { - let lastViewerReviewIndex: number = timelineEvents.length - 1; - let comittedDuringReview: boolean = false; - let interReviewCommits: Common.TimelineEvent[] = []; - - for (let i = timelineEvents.length - 1; i > 0; i--) { - if ( - timelineEvents[i].event === Common.EventType.Committed && - (timelineEvents[i] as Common.CommitEvent).sha === latestReviewCommitOid - ) { - interReviewCommits.unshift({ - id: latestReviewCommitOid, - event: Common.EventType.NewCommitsSinceReview - }); - timelineEvents.splice(lastViewerReviewIndex + 1, 0, ...interReviewCommits); + 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; } - else if (comittedDuringReview && timelineEvents[i].event === Common.EventType.Committed) { - interReviewCommits.unshift(timelineEvents[i]); - timelineEvents.splice(i, 1); - } - else if ( - !comittedDuringReview && - timelineEvents[i].event === Common.EventType.Reviewed && - (timelineEvents[i] as Common.ReviewEvent).user.login === currentUser - ) { - lastViewerReviewIndex = i; - comittedDuringReview = true; - } } } + + 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 { @@ -1608,6 +1769,28 @@ export function getPRFetchQuery(user: string, query: string): string { 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; } @@ -1716,6 +1899,15 @@ export function variableSubstitution( 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; @@ -1786,6 +1978,12 @@ export function vscodeDevPrLink(pullRequest: IssueModel) { 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); @@ -1799,7 +1997,8 @@ export enum UnsatisfiedChecks { ReviewRequired = 1 << 0, ChangesRequested = 1 << 1, CIFailed = 1 << 2, - CIPending = 1 << 3 + CIPending = 1 << 3, + Unknown = 1 << 4 } export async function extractRepoFromQuery(folderManager: FolderRepositoryManager, query: string | undefined): Promise { @@ -1818,4 +2017,4 @@ export async function extractRepoFromQuery(folderManager: FolderRepositoryManage } return undefined; -} \ No newline at end of file +} diff --git a/src/github/views.ts b/src/github/views.ts index 70a9dc245a..0d0f2e3f37 100644 --- a/src/github/views.ts +++ b/src/github/views.ts @@ -9,6 +9,7 @@ import { ILabel, IMilestone, IProjectItem, + IssueReference, MergeMethod, MergeMethodsAvailability, MergeQueueState, @@ -67,6 +68,7 @@ export interface Issue { isDarkTheme: boolean; isEnterprise: boolean; canAssignCopilot: boolean; + canRequestCopilotReview: boolean; reactions: Reaction[]; busy?: boolean; } @@ -82,6 +84,7 @@ export interface PullRequest extends Issue { commitsCount: number; projectItems: IProjectItem[] | undefined; repositoryDefaultBranch: string; + doneCheckoutBranch: string; emailForCommit?: string; pendingReviewType?: ReviewType; status: PullRequestChecks | null; @@ -109,6 +112,9 @@ export interface PullRequest extends Issue { revertable?: boolean; busy?: boolean; loadingCommit?: string; + generateDescriptionTitle?: string; + closingIssues?: IssueReference[]; + attestationCommitsEnabled?: boolean; } export interface ProjectItemsReply { @@ -120,12 +126,39 @@ export interface ChangeAssigneesReply { 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; @@ -151,11 +184,36 @@ export enum PreReviewState { ReviewedWithoutComments } +export interface ChangeTemplateReply { + description: string; +} + export interface CancelCodingAgentReply { events: TimelineEvent[]; } -export interface OverviewContext { +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; @@ -163,7 +221,53 @@ export interface OverviewContext { [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; + addAttestation?: boolean; + '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/issues/issueCompletionProvider.ts b/src/issues/issueCompletionProvider.ts index db2506f354..d2ccdcaaf7 100644 --- a/src/issues/issueCompletionProvider.ts +++ b/src/issues/issueCompletionProvider.ts @@ -6,6 +6,7 @@ import * as vscode from 'vscode'; import { IGNORE_COMPLETION_TRIGGER, + ISSUE_COMPLETION_FORMAT_EDITOR, ISSUE_COMPLETION_FORMAT_SCM, ISSUES_SETTINGS_NAMESPACE, } from '../common/settingKeys'; @@ -100,7 +101,9 @@ export class IssueCompletionProvider implements vscode.CompletionItemProvider { return []; } - if ((document.languageId !== 'scminput') && (document.languageId !== 'git-commit') && !(await isComment(document, position))) { + const isPositionComment = document.languageId === 'plaintext' || document.languageId === 'markdown' || await isComment(document, position); + + if ((document.languageId !== 'scminput') && (document.languageId !== 'git-commit') && !isPositionComment) { return []; } @@ -206,11 +209,15 @@ export class IssueCompletionProvider implements vscode.CompletionItemProvider { if (document.languageId === 'markdown') { item.insertText = `[${getIssueNumberLabel(issue, repo)}](${issue.html_url})`; } else { - const configuration = vscode.workspace + let completionFormatSetting = ISSUE_COMPLETION_FORMAT_EDITOR; + if (document.uri.path.match(/git\/scm\d\/input/)) { + completionFormatSetting = ISSUE_COMPLETION_FORMAT_SCM; + } + const completionFormat = 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); + .get(completionFormatSetting); + if (typeof completionFormat === 'string') { + item.insertText = variableSubstitution(completionFormat, issue, repo); } else { item.insertText = `${getIssueNumberLabel(issue, repo)}`; } diff --git a/src/issues/issueFeatureRegistrar.ts b/src/issues/issueFeatureRegistrar.ts index cd575b7d33..c0edf0e1e7 100644 --- a/src/issues/issueFeatureRegistrar.ts +++ b/src/issues/issueFeatureRegistrar.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { basename } from 'path'; +import * as yaml from 'js-yaml'; import * as vscode from 'vscode'; import { CurrentIssue } from './currentIssue'; import { IssueCompletionProvider } from './issueCompletionProvider'; @@ -20,6 +21,7 @@ import { ISSUE_COMPLETIONS, ISSUES_SETTINGS_NAMESPACE, USER_COMPLETIONS, + WORKING_BASE_BRANCH, } from '../common/settingKeys'; import { editQuery } from '../common/settingsUtils'; import { ITelemetry } from '../common/telemetry'; @@ -56,17 +58,15 @@ import { PermalinkInfo, pushAndCreatePR, USER_EXPRESSION, + YamlIssueTemplate, } from './util'; -import { truncate } from '../common/utils'; import { OctokitCommon } from '../github/common'; -import { CopilotRemoteAgentManager } from '../github/copilotRemoteAgent'; import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; import { IProject } from '../github/interface'; import { IssueModel } from '../github/issueModel'; import { IssueOverviewPanel } from '../github/issueOverview'; import { RepositoriesManager } from '../github/repositoriesManager'; import { ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput } from '../github/utils'; -import { chatCommand } from '../lm/utils'; import { ReviewManager } from '../view/reviewManager'; import { ReviewsManager } from '../view/reviewsManager'; import { PRNode } from '../view/treeNodes/pullRequestNode'; @@ -75,7 +75,6 @@ const CREATING_ISSUE_FROM_FILE_CONTEXT = 'issues.creatingFromFile'; export class IssueFeatureRegistrar extends Disposable { private static readonly ID = 'IssueFeatureRegistrar'; - private _stateManager: StateManager; private _newIssueCache: NewIssueCache; private createIssueInfo: @@ -93,10 +92,9 @@ export class IssueFeatureRegistrar extends Disposable { private reviewsManager: ReviewsManager, private context: vscode.ExtensionContext, private telemetry: ITelemetry, - private copilotRemoteAgentManager: CopilotRemoteAgentManager, + private readonly _stateManager: StateManager ) { super(); - this._stateManager = new StateManager(gitAPI, this.manager, this.context); this._newIssueCache = new NewIssueCache(context); } @@ -143,19 +141,6 @@ export class IssueFeatureRegistrar extends Disposable { this, ), ); - this._register( - vscode.commands.registerCommand( - 'issue.startCodingAgentFromTodo', - (todoInfo?: { document: vscode.TextDocument; lineNumber: number; line: string; insertIndex: number; range: vscode.Range }) => { - /* __GDPR__ - "issue.startCodingAgentFromTodo" : {} - */ - this.telemetry.sendTelemetryEvent('issue.startCodingAgentFromTodo'); - return this.startCodingAgentFromTodo(todoInfo); - }, - this, - ), - ); this._register( vscode.commands.registerCommand( 'issue.assignToCodingAgent', @@ -309,6 +294,52 @@ export class IssueFeatureRegistrar extends Disposable { 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', @@ -524,7 +555,6 @@ export class IssueFeatureRegistrar extends Disposable { return openCodeLink(issueModel, this.manager); }), ); - const chatCommandID = chatCommand(); this._register( vscode.commands.registerCommand('issue.chatSummarizeIssue', (issue: any) => { if (!(issue instanceof IssueModel || issue instanceof PRNode)) { @@ -535,11 +565,13 @@ export class IssueFeatureRegistrar extends Disposable { */ this.telemetry.sendTelemetryEvent('issue.chatSummarizeIssue'); if (issue instanceof IssueModel) { - commands.executeCommand(chatCommandID, vscode.l10n.t('@githubpr Summarize issue {0}/{1}#{2}', issue.remote.owner, issue.remote.repositoryName, issue.number)); + 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(chatCommandID, vscode.l10n.t('@githubpr Summarize pull request {0}/{1}#{2}', remote.owner, remote.repositoryName, pullRequestModel.number)); + 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); } }), ); @@ -552,7 +584,8 @@ export class IssueFeatureRegistrar extends Disposable { "issue.chatSuggestFix" : {} */ this.telemetry.sendTelemetryEvent('issue.chatSuggestFix'); - commands.executeCommand(chatCommandID, vscode.l10n.t('@githubpr Find a fix for issue {0}/{1}#{2}', issue.remote.owner, issue.remote.repositoryName, issue.number)); + 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 () => { @@ -576,13 +609,10 @@ export class IssueFeatureRegistrar extends Disposable { this._register( vscode.languages.registerHoverProvider('*', new UserHoverProvider(this.manager, this.telemetry)), ); - const todoProvider = new IssueTodoProvider(this.context, this.copilotRemoteAgentManager); + const todoProvider = new IssueTodoProvider(this.context); this._register( vscode.languages.registerCodeActionsProvider('*', todoProvider, { providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] }), ); - this._register( - vscode.languages.registerCodeLensProvider('*', todoProvider), - ); }); } @@ -740,9 +770,11 @@ export class IssueFeatureRegistrar extends Disposable { ...options, title: template.title, body: template.body, + labels: template.labels, + assignees: template.assignees, }; } - this.makeNewIssueFile(uri, options); + await this.makeNewIssueFile(uri, options); } async createIssueFromFile() { @@ -810,7 +842,7 @@ export class IssueFeatureRegistrar extends Disposable { let githubRepository = issueModel.githubRepository; let remote = issueModel.remote; if (!repoManager) { - repoManager = await this.chooseRepo(vscode.l10n.t('Choose which repository you want to work on this isssue in.')); + repoManager = await this.chooseRepo(vscode.l10n.t('Choose which repository you want to work on this issue in.')); if (!repoManager) { return; } @@ -825,10 +857,40 @@ export class IssueFeatureRegistrar extends Disposable { } } + // 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), - true + checkoutDefaultBranch ); } @@ -1001,6 +1063,36 @@ export class IssueFeatureRegistrar extends Disposable { if (matches && matches.length === 2 && (await this._stateManager.getUserMap(document.uri)).has(matches[1])) { assignees = [matches[1]]; } + + // Auto-assign to current user if they are assignable in the repository + const folderManager = this.manager.getManagerForFile(document.uri); + if (folderManager) { + try { + // Get the GitHub repository for the document + const githubRepository = folderManager.gitHubRepositories[0]; + if (githubRepository) { + const currentUser = await folderManager.getCurrentUser(githubRepository); + if (currentUser?.login) { + // Check if the current user is assignable in this repository + const assignableUsers = await folderManager.getAssignableUsers(); + const assignableUsersForRemote = assignableUsers[githubRepository.remote.remoteName] || []; + const isAssignable = assignableUsersForRemote.some(user => user.login === currentUser.login); + if (isAssignable) { + // Add current user to assignees if not already included + if (!assignees) { + assignees = [currentUser.login]; + } else if (!assignees.includes(currentUser.login)) { + assignees.push(currentUser.login); + } + } + } + } + } catch (error) { + // If we can't get the current user or assignable users, just continue without auto-assignment + Logger.debug(`Failed to auto-assign current user: ${error}`, IssueFeatureRegistrar.ID); + } + } + let title: string | undefined; const body: string | undefined = await this.createTodoIssueBody(newIssue, issueBody); @@ -1029,7 +1121,7 @@ export class IssueFeatureRegistrar extends Disposable { quickInput.busy = true; this.createIssueInfo = { document, newIssue, lineNumber, insertIndex }; - this.makeNewIssueFile(document.uri, { title, body, assignees }); + await this.makeNewIssueFile(document.uri, { title, body, assignees }); quickInput.busy = false; quickInput.hide(); }); @@ -1060,7 +1152,7 @@ export class IssueFeatureRegistrar extends Disposable { await vscode.workspace.fs.delete(bodyPath); const assigneeLine = `${ASSIGNEES} ${options?.assignees && options.assignees.length > 0 ? options.assignees.map(value => '@' + value).join(', ') + ' ' : '' }`; - const labelLine = `${LABELS} `; + const labelLine = `${LABELS} ${options?.labels && options.labels.length > 0 ? options.labels.join(', ') + ' ' : ''}`; const milestoneLine = `${MILESTONE} `; const projectsLine = `${PROJECTS} `; const cached = this._newIssueCache.get(); @@ -1207,14 +1299,14 @@ ${options?.body ?? ''}\n return choice?.repo; } - private async chooseTemplate(folderManager: FolderRepositoryManager): Promise<{ title: string | undefined, body: string | undefined } | undefined> { + private async chooseTemplate(folderManager: FolderRepositoryManager): Promise { const templateUris = await folderManager.getIssueTemplates(); if (templateUris.length === 0) { - return { title: undefined, body: undefined }; + return { title: undefined, body: undefined, labels: undefined, assignees: undefined, name: undefined, about: undefined }; } interface IssueChoice extends vscode.QuickPickItem { - template: { title: string | undefined, body: string | undefined } | undefined; + template: IssueTemplate | undefined; } const templates = await Promise.all( templateUris @@ -1240,7 +1332,7 @@ ${options?.body ?? ''}\n }); choices.push({ label: vscode.l10n.t('Blank issue'), - template: { title: undefined, body: undefined } + template: { title: undefined, body: undefined, labels: undefined, assignees: undefined, name: undefined, about: undefined } }); const selectedTemplate = await vscode.window.showQuickPick(choices, { @@ -1251,11 +1343,84 @@ ${options?.body ?? ''}\n } 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, body }; + 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( @@ -1364,7 +1529,12 @@ ${options?.body ?? ''}\n await vscode.env.clipboard.writeText(issue.html_url); break; case openIssue: - await IssueOverviewPanel.createOrShow(this.telemetry, this.context.extensionUri, constFolderManager, issue); + 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; } }); @@ -1440,10 +1610,21 @@ ${options?.body ?? ''}\n } } - private getMarkdownLinkText(): string | undefined { + private getMarkdownLinkText(range?: vscode.Range | vscode.NotebookRange): string | undefined { if (!vscode.window.activeTextEditor) { return undefined; } + + // If a specific range is provided (e.g., from a gutter click), use that + // Note: NotebookRange is excluded because getText() only accepts vscode.Range + if (range && !(range instanceof vscode.NotebookRange)) { + const text = vscode.window.activeTextEditor.document.getText(range); + if (text) { + return text; + } + } + + // Otherwise fall back to the current selection let editorSelection: vscode.Range | undefined = vscode.window.activeTextEditor.selection; if (editorSelection.start.line !== editorSelection.end.line) { editorSelection = new vscode.Range( @@ -1466,8 +1647,13 @@ ${options?.body ?? ''}\n const links = await this.getPermalinkWithError(repositoriesManager, includeRange, true, context); const withPermalinks: (PermalinkInfo & { permalink: string })[] = links.filter((link): link is PermalinkInfo & { permalink: string } => !!link.permalink); - if (withPermalinks.length === 1) { - const selection = this.getMarkdownLinkText(); + // 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})`); } @@ -1487,35 +1673,6 @@ ${options?.body ?? ''}\n return undefined; } - async startCodingAgentFromTodo(todoInfo?: { document: vscode.TextDocument; lineNumber: number; line: string; insertIndex: number; range: vscode.Range }) { - if (!todoInfo) { - return; - } - - const { document, line, insertIndex } = todoInfo; - const todoText = line.substring(insertIndex).trim(); - if (!todoText) { - vscode.window.showWarningMessage(vscode.l10n.t('No task description found in TODO comment')); - return; - } - - const relativePath = vscode.workspace.asRelativePath(document.uri); - const prompt = vscode.l10n.t('Work on TODO: {0} (from {1})', todoText, relativePath); - return vscode.window.withProgress({ - location: vscode.ProgressLocation.Notification, - title: vscode.l10n.t('Delegating \'{0}\' to coding agent', truncate(todoText, 20)) - }, async (_progress) => { - try { - await this.copilotRemoteAgentManager.commandImpl({ - userPrompt: prompt, - source: 'todo' - }); - } catch (error) { - vscode.window.showErrorMessage(vscode.l10n.t('Failed to start coding agent session: {0}', error.message)); - } - }); - } - async assignToCodingAgent(issueModel: any) { if (!issueModel) { return; diff --git a/src/issues/issueFile.ts b/src/issues/issueFile.ts index de3302d62d..69e0ef6ca3 100644 --- a/src/issues/issueFile.ts +++ b/src/issues/issueFile.ts @@ -14,6 +14,7 @@ export interface NewIssueFileOptions { title?: string; body?: string; assignees?: string[] | undefined, + labels?: string[] | undefined, remote?: Remote, } diff --git a/src/issues/issueLinkProvider.ts b/src/issues/issueLinkProvider.ts deleted file mode 100644 index fafc7b7f6b..0000000000 --- a/src/issues/issueLinkProvider.ts +++ /dev/null @@ -1,89 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import * as vscode from 'vscode'; -import { StateManager } from './stateManager'; -import { - getIssue, - isComment, - MAX_LINE_LENGTH, -} from './util'; -import { EDITOR, WORD_WRAP } from '../common/settingKeys'; -import { ReposManagerState } from '../github/folderRepositoryManager'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { ISSUE_EXPRESSION, ParsedIssue, parseIssueExpressionOutput } from '../github/utils'; - -const MAX_LINE_COUNT = 2000; - -class IssueDocumentLink extends vscode.DocumentLink { - constructor( - range: vscode.Range, - public readonly mappedLink: { readonly value: string; readonly parsed: ParsedIssue }, - public readonly uri: vscode.Uri, - ) { - super(range); - } -} - -export class IssueLinkProvider implements vscode.DocumentLinkProvider { - constructor(private manager: RepositoriesManager, private stateManager: StateManager) { } - - async provideDocumentLinks( - document: vscode.TextDocument, - _token: vscode.CancellationToken, - ): Promise { - const links: vscode.DocumentLink[] = []; - const wraps: boolean = vscode.workspace.getConfiguration(EDITOR, document).get(WORD_WRAP, 'off') !== 'off'; - for (let i = 0; i < Math.min(document.lineCount, MAX_LINE_COUNT); i++) { - let searchResult = -1; - let lineOffset = 0; - const line = document.lineAt(i).text; - const lineLength = wraps ? line.length : Math.min(line.length, MAX_LINE_LENGTH); - let lineSubstring = line.substring(0, lineLength); - while ((searchResult = lineSubstring.search(ISSUE_EXPRESSION)) >= 0) { - const match = lineSubstring.match(ISSUE_EXPRESSION); - const parsed = parseIssueExpressionOutput(match); - if (match && parsed) { - const startPosition = new vscode.Position(i, searchResult + lineOffset); - if (await isComment(document, startPosition)) { - const link = new IssueDocumentLink( - new vscode.Range( - startPosition, - new vscode.Position(i, searchResult + lineOffset + match[0].length - 1), - ), - { value: match[0], parsed }, - document.uri, - ); - links.push(link); - } - } - lineOffset += searchResult + (match ? match[0].length : 0); - lineSubstring = line.substring(lineOffset, line.length); - } - } - return links; - } - - async resolveDocumentLink( - link: IssueDocumentLink, - _token: vscode.CancellationToken, - ): Promise { - if (this.manager.state === ReposManagerState.RepositoriesLoaded) { - const folderManager = this.manager.getManagerForFile(link.uri); - if (!folderManager) { - return; - } - const issue = await getIssue( - this.stateManager, - folderManager, - link.mappedLink.value, - link.mappedLink.parsed, - ); - if (issue) { - link.target = await vscode.env.asExternalUri(vscode.Uri.parse(issue.html_url)); - } - return link; - } - } -} diff --git a/src/issues/issueTodoProvider.ts b/src/issues/issueTodoProvider.ts index 383e6007aa..ace829f627 100644 --- a/src/issues/issueTodoProvider.ts +++ b/src/issues/issueTodoProvider.ts @@ -4,19 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { isComment, MAX_LINE_LENGTH } from './util'; -import { CODING_AGENT, CREATE_ISSUE_TRIGGERS, ISSUES_SETTINGS_NAMESPACE, SHOW_CODE_LENS } from '../common/settingKeys'; +import { MAX_LINE_LENGTH } from './util'; +import { CREATE_ISSUE_TRIGGERS, ISSUES_SETTINGS_NAMESPACE } from '../common/settingKeys'; import { escapeRegExp } from '../common/utils'; -import { CopilotRemoteAgentManager } from '../github/copilotRemoteAgent'; import { ISSUE_OR_URL_EXPRESSION } from '../github/utils'; -export class IssueTodoProvider implements vscode.CodeActionProvider, vscode.CodeLensProvider { +export class IssueTodoProvider implements vscode.CodeActionProvider { private expression: RegExp | undefined; constructor( - context: vscode.ExtensionContext, - private copilotRemoteAgentManager: CopilotRemoteAgentManager - ) { + context: vscode.ExtensionContext) { context.subscriptions.push( vscode.workspace.onDidChangeConfiguration(() => { this.updateTriggers(); @@ -76,64 +73,10 @@ export class IssueTodoProvider implements vscode.CodeActionProvider, vscode.Code arguments: [{ document, lineNumber, line, insertIndex, range }], }; codeActions.push(createIssueAction); - - // Start Coding Agent Session action (if copilot manager is available) - if (this.copilotRemoteAgentManager) { - const startAgentAction: vscode.CodeAction = new vscode.CodeAction( - vscode.l10n.t('Delegate to agent'), - vscode.CodeActionKind.QuickFix, - ); - startAgentAction.ranges = [new vscode.Range(lineNumber, search, lineNumber, search + match[0].length)]; - startAgentAction.command = { - title: vscode.l10n.t('Delegate to agent'), - command: 'issue.startCodingAgentFromTodo', - arguments: [{ document, lineNumber, line, insertIndex, range }], - }; - codeActions.push(startAgentAction); - } break; } lineNumber++; } while (range.end.line >= lineNumber); return codeActions; } - - async provideCodeLenses( - document: vscode.TextDocument, - _token: vscode.CancellationToken, - ): Promise { - if (this.expression === undefined) { - return []; - } - - // Check if CodeLens is enabled - const isCodeLensEnabled = vscode.workspace.getConfiguration(CODING_AGENT).get(SHOW_CODE_LENS, true); - if (!isCodeLensEnabled) { - return []; - } - - const codeLenses: vscode.CodeLens[] = []; - for (let lineNumber = 0; lineNumber < document.lineCount; lineNumber++) { - const textLine = document.lineAt(lineNumber); - const { text: line, firstNonWhitespaceCharacterIndex } = textLine; - const todoInfo = this.findTodoInLine(line); - if (!todoInfo) { - continue; - } - if (!(await isComment(document, new vscode.Position(lineNumber, firstNonWhitespaceCharacterIndex)))) { - continue; - } - const { match, search, insertIndex } = todoInfo; - const range = new vscode.Range(lineNumber, search, lineNumber, search + match[0].length); - if (this.copilotRemoteAgentManager && (await this.copilotRemoteAgentManager.isAvailable())) { - const startAgentCodeLens = new vscode.CodeLens(range, { - title: vscode.l10n.t('Delegate to agent'), - command: 'issue.startCodingAgentFromTodo', - arguments: [{ document, lineNumber, line, insertIndex, range }], - }); - codeLenses.push(startAgentCodeLens); - } - } - return codeLenses; - } } diff --git a/src/issues/issuesView.ts b/src/issues/issuesView.ts index b33079ac12..9628ad7ea8 100644 --- a/src/issues/issuesView.ts +++ b/src/issues/issuesView.ts @@ -8,7 +8,7 @@ import * as vscode from 'vscode'; import { issueBodyHasLink } from './issueLinkLookup'; import { IssueItem, QueryGroup, StateManager } from './stateManager'; import { commands, contexts } from '../common/executeCommands'; -import { ISSUE_AVATAR_DISPLAY, ISSUES_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { ISSUE_AVATAR_DISPLAY, IssueAvatarDisplay, ISSUES_SETTINGS_NAMESPACE, SHOW_ISSUE_NUMBER_IN_TREE } from '../common/settingKeys'; import { DataUri } from '../common/uri'; import { groupBy } from '../common/utils'; import { FolderRepositoryManager, ReposManagerState } from '../github/folderRepositoryManager'; @@ -65,7 +65,8 @@ export class IssuesTreeData // Listen for changes to the avatar display setting context.subscriptions.push( vscode.workspace.onDidChangeConfiguration(change => { - if (change.affectsConfiguration(`${ISSUES_SETTINGS_NAMESPACE}.${ISSUE_AVATAR_DISPLAY}`)) { + if (change.affectsConfiguration(`${ISSUES_SETTINGS_NAMESPACE}.${ISSUE_AVATAR_DISPLAY}`) + || change.affectsConfiguration(`${ISSUES_SETTINGS_NAMESPACE}.${SHOW_ISSUE_NUMBER_IN_TREE}`)) { this._onDidChangeTreeData.fire(); } }), @@ -87,27 +88,44 @@ export class IssuesTreeData } private async getIssueTreeItem(element: IssueItem): Promise { - const treeItem = new vscode.TreeItem(element.title, vscode.TreeItemCollapsibleState.None); + const showIssueNumber = vscode.workspace + .getConfiguration(ISSUES_SETTINGS_NAMESPACE, null) + .get(SHOW_ISSUE_NUMBER_IN_TREE, false); + const numberPrefix = showIssueNumber ? `#${element.number}: ` : ''; + const treeItem = new vscode.TreeItem(`${numberPrefix}${element.title}`, vscode.TreeItemCollapsibleState.None); const avatarDisplaySetting = vscode.workspace .getConfiguration(ISSUES_SETTINGS_NAMESPACE, null) - .get<'author' | 'assignee'>(ISSUE_AVATAR_DISPLAY, 'author'); - - let avatarUser: IAccount | undefined; - if ((avatarDisplaySetting === 'assignee') && element.assignees && (element.assignees.length > 0)) { - avatarUser = element.assignees[0]; - } else if (avatarDisplaySetting === 'author') { - avatarUser = element.author; - } - - if (avatarUser) { - 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('issues.closed'))); + .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 { - // Use GitHub codicon when assignee setting is selected but no assignees exist - treeItem.iconPath = new vscode.ThemeIcon('github'); + let avatarUser: IAccount | undefined; + if ((avatarDisplaySetting === 'assignee') && element.assignees && (element.assignees.length > 0)) { + avatarUser = element.assignees[0]; + } else { + 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.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 = { @@ -117,7 +135,12 @@ export class IssuesTreeData }; if (this.stateManager.currentIssue(element.uri)?.issue.number === element.number) { - treeItem.label = `✓ ${treeItem.label as string}`; + // Escape any $(...) syntax to avoid rendering issue titles as icons. + const escapedTitle = element.title.replace(/\$\([a-zA-Z0-9~-]+\)/g, '\\$&'); + const label: vscode.TreeItemLabel2 = { + label: new vscode.MarkdownString(`$(check) ${numberPrefix}${escapedTitle}`, true) + }; + treeItem.label = label as vscode.TreeItemLabel; treeItem.contextValue = 'currentissue'; } else { const savedState = this.stateManager.getSavedIssueState(element.number); @@ -147,7 +170,7 @@ export class IssuesTreeData getChildren( element: FolderRepositoryManager | QueryNode | IssueGroupNode | IssueItem | undefined, - ): FolderRepositoryManager[] | QueryNode[] | Promise { + ): FolderRepositoryManager[] | QueryNode[] | Promise { if (element === undefined && this.manager.state !== ReposManagerState.RepositoriesLoaded) { return this.getStateChildren(); } else { @@ -175,12 +198,25 @@ export class IssuesTreeData } } - private getRootChildren(): FolderRepositoryManager[] | QueryNode[] | Promise { + 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) { - return this.manager.folderManagers; + // 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 []; } @@ -236,7 +272,7 @@ export class IssuesTreeData getIssuesChildren( element: FolderRepositoryManager | QueryNode | IssueGroupNode | IssueItem | undefined, - ): FolderRepositoryManager[] | QueryNode[] | Promise { + ): FolderRepositoryManager[] | QueryNode[] | Promise { if (element === undefined) { return this.getRootChildren(); } else if (element instanceof FolderRepositoryManager) { diff --git a/src/issues/shareProviders.ts b/src/issues/shareProviders.ts index 2b98217004..f652a0c9d2 100644 --- a/src/issues/shareProviders.ts +++ b/src/issues/shareProviders.ts @@ -218,27 +218,7 @@ export class GitHubPermalinkAsMarkdownShareProvider extends GitHubPermalinkShare } private async getMarkdownLinkText(item: vscode.ShareableItem): Promise { - const fileName = pathLib.basename(item.resourceUri.path); - - if (item.selection) { - const document = await vscode.workspace.openTextDocument(item.resourceUri); - - const editorSelection = item.selection.start === item.selection.end - ? item.selection - : new vscode.Range(item.selection.start, new vscode.Position(item.selection.start.line + 1, 0)); - - const selectedText = document.getText(editorSelection); - if (selectedText) { - return selectedText; - } - - const wordRange = document.getWordRangeAtPosition(item.selection.start); - if (wordRange) { - return document.getText(wordRange); - } - } - - return fileName; + return pathLib.basename(item.resourceUri.path); } } diff --git a/src/issues/stateManager.ts b/src/issues/stateManager.ts index b02b45ad07..8d2ea3843f 100644 --- a/src/issues/stateManager.ts +++ b/src/issues/stateManager.ts @@ -9,11 +9,13 @@ import { CurrentIssue } from './currentIssue'; import { Repository } from '../api/api'; import { GitApiImpl } from '../api/api1'; import { AuthProvider } from '../common/authentication'; -import { parseRepositoryRemotes } from '../common/remote'; +import { parseRepositoryRemotesAsync } from '../common/remote'; import { DEFAULT, + DEV_MODE, IGNORE_MILESTONES, ISSUES_SETTINGS_NAMESPACE, + PR_SETTINGS_NAMESPACE, QUERIES, USE_BRANCH_FOR_ISSUES, } from '../common/settingKeys'; @@ -228,7 +230,11 @@ export class StateManager { }), ); this.registerRepositoryChangeEvent(); - await this.setAllIssueData(); + // Skip fetching issues if dev mode is enabled + const devMode = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(DEV_MODE, false); + if (!devMode) { + await this.setAllIssueData(); + } this.context.subscriptions.push( this.onRefreshCacheNeeded(async () => { await this.refresh(); @@ -315,7 +321,7 @@ export class StateManager { return; } singleRepoState.issueCollection.clear(); - const enterpriseRemotes = parseRepositoryRemotes(folderManager.repository).filter( + const enterpriseRemotes = (await parseRepositoryRemotesAsync(folderManager.repository)).filter( remote => remote.isEnterprise ); const user = await this.getCurrentUser(enterpriseRemotes.length ? AuthProvider.githubEnterprise : AuthProvider.github); @@ -329,30 +335,33 @@ export class StateManager { 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), + variableSubstitution(query.query, undefined, undefined, user).trim(), ).then(issues => ({ groupBy: query.groupBy ?? [], issues })); if (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 => { + 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(); - resolve( - issues?.items.map(item => { - const issueItem: IssueItem = item as IssueItem; - issueItem.uri = folderManager.repository.rootUri; - return issueItem; - }), - ); - }); + } } private async setCurrentIssueFromBranch(singleRepoState: SingleRepoState, branchName: string, silent: boolean = false) { diff --git a/src/issues/userCompletionProvider.ts b/src/issues/userCompletionProvider.ts index 6ed98c11bf..e0a74f45e4 100644 --- a/src/issues/userCompletionProvider.ts +++ b/src/issues/userCompletionProvider.ts @@ -77,7 +77,9 @@ export class UserCompletionProvider implements vscode.CompletionItemProvider { return []; } - if (!this.isCodeownersFiles(document.uri) && (document.languageId !== 'scminput') && (document.languageId !== 'git-commit') && !(await isComment(document, position))) { + const isPositionComment = document.languageId === 'plaintext' || document.languageId === 'markdown' || await isComment(document, position); + + if (!this.isCodeownersFiles(document.uri) && (document.languageId !== 'scminput') && (document.languageId !== 'git-commit') && !isPositionComment) { return []; } @@ -165,7 +167,7 @@ export class UserCompletionProvider implements vscode.CompletionItemProvider { } private cachedPrUsers: UserCompletion[] = []; - private cachedPrTimelineEvents: TimelineEvent[] = []; + private cachedPrTimelineEvents: TimelineEvent[] | undefined; private cachedForPrNumber: number | undefined; private async getCommentSpecificSuggestions( alreadyIncludedUsers: Map, @@ -236,7 +238,7 @@ export class UserCompletionProvider implements vscode.CompletionItemProvider { ); if (githubRepo) { - const pr = await githubRepo.getPullRequest(prNumber); + const pr = await githubRepo.getPullRequest(prNumber, 'UserCompletionProvider.provideCompletionItems'); this.cachedForPrNumber = prNumber; this.cachedPrTimelineEvents = await pr!.getTimelineEvents(); } diff --git a/src/issues/util.ts b/src/issues/util.ts index e36207fa6d..3e54082abe 100644 --- a/src/issues/util.ts +++ b/src/issues/util.ts @@ -91,9 +91,36 @@ 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; @@ -198,7 +225,7 @@ function getFileAndPosition(context: LinkContext, positionInfo?: NewIssue): { ur 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, 1)); + 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; } @@ -225,6 +252,7 @@ export interface PermalinkInfo { permalink: string | undefined; error: string | undefined; originalFile: vscode.Uri | undefined; + range: vscode.Range | vscode.NotebookRange | undefined; } export function getSimpleUpstream(repository: Repository) { @@ -284,12 +312,12 @@ export async function createSinglePermalink( ): 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 }; + return { permalink: undefined, error: vscode.l10n.t('No active text editor position to create permalink from.'), originalFile: undefined, range: undefined }; } const repository = getRepositoryForFile(gitAPI, uri); if (!repository) { - return { permalink: undefined, error: vscode.l10n.t('The current file isn\'t part of repository.'), originalFile: uri }; + return { permalink: undefined, error: vscode.l10n.t('The current file isn\'t part of repository.'), originalFile: uri, range }; } let commitHash: string | undefined; @@ -301,7 +329,7 @@ export async function createSinglePermalink( 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 }; + return { permalink: undefined, error: vscode.l10n.t('No branch on a remote contains the most recent commit for the file.'), originalFile: uri, range }; } // Now that we know that the file existed at some point in the repo, use the head commit to construct the URI. if (repository.state.HEAD?.commit && (log[0].hash !== repository.state.HEAD?.commit)) { @@ -318,7 +346,7 @@ export async function createSinglePermalink( 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 }; + return { permalink: undefined, error: vscode.l10n.t('The selection may not exist on any remote.'), originalFile: uri, range }; } const upstream: Remote & { fetchUrl: string } = rawUpstream as Remote & { fetchUrl: string }; @@ -330,7 +358,8 @@ export async function createSinglePermalink( permalink: (`${originOfFetchUrl}/${getOwnerAndRepo(repositoriesManager, repository, upstream)}/blob/${commitHash }${includeFile ? `${encodedPathSegment}${includeRange ? rangeString(range) : ''}` : ''}`), error: undefined, - originalFile: uri + originalFile: uri, + range }; Logger.debug(`permalink generated: ${result.permalink}`, PERMALINK_COMPONENT); return result; @@ -404,11 +433,11 @@ export async function createSingleGitHubLink( ): 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 }; + 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 }; + 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) { @@ -419,7 +448,7 @@ export async function createSingleGitHubLink( } const upstream = getSimpleUpstream(folderManager.repository); if (!upstream?.fetchUrl) { - return { permalink: undefined, error: vscode.l10n.t('Repository does not have any remotes.'), originalFile: undefined }; + 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(/\/$/, ''); @@ -428,7 +457,8 @@ export async function createSingleGitHubLink( permalink: (`${originOfFetchUrl}/${new Protocol(upstream.fetchUrl).nameWithOwner}/blob/${encodedBranchAndFilePath }${includeRange ? rangeString(range) : ''}`), error: undefined, - originalFile: uri + originalFile: uri, + range }; } @@ -517,12 +547,11 @@ export async function pushAndCreatePR( } export async function isComment(document: vscode.TextDocument, position: vscode.Position): Promise { - if (document.languageId !== 'markdown' && document.languageId !== 'plaintext') { - const tokenInfo = await vscode.languages.getTokenInformationAtPosition(document, position); - if (tokenInfo.type !== vscode.StandardTokenType.Comment) { - return false; - } + const tokenInfo = await vscode.languages.getTokenInformationAtPosition(document, position); + if (tokenInfo.type !== vscode.StandardTokenType.Comment) { + return false; } + return true; } diff --git a/src/lm/issueContextProvider.ts b/src/lm/issueContextProvider.ts new file mode 100644 index 0000000000..78cda0ecb1 --- /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 { + iconPath: new vscode.ThemeIcon('issues'), + label: `#${issue.number} ${issue.title}`, + modelDescription: 'The GitHub issue the user is viewing.', + tooltip: new vscode.MarkdownString(`#${issue.number} ${issue.title}`), + issue, + command: { + command: 'issue.openDescription', + title: vscode.l10n.t('Open Issue') + } + }; + } + + private async _resolvedIssueValue(issue: IssueModel): Promise { + const timeline = issue.timelineEvents ?? await issue.getIssueTimelineEvents(); + return JSON.stringify({ + issueNumber: issue.number, + owner: issue.remote.owner, + repo: issue.remote.repositoryName, + title: issue.title, + body: issue.body, + comments: timeline.filter(e => e.event === EventType.Commented).map((e: CommentEvent) => ({ + author: e.user?.login, + body: e.body, + createdAt: e.createdAt + })) + }); + } +} \ No newline at end of file diff --git a/src/lm/participants.ts b/src/lm/participants.ts deleted file mode 100644 index 6f2e339940..0000000000 --- a/src/lm/participants.ts +++ /dev/null @@ -1,210 +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 { renderPrompt } from '@vscode/prompt-tsx'; -import * as vscode from 'vscode'; -import { ParticipantsPrompt } from './participantsPrompt'; -import { Disposable } from '../common/lifecycle'; -import { IToolCall, TOOL_COMMAND_RESULT, TOOL_MARKDOWN_RESULT } from './tools/toolsUtils'; - -export class ChatParticipantState { - private _messages: vscode.LanguageModelChatMessage[] = []; - - get lastToolResult(): (vscode.LanguageModelTextPart | vscode.LanguageModelToolResultPart | vscode.LanguageModelToolCallPart)[] { - for (let i = this._messages.length - 1; i >= 0; i--) { - const message = this._messages[i]; - for (const part of message.content) { - if (part instanceof vscode.LanguageModelToolResultPart) { - return message.content; - } - } - } - return []; - } - - get firstUserMessage(): vscode.LanguageModelTextPart | undefined { - for (let i = 0; i < this._messages.length; i++) { - const message = this._messages[i]; - if (message.role === vscode.LanguageModelChatMessageRole.User && message.content) { - for (const part of message.content) { - if (part instanceof vscode.LanguageModelTextPart) { - return part; - } - } - } - } - return undefined; - } - - get messages(): vscode.LanguageModelChatMessage[] { - return this._messages; - } - - addMessage(message: vscode.LanguageModelChatMessage): void { - this._messages.push(message); - } - - addMessages(messages: vscode.LanguageModelChatMessage[]): void { - this._messages.push(...messages); - } - - reset(): void { - this._messages = []; - } -} - -export class ChatParticipant extends Disposable { - - constructor(context: vscode.ExtensionContext, private readonly state: ChatParticipantState) { - super(); - const ghprChatParticipant = this._register(vscode.chat.createChatParticipant('githubpr', ( - request: vscode.ChatRequest, - context: vscode.ChatContext, - stream: vscode.ChatResponseStream, - token: vscode.CancellationToken - ) => this.handleParticipantRequest(request, context, stream, token))); - ghprChatParticipant.iconPath = vscode.Uri.joinPath(context.extensionUri, 'resources/icons/github_logo.png'); - } - - async handleParticipantRequest( - request: vscode.ChatRequest, - context: vscode.ChatContext, - stream: vscode.ChatResponseStream, - token: vscode.CancellationToken - ): Promise { - this.state.reset(); - - const models = await vscode.lm.selectChatModels({ - vendor: 'copilot', - family: 'gpt-4o' - }); - const model = models[0]; - - - const allTools: vscode.LanguageModelChatTool[] = []; - for (const tool of vscode.lm.tools) { - if (request.tools.has(tool.name) && request.tools.get(tool.name)) { - allTools.push(tool); - } else if (tool.name.startsWith('github-pull-request')) { - allTools.push(tool); - } - } - - const { messages } = await renderPrompt( - ParticipantsPrompt, - { userMessage: request.prompt }, - { modelMaxPromptTokens: model.maxInputTokens }, - model); - - this.state.addMessages(messages); - - const toolReferences = [...request.toolReferences]; - const options: vscode.LanguageModelChatRequestOptions = { - justification: 'Answering user questions pertaining to GitHub.' - }; - - const commands: vscode.Command[] = []; - const runWithFunctions = async (): Promise => { - - const requestedTool = toolReferences.shift(); - if (requestedTool) { - options.toolMode = vscode.LanguageModelChatToolMode.Required; - options.tools = allTools.filter(tool => tool.name === requestedTool.name); - } else { - options.toolMode = undefined; - options.tools = allTools; - } - - const toolCalls: IToolCall[] = []; - const response = await model.sendRequest(this.state.messages, options, token); - - for await (const part of response.stream) { - - if (part instanceof vscode.LanguageModelTextPart) { - stream.markdown(part.value); - } else if (part instanceof vscode.LanguageModelToolCallPart) { - - const tool = vscode.lm.tools.find(tool => tool.name === part.name); - if (!tool) { - throw new Error('Got invalid tool choice: ' + part.name); - } - - let input: any; - try { - input = part.input; - } catch (err) { - throw new Error(`Got invalid tool use parameters: "${JSON.stringify(part.input)}". (${(err as Error).message})`); - } - - const invocationOptions: vscode.LanguageModelToolInvocationOptions = { input, toolInvocationToken: request.toolInvocationToken }; - toolCalls.push({ - call: part, - result: vscode.lm.invokeTool(tool.name, invocationOptions, token), - tool - }); - } - } - - if (toolCalls.length) { - const assistantMsg = vscode.LanguageModelChatMessage.Assistant(''); - assistantMsg.content = toolCalls.map(toolCall => new vscode.LanguageModelToolCallPart(toolCall.call.callId, toolCall.tool.name, toolCall.call.input)); - this.state.addMessage(assistantMsg); - - let shownToUser = false; - for (const toolCall of toolCalls) { - let toolCallResult = (await toolCall.result); - - const additionalContent: vscode.LanguageModelTextPart[] = []; - let result: vscode.LanguageModelToolResultPart | undefined; - - for (let i = 0; i < toolCallResult.content.length; i++) { - const part = toolCallResult.content[i]; - if (!(part instanceof vscode.LanguageModelTextPart)) { - // We only support text results for now, will change when we finish adopting prompt-tsx - result = new vscode.LanguageModelToolResultPart(toolCall.call.callId, toolCallResult.content); - continue; - } - - if (part.value === TOOL_MARKDOWN_RESULT) { - const markdown = new vscode.MarkdownString((toolCallResult.content[++i] as vscode.LanguageModelTextPart).value); - markdown.supportHtml = true; - stream.markdown(markdown); - shownToUser = true; - } else if (part.value === TOOL_COMMAND_RESULT) { - commands.push(JSON.parse((toolCallResult.content[++i] as vscode.LanguageModelTextPart).value) as vscode.Command); - } else { - if (!result) { - result = new vscode.LanguageModelToolResultPart(toolCall.call.callId, [part]); - } else { - additionalContent.push(part); - } - } - } - const message = vscode.LanguageModelChatMessage.User(''); - message.content = [result!]; - this.state.addMessage(message); - if (additionalContent.length) { - const additionalMessage = vscode.LanguageModelChatMessage.User(''); - additionalMessage.content = additionalContent; - this.state.addMessage(additionalMessage); - } - } - - this.state.addMessage(vscode.LanguageModelChatMessage.User(`Above is the result of calling the functions ${toolCalls.map(call => call.tool.name).join(', ')}. ${shownToUser ? 'The user can see the result of the tool call.' : ''}`)); - return runWithFunctions(); - } - }; - await runWithFunctions(); - this.addButtons(stream, commands); - } - - private addButtons(stream: vscode.ChatResponseStream, commands: vscode.Command[]) { - for (const command of commands) { - stream.button(command); - } - } -} - diff --git a/src/lm/participantsPrompt.ts b/src/lm/participantsPrompt.ts deleted file mode 100644 index 9533fd073d..0000000000 --- a/src/lm/participantsPrompt.ts +++ /dev/null @@ -1,43 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { AssistantMessage, BasePromptElementProps, Chunk, PromptElement, PromptPiece, PromptSizing, UserMessage } from '@vscode/prompt-tsx'; - -interface ParticipantsPromptProps extends BasePromptElementProps { - readonly userMessage: string; -} - -export class ParticipantsPrompt extends PromptElement { - render(_state: void, _sizing: PromptSizing): PromptPiece { - const instructions = [ - 'Instructions:', - '- The user will ask a question related to GitHub, and it may require lots of research to answer correctly. There is a selection of tools that let you perform actions or retrieve helpful context to answer the user\'s question.', - "- If you aren't sure which tool is relevant, you can call multiple tools. You can call tools repeatedly to take actions or gather as much context as needed until you have completed the task fully. Don't give up unless you are sure the request cannot be fulfilled with the tools you have.", - "- Don't ask the user for confirmation to use tools, just use them.", - '- When talking about issues, be as concise as possible while still conveying all the information you need to. Avoid mentioning the following:', - ' - The fact that there are no comments.', - ' - Any info that seems like template info.' - ].join('\n'); - - const assistantPiece: PromptPiece = { - ctor: AssistantMessage, - props: {}, - children: [instructions] - }; - - const userPiece: PromptPiece = { - ctor: UserMessage, - props: {}, - children: [this.props.userMessage] - }; - - const container: PromptPiece = { - ctor: Chunk, - props: {}, - children: [assistantPiece, userPiece] - }; - return container; - } -} \ No newline at end of file diff --git a/src/lm/pullRequestContextProvider.ts b/src/lm/pullRequestContextProvider.ts new file mode 100644 index 0000000000..aa091a5987 --- /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({ + iconPath: 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 { + iconPath: 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 index 4cd4db515d..40b5a7a154 100644 --- a/src/lm/tools/activePullRequestTool.ts +++ b/src/lm/tools/activePullRequestTool.ts @@ -6,28 +6,24 @@ import * as vscode from 'vscode'; import { FetchIssueResult } from './fetchIssueTool'; -import { COPILOT_LOGINS } from '../../common/copilot'; import { GitChangeType, InMemFileChange } from '../../common/file'; -import Logger from '../../common/logger'; import { CommentEvent, EventType, ReviewEvent } from '../../common/timelineEvent'; -import { CopilotRemoteAgentManager } from '../../github/copilotRemoteAgent'; 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, - private readonly copilotRemoteAgentManager: CopilotRemoteAgentManager + protected readonly folderManagers: RepositoriesManager ) { } protected abstract _findActivePullRequest(): PullRequestModel | undefined; protected abstract _confirmationTitle(): string; - private shouldIncludeCodingAgentSession(pullRequest?: PullRequestModel): boolean { - return !!pullRequest && this.copilotRemoteAgentManager.enabled && COPILOT_LOGINS.includes(pullRequest.author.login); - } - private _getPullRequestLabel(pullRequest: PullRequestModel): string { return `${pullRequest.title} (#${pullRequest.number})`; } @@ -50,103 +46,47 @@ export abstract class PullRequestTool implements vscode.LanguageModelTool line.startsWith('data:')) - .forEach(line => { - try { - const obj = JSON.parse(line.replace(/^data:\s*/, '')); - if (Array.isArray(obj.choices)) { - for (const choice of obj.choices) { - const delta = choice.delta || {}; - if (typeof delta.content === 'string' && !!delta.role) { - result.push(delta.content); - } - } - } - } catch { /* ignore parse errors */ } - }); - - return result; - } - - async fallbackSessionLogs( - pullRequest: PullRequestModel, - model: vscode.LanguageModelChat, - cancellationToken: vscode.CancellationToken - ) { - const logs = await this.copilotRemoteAgentManager.getSessionLogsFromAction(pullRequest); - // Summarize the Copilot agent's thinking process using the model - const messages = [ - vscode.LanguageModelChatMessage.Assistant('You are an expert summarizer. The following logs show the thinking process and performed actions of a GitHub Copilot agent that was in charge of working on the current pull request. Read the logs and always maintain the thinking process. You can remove information on the tool call results that you think are not necessary for building context.'), - vscode.LanguageModelChatMessage.User(`Copilot Agent Logs (JSON):\n${JSON.stringify(logs)}`) - ]; - - let summaryText: string | undefined; - try { - const response = await model.sendRequest(messages, { justification: 'Summarizing Copilot agent logs for the active pull request.' }, cancellationToken); - summaryText = await (typeof response.text === 'string' ? response.text : (typeof response.text?.[Symbol.asyncIterator] === 'function' ? (async () => { let out = ''; for await (const chunk of response.text) { out += chunk; } return out; })() : '')); - } catch (e) { - summaryText = ''; - } - - return summaryText; - } - - async fetchCodingAgentSession( - pullRequest: PullRequestModel, - model: vscode.LanguageModelChat, - token: vscode.CancellationToken - ): Promise { - let copilotSteps: string | string[] = []; - try { - const logs = await this.copilotRemoteAgentManager.getSessionLogFromPullRequest(pullRequest); - if (!logs) { - throw new Error('Could not get session logs'); - } - - copilotSteps = this.parseCopilotEventStream(logs.logs); - if (copilotSteps.length === 0) { - throw new Error('Empty Copilot agent logs received'); - } - } catch (e) { - Logger.debug(`Failed to fetch Copilot agent logs from API: ${e}.`, ActivePullRequestTool.toolId); - copilotSteps = await this.fallbackSessionLogs(pullRequest, model, token); - } - - return copilotSteps; - } - - async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken): Promise { + 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')]); } - let codingAgentSession: string | string[] = []; - if (this.shouldIncludeCodingAgentSession(pullRequest) && options.model) { - codingAgentSession = await this.fetchCodingAgentSession(pullRequest, options.model, token); + 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 status = await pullRequest.getStatusChecks(); - const timeline = pullRequest.timelineEvents.length > 0 ? pullRequest.timelineEvents : await pullRequest.getTimelineEvents(); const pullRequestInfo = { title: pullRequest.title, body: pullRequest.body, author: pullRequest.author, assignees: pullRequest.assignees, - comments: pullRequest.comments.map(comment => { + reviewThreads: pullRequest.reviewThreadsCache.map(thread => { return { - author: comment.user?.login, - body: comment.body, - commentState: comment.isResolved ? 'resolved' : 'unresolved', - file: comment.path + 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: timeline.filter((event): event is ReviewEvent | CommentEvent => event.event === EventType.Reviewed || event.event === EventType.Commented).map(event => { + timelineComments: reviewAndCommentEvents.map(event => { return { author: event.user?.login, body: event.body, @@ -154,29 +94,15 @@ export abstract class PullRequestTool implements vscode.LanguageModelTool { - return { - context: status.context, - description: status.description, - state: status.state, - name: status.workflowName, - targetUrl: status.targetUrl - }; - }), - reviewRequirements: { - approvalsNeeded: status[1]?.count ?? 0, - currentApprovals: status[1]?.approvals.length ?? 0, - areChangesRequested: (status[1]?.requestedChanges.length ?? 0) > 0, - }, isDraft: pullRequest.isDraft ? 'is a draft and cannot be merged until marked as ready for review' : 'false', - codingAgentSession, 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))]); @@ -187,7 +113,7 @@ export abstract class PullRequestTool implements vscode.LanguageModelTool manager.activePullRequest); diff --git a/src/lm/tools/copilotRemoteAgentTool.ts b/src/lm/tools/copilotRemoteAgentTool.ts deleted file mode 100644 index 822d2d556a..0000000000 --- a/src/lm/tools/copilotRemoteAgentTool.ts +++ /dev/null @@ -1,154 +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 marked from 'marked'; -import * as vscode from 'vscode'; -import { COPILOT_ACCOUNTS } from '../../common/comment'; -import { ITelemetry } from '../../common/telemetry'; -import { toOpenPullRequestWebviewUri } from '../../common/uri'; -import { CopilotRemoteAgentManager } from '../../github/copilotRemoteAgent'; -import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; -import { PlainTextRenderer } from '../../github/markdownUtils'; -import { PrsTreeModel } from '../../view/prsTreeModel'; - -export interface CopilotRemoteAgentToolParameters { - // The LLM is inconsistent in providing repo information. - // For now, we only support the active repository in the current workspace. - // repo?: { - // owner?: string; - // name?: string; - // }; - title: string; - body?: string; - existingPullRequest?: string; -} - -export class CopilotRemoteAgentTool implements vscode.LanguageModelTool { - public static readonly toolId = 'github-pull-request_copilot-coding-agent'; - - constructor(private manager: CopilotRemoteAgentManager, private telemetry: ITelemetry, private prsTreeModel: PrsTreeModel) { } - - async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions): Promise { - const { title, existingPullRequest } = options.input; - const folderManager = existingPullRequest ? undefined : await this.manager.tryPromptForAuthAndRepo(); - - // Check if the coding agent is available (enabled and assignable) - const isAvailable = await this.manager.isAvailable(); - if (!isAvailable) { - throw new Error(vscode.l10n.t('Copilot coding agent is not available for this repository. Make sure the agent is enabled and assignable to this repository.')); - } - - const targetRepo = await this.manager.repoInfo(folderManager); - const autoPushEnabled = this.manager.autoCommitAndPushEnabled; - const openPR = existingPullRequest || await this.getActivePullRequestWithSession(targetRepo); - - /* __GDPR__ - "remoteAgent.tool.prepare" : {} - */ - this.telemetry.sendTelemetryEvent('copilot.remoteAgent.tool.prepare', {}); - - return { - pastTenseMessage: vscode.l10n.t('Launched coding agent'), - invocationMessage: vscode.l10n.t('Launching coding agent'), - confirmationMessages: { - message: openPR - ? vscode.l10n.t('The coding agent will incorporate your feedback on existing pull request **#{0}**.', openPR) - : (targetRepo && autoPushEnabled - ? vscode.l10n.t('The coding agent will continue work on "**{0}**" in a new branch on "**{1}/{2}**". Any uncommitted changes will be **automatically pushed**.', title, targetRepo.owner, targetRepo.repo) - : vscode.l10n.t('The coding agent will start working on "**{0}**"', title)), - title: vscode.l10n.t('Start coding agent?'), - } - }; - } - - async invoke( - options: vscode.LanguageModelToolInvocationOptions, - _: vscode.CancellationToken - ): Promise { - const title = options.input.title; - const body = options.input.body || ''; - const existingPullRequest = options.input.existingPullRequest || ''; - const folderManager = existingPullRequest ? undefined : await this.manager.tryPromptForAuthAndRepo(); - - const targetRepo = await this.manager.repoInfo(folderManager); - if (!targetRepo) { - return new vscode.LanguageModelToolResult([ - new vscode.LanguageModelTextPart(vscode.l10n.t('No repository information found. Please open a workspace with a Git repository.')) - ]); - } - - let pullRequestNumber: number | undefined; - if (existingPullRequest) { - pullRequestNumber = parseInt(existingPullRequest, 10); - if (isNaN(pullRequestNumber)) { - return new vscode.LanguageModelToolResult([ - new vscode.LanguageModelTextPart(vscode.l10n.t('Invalid pull request number: {0}', existingPullRequest)) - ]); - } - } else { - pullRequestNumber = await this.getActivePullRequestWithSession(targetRepo); - } - - /* __GDPR__ - "copilot.remoteAgent.tool.invoke" : { - "hasExistingPR" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "hasBody" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetry.sendTelemetryEvent('copilot.remoteAgent.tool.invoke', { - hasExistingPR: pullRequestNumber ? 'true' : 'false', - hasBody: body ? 'true' : 'false' - }); - - if (pullRequestNumber) { - await this.manager.addFollowUpToExistingPR(pullRequestNumber, title, body); - return new vscode.LanguageModelToolResult([ - new vscode.LanguageModelTextPart(vscode.l10n.t('Follow-up added to pull request #{0}.', pullRequestNumber)), - ]); - } - - const result = await this.manager.invokeRemoteAgent(title, body); - if (result.state === 'error') { - /* __GDPR__ - "copilot.remoteAgent.tool.error" : { - "reason" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetry.sendTelemetryErrorEvent('copilot.remoteAgent.tool.error', { reason: 'invocationError' }); - throw new Error(result.error); - } - - let lmResult: (vscode.LanguageModelTextPart | vscode.LanguageModelDataPart)[] = [new vscode.LanguageModelTextPart(result.llmDetails)]; - const pr = await targetRepo.fm.resolvePullRequest(targetRepo.owner, targetRepo.repo, result.number); - if (pr) { - const plaintextBody = marked.parse(pr.body, { renderer: new PlainTextRenderer(true), smartypants: true }).trim(); - const preferredRendering = { - uri: (await toOpenPullRequestWebviewUri({ owner: pr.githubRepository.remote.owner, repo: pr.githubRepository.remote.repositoryName, pullRequestNumber: pr.number })).toString(), - title: pr.title, - description: plaintextBody, - author: COPILOT_ACCOUNTS[pr.author.login].name, - linkTag: `#${pr.number}` - }; - const buffer: Buffer = Buffer.from(JSON.stringify(preferredRendering)); - const data: Uint8Array = Uint8Array.from(buffer); - - // API might not be available for tests, this guarantees we are still able to test - const userAudience = vscode.LanguageModelPartAudience?.User ?? 1; - lmResult.push(new vscode.LanguageModelDataPart2(data, 'application/pull-request+json', [userAudience])); - } - - return new vscode.LanguageModelToolResult2(lmResult); - } - - protected async getActivePullRequestWithSession(repoInfo: { repo: string; owner: string; fm: FolderRepositoryManager } | undefined): Promise { - if (!repoInfo) { - return; - } - const activePR = repoInfo.fm.activePullRequest; - if (activePR && this.prsTreeModel.getCopilotStateForPR(repoInfo.owner, repoInfo.repo, activePR.number)) { - return activePR.number; - } - } -} \ 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/displayIssuesTool.ts b/src/lm/tools/displayIssuesTool.ts deleted file mode 100644 index 8177718f87..0000000000 --- a/src/lm/tools/displayIssuesTool.ts +++ /dev/null @@ -1,173 +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 { ensureEmojis } from '../../common/emoji'; -import Logger from '../../common/logger'; -import { reviewerLabel } from '../../github/interface'; -import { makeLabel } from '../../github/utils'; -import { ChatParticipantState } from '../participants'; -import { IssueSearchResultAccount, IssueSearchResultItem, SearchToolResult } from './searchTools'; -import { concatAsyncIterable, TOOL_MARKDOWN_RESULT, ToolBase } from './toolsUtils'; - -export type DisplayIssuesParameters = SearchToolResult; - -type IssueColumn = keyof IssueSearchResultItem; - -const LLM_FIND_IMPORTANT_COLUMNS_INSTRUCTIONS = `Instructions: -You are an expert on GitHub issues. You can help the user identify the most important columns for rendering issues based on a query for issues: -- Include a column related to the sort value, if given. -- Output a newline separated list of columns only, max 4 columns. -- List the columns in the order they should be displayed. -- Don't change the casing. -- Don't include columns that will all have the same value for all the resulting issues. -Here are the possible columns: -`; - -export class DisplayIssuesTool extends ToolBase { - public static readonly toolId = 'github-pull-request_renderIssues'; - private static ID = 'DisplayIssuesTool'; - constructor(private readonly context: vscode.ExtensionContext, chatParticipantState: ChatParticipantState) { - super(chatParticipantState); - } - - private assistantPrompt(issues: IssueSearchResultItem[]): string { - const possibleColumns = Object.keys(issues[0]); - return `${LLM_FIND_IMPORTANT_COLUMNS_INSTRUCTIONS}\n${possibleColumns.map(column => `- ${column}`).join('\n')}\nHere's the data you have about the issues:\n`; - } - - private postProcess(proposedColumns: string, issues: IssueSearchResultItem[]): IssueColumn[] { - const lines = proposedColumns.split('\n'); - const possibleColumns = Object.keys(issues[0]); - const finalColumns: IssueColumn[] = []; - for (let line of lines) { - line = line.trim(); - if (line === '') { - continue; - } - if (!possibleColumns.includes(line)) { - // Check if the llm decided to use formatting, even though we asked it not to - const splitOnSpace = line.split(' '); - if (splitOnSpace.length > 1) { - const testColumn = splitOnSpace[splitOnSpace.length - 1]; - if (possibleColumns.includes(testColumn)) { - finalColumns.push(testColumn as IssueColumn); - } - } - } else { - finalColumns.push(line as IssueColumn); - } - } - return finalColumns; - } - - private async getImportantColumns(issueItemsInfo: vscode.LanguageModelTextPart | undefined, issues: IssueSearchResultItem[], token: vscode.CancellationToken): Promise { - if (!issueItemsInfo) { - return ['number', 'title', 'state']; - } - - // Try to get the llm to tell us which columns are important based on information it has about the issues - const models = await vscode.lm.selectChatModels({ - vendor: 'copilot', - family: 'gpt-4o' - }); - const model = models[0]; - const chatOptions: vscode.LanguageModelChatRequestOptions = { - justification: 'Answering user questions pertaining to GitHub.' - }; - const messages = [vscode.LanguageModelChatMessage.Assistant(this.assistantPrompt(issues))]; - messages.push(new vscode.LanguageModelChatMessage(vscode.LanguageModelChatMessageRole.User, issueItemsInfo?.value)); - const response = await model.sendRequest(messages, chatOptions, token); - const result = this.postProcess(await concatAsyncIterable(response.text), issues); - const indexOfUrl = result.indexOf('url'); - if (result.length === 0) { - return ['number', 'title', 'state']; - } else if (indexOfUrl >= 0) { - // Never include the url column - result.splice(indexOfUrl, 1); - } - - return result; - } - - private renderUser(account: IssueSearchResultAccount) { - return `[@${reviewerLabel(account)}](${account.url})`; - } - - private issueToRow(issue: IssueSearchResultItem, importantColumns: IssueColumn[]): string { - return `| ${importantColumns.map(column => { - switch (column) { - case 'number': - return `[${issue[column]}](${issue.url})`; - case 'labels': - return issue[column]?.map((label) => label?.name && label.color ? makeLabel({ name: label.name, color: label.color }) : '').join(', '); - case 'assignees': - return issue[column]?.map((assignee) => this.renderUser(assignee)).join(', '); - case 'author': - const account = issue[column]; - return account ? this.renderUser(account) : ''; - case 'createdAt': - case 'updatedAt': - const updatedAt = issue[column]; - return updatedAt ? new Date(updatedAt).toLocaleDateString() : ''; - case 'milestone': - return issue[column]; - default: - return issue[column]; - } - }).join(' | ')} |`; - } - - private foundIssuesCount(params: DisplayIssuesParameters): number { - return params.totalIssues !== undefined ? params.totalIssues : (params.arrayOfIssues?.length ?? 0); - } - - async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions): Promise { - const maxDisplay = 10; - const foundIssuesCount = this.foundIssuesCount(options.input); - const actualDisplay = Math.min(maxDisplay, foundIssuesCount); - if (actualDisplay === 0) { - return { - invocationMessage: vscode.l10n.t('No issues found') - }; - } else if (actualDisplay < foundIssuesCount) { - return { - invocationMessage: vscode.l10n.t('Found {0} issues. Generating a markdown table of the first {1}', foundIssuesCount, actualDisplay) - }; - } else { - return { - invocationMessage: vscode.l10n.t('Found {0} issues. Generating a markdown table', foundIssuesCount) - }; - } - } - - async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken): Promise { - await ensureEmojis(this.context); - const issueItemsInfo: vscode.LanguageModelTextPart | undefined = this.chatParticipantState.firstUserMessage; - const issueItems: IssueSearchResultItem[] | undefined = options.input.arrayOfIssues; - if (!issueItems || issueItems.length === 0) { - return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(vscode.l10n.t('No issues found. Please try another query.'))]); - } - Logger.debug(`Displaying ${this.foundIssuesCount(options.input)} issues, first issue ${issueItems[0].number}`, DisplayIssuesTool.ID); - const importantColumns = await this.getImportantColumns(issueItemsInfo, issueItems, token); - - const titleRow = `| ${importantColumns.join(' | ')} |`; - Logger.debug(`Columns ${titleRow} issues`, DisplayIssuesTool.ID); - const separatorRow = `| ${importantColumns.map(() => '---').join(' | ')} |\n`; - const issues = new vscode.MarkdownString(titleRow); - issues.supportHtml = true; - issues.appendMarkdown('\n'); - issues.appendMarkdown(separatorRow); - issues.appendMarkdown(issueItems.slice(0, 10).map(issue => { - return this.issueToRow(issue, importantColumns); - }).join('\n')); - - return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(TOOL_MARKDOWN_RESULT), - new vscode.LanguageModelTextPart(issues.value), - new vscode.LanguageModelTextPart(`The issues have been shown to the user. Simply say that you've already displayed the issue or first 10 issues.`)]); - } - -} \ No newline at end of file diff --git a/src/lm/tools/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/openPullRequestTool.ts b/src/lm/tools/openPullRequestTool.ts index 74b5d2f0bc..58ac7f364d 100644 --- a/src/lm/tools/openPullRequestTool.ts +++ b/src/lm/tools/openPullRequestTool.ts @@ -10,11 +10,11 @@ import { PullRequestModel } from '../../github/pullRequestModel'; import { PullRequestOverviewPanel } from '../../github/pullRequestOverview'; export class OpenPullRequestTool extends PullRequestTool { - public static readonly toolId = 'github-pull-request_openPullRequest'; + public static readonly toolId = 'github-pull-request_pullRequestInViewport'; protected _findActivePullRequest(): PullRequestModel | undefined { - // First check if there's a PR overview panel open - const panelPR = PullRequestOverviewPanel.currentPanel?.getCurrentItem(); + // First check if there's an active PR overview panel + const panelPR = PullRequestOverviewPanel.getActivePanel()?.getCurrentItem(); if (panelPR) { return panelPR; } 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 index 4d5c99273a..92d623e7c5 100644 --- a/src/lm/tools/searchTools.ts +++ b/src/lm/tools/searchTools.ts @@ -5,19 +5,10 @@ 'use strict'; import * as vscode from 'vscode'; -import { concatAsyncIterable, RepoToolBase } from './toolsUtils'; +import { RepoToolBase } from './toolsUtils'; import Logger from '../../common/logger'; -import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; -import { ILabel } from '../../github/interface'; import { escapeMarkdown } from '../../issues/util'; -interface ConvertToQuerySyntaxParameters { - naturalLanguageString?: string; - repo?: { - owner?: string; - name?: string; - }; -} interface ConvertToQuerySyntaxResult { query: string; @@ -27,375 +18,6 @@ interface ConvertToQuerySyntaxResult { }; } -enum ValidatableProperty { - is = 'is', - type = 'type', - state = 'state', - in = 'in', - linked = 'linked', - status = 'status', - draft = 'draft', - review = 'review', - no = 'no', -} - -const githubSearchSyntax = { - is: { possibleValues: ['issue', 'pr', 'draft', 'public', 'private', 'locked', 'unlocked'] }, - assignee: { valueDescription: 'A GitHub user name or @me' }, - author: { valueDescription: 'A GitHub user name or @me' }, - mentions: { valueDescription: 'A GitHub user name or @me' }, - team: { valueDescription: 'A GitHub user name' }, - commenter: { valueDescription: 'A GitHub user name or @me' }, - involves: { valueDescription: 'A GitHub user name or @me' }, - label: { valueDescription: 'A GitHub issue/pr label' }, - type: { possibleValues: ['pr', 'issue'] }, - state: { possibleValues: ['open', 'closed', 'merged'] }, - in: { possibleValues: ['title', 'body', 'comments'] }, - user: { valueDescription: 'A GitHub user name or @me' }, - org: { valueDescription: 'A GitHub org, without the repo name' }, - repo: { valueDescription: 'A GitHub repo, without the org name' }, - linked: { possibleValues: ['pr', 'issue'] }, - milestone: { valueDescription: 'A GitHub milestone' }, - project: { valueDescription: 'A GitHub project' }, - status: { possibleValues: ['success', 'failure', 'pending'] }, - head: { valueDescription: 'A git commit sha or branch name' }, - base: { valueDescription: 'A git commit sha or branch name' }, - comments: { valueDescription: 'A number' }, - interactions: { valueDescription: 'A number' }, - reactions: { valueDescription: 'A number' }, - draft: { possibleValues: ['true', 'false'] }, - review: { possibleValues: ['none', 'required', 'approved', 'changes_requested'] }, - reviewedBy: { valueDescription: 'A GitHub user name or @me' }, - reviewRequested: { valueDescription: 'A GitHub user name or @me' }, - userReviewRequested: { valueDescription: 'A GitHub user name or @me' }, - teamReviewRequested: { valueDescription: 'A GitHub user name' }, - created: { valueDescription: 'A date, with an optional < >' }, - updated: { valueDescription: 'A date, with an optional < >' }, - closed: { valueDescription: 'A date, with an optional < >' }, - no: { possibleValues: ['label', 'milestone', 'assignee', 'project'] }, - sort: { possibleValues: ['updated', 'updated-asc', 'interactions', 'interactions-asc', 'author-date', 'author-date-asc', 'committer-date', 'committer-date-asc', 'reactions', 'reactions-asc', 'reactions-(+1, -1, smile, tada, heart)'] } -}; - -const MATCH_UNQUOTED_SPACES = /(?!\B"[^"]*)\s+(?![^"]*"\B)/; - -export class ConvertToSearchSyntaxTool extends RepoToolBase { - public static readonly toolId = 'github-pull-request_formSearchQuery'; - static ID = 'ConvertToSearchSyntaxTool'; - - private async fullQueryAssistantPrompt(folderRepoManager: FolderRepositoryManager): Promise { - const remote = folderRepoManager.activePullRequest?.remote ?? folderRepoManager.activeIssue?.remote ?? (await folderRepoManager.getPullRequestDefaultRepo()).remote; - - return `Instructions: -You are an expert on GitHub issue search syntax. GitHub issues are always software engineering related. You can help the user convert a natural language query to a query that can be used to search GitHub issues. Here are some rules to follow: -- Always try to include "repo:" or "org:" in your response. -- "repo" is often formated as "owner/name". If needed, the current repo is ${remote.owner}/${remote.repositoryName}. -- Ignore display information. -- Respond with only the query. -- Always include a "sort:" parameter. If multiple sorts are possible, choose the one that the user requested. -- Always include a property with the @me value if the query includes "me" or "my". -- Here are some examples of valid queries: - - repo:microsoft/vscode is:issue state:open sort:updated-asc - - mentions:@me org:microsoft is:issue state:open sort:updated - - assignee:@me milestone:"October 2024" is:open is:issue sort:reactions - - comments:>5 org:contoso is:issue state:closed mentions:@me label:bug - - interactions:>5 repo:contoso/cli is:issue state:open - - repo:microsoft/vscode-python is:issue sort:updated -assignee:@me - - repo:contoso/cli is:issue sort:updated no:milestone -- Go through each word of the natural language query and try to match it to a syntax component. -- Use a "-" in front of a syntax component to indicate that it should be "not-ed". -- Use the "no" syntax component to indicate that a property should be empty. -- As a reminder, here are the components of the query syntax: - ${JSON.stringify(githubSearchSyntax)} -`; - } - - private async labelsAssistantPrompt(folderRepoManager: FolderRepositoryManager, labels: ILabel[]): Promise { - // It seems that AND and OR aren't supported in GraphQL, so we can't use them in the query - // Here's the prompt in case we switch to REST: - // - Use as many labels as you think fit the query. If one label fits, then there are probably more that fit. - // - Respond with a list of labels in github search syntax, separated by AND or OR. Examples: "label:bug OR label:polish", "label:accessibility AND label:editor-accessibility" - return `Instructions: -You are an expert on choosing search keywords based on a natural language search query. Here are some rules to follow: -- Choose labels based on what the user wants to search for, not based on the actual words in the query. -- The user might include info on how they want their search results to be displayed. Ignore all of that. -- Labels will be and-ed together, so don't pick a bunch of super specific labels. -- Try to pick just one label. -- Respond with a space-separated list of labels: Examples: 'bug polish', 'accessibility "feature accessibility"' -- Only choose labels that you're sure are relevant. Having no labels is preferable than lables that aren't relevant. -- Don't choose labels that the user has explicitly excluded. -- Respond with label names chosen from this JSON array of options: -${JSON.stringify(labels.filter(label => !label.name.includes('required') && !label.name.includes('search') && !label.name.includes('question') && !label.name.includes('find') && !label.name.includes('issue')).map(label => ({ name: label.name, description: label.description })))} -`; - } - - private freeFormAssistantPrompt(): string { - return `Instructions: -You are getting ready to make a GitHub search query. Given a natural language query, you should find any key words that might be good for searching: -- Only include a max of 1 key word that is relevant to the search query. -- Don't refer to issue numbers. -- Don't refer to product names. -- Don't include any key words that might be related to display or rendering. -- Respond with only your chosen key word. -- It's better to return no keywords than to return irrelevant keywords. -- If an issue is provided, choose a keyword that names the feature or bug that the issue is about. -- Don't include key words or concepts that are already covered by labels. -`; - } - - private freeFormUserPrompt(labels: string[], originalUserPrompt: string): string { - return `I've already included the following labels: [${labels.join(', ')}]. The best search keywords in "${originalUserPrompt}" are:`; - } - - private labelsUserPrompt(originalUserPrompt: string): string { - return `The following labels are most appropriate for "${originalUserPrompt}":`; - } - - private fullQueryUserPrompt(originalUserPrompt: string): string { - originalUserPrompt = originalUserPrompt.replace(/\b(me|my)\b/, (value) => value.toUpperCase()); - const date = new Date(); - return `Pretend today's date is ${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}, but only include it if needed. How should this be converted to a GitHub issue search query? ${originalUserPrompt}`; - } - - private validateSpecificQueryPart(property: ValidatableProperty | string, value: string): boolean { - switch (property) { - case ValidatableProperty.is: - return value === 'issue' || value === 'pr' || value === 'draft' || value === 'public' || value === 'private' || value === 'locked' || value === 'unlocked'; - case ValidatableProperty.type: - return value === 'pr' || value === 'issue'; - case ValidatableProperty.state: - return value === 'open' || value === 'closed' || value === 'merged'; - case ValidatableProperty.in: - return value === 'title' || value === 'body' || value === 'comments'; - case ValidatableProperty.linked: - return value === 'pr' || value === 'issue'; - case ValidatableProperty.status: - return value === 'success' || value === 'failure' || value === 'pending'; - case ValidatableProperty.draft: - return value === 'true' || value === 'false'; - case ValidatableProperty.review: - return value === 'none' || value === 'required' || value === 'approved' || value === 'changes_requested'; - case ValidatableProperty.no: - return value === 'label' || value === 'milestone' || value === 'assignee' || value === 'project'; - default: - return true; - } - } - - private validateLabelsList(labelsList: string, allLabels: ILabel[]): string[] { - // I wrote everything for AND and OR, but it isn't supported with GraphQL. - // Leaving it in for now in case we switch to REST. - const isAndOrOr = (labelOrOperator: string) => { - return labelOrOperator === 'AND' || labelOrOperator === 'OR'; - }; - - const labelsAndOperators = labelsList.split(MATCH_UNQUOTED_SPACES).map(label => label.trim()); - let goodLabels: string[] = []; - for (let labelOrOperator of labelsAndOperators) { - if (isAndOrOr(labelOrOperator)) { - if (goodLabels.length === 0) { - continue; - } else if (goodLabels.length > 0 && isAndOrOr(goodLabels[goodLabels.length - 1])) { - goodLabels[goodLabels.length - 1] = labelOrOperator; - } else { - goodLabels.push(labelOrOperator); - } - continue; - } - // Make sure it does start with `label:` - const labelPrefixRegex = /^label:(?!\B"[^"]*)\s+(?![^"]*"\B)/; - const labelPrefixMatch = labelOrOperator.match(labelPrefixRegex); - let label = labelOrOperator; - if (labelPrefixMatch) { - label = labelPrefixMatch[1]; - } - if (allLabels.find(l => l.name === label)) { - goodLabels.push(label); - } - } - if (goodLabels.length > 0 && isAndOrOr(goodLabels[goodLabels.length - 1])) { - goodLabels = goodLabels.slice(0, goodLabels.length - 1); - } - return goodLabels; - } - - private validateFreeForm(baseQuery: string, labels: string[], freeForm: string) { - // Currently, we only allow the free form to return one keyword - freeForm = freeForm.trim(); - // useless strings to search for - if (freeForm.includes('issue') || freeForm.match(MATCH_UNQUOTED_SPACES) || freeForm.toLowerCase() === 'none') { - return ''; - } - if (baseQuery.includes(freeForm)) { - return ''; - } - if (labels.includes(freeForm)) { - return ''; - } - if (labels.some(label => freeForm.includes(label) || label.includes(freeForm))) { - return ''; - } - if (Object.keys(githubSearchSyntax).find(searchPart => freeForm.includes(searchPart) || searchPart.includes(freeForm))) { - return ''; - } - return freeForm; - } - - private validateQuery(query: string, labels: string[], freeForm: string) { - let reformedQuery = ''; - const queryParts = query.split(MATCH_UNQUOTED_SPACES); - // Only keep property:value pairs and '-', no reform allowed here. - for (const part of queryParts) { - if (part.startsWith('label:')) { - continue; - } - const propAndVal = part.split(':'); - if (propAndVal.length === 2) { - const hasMinus = propAndVal[0].startsWith('-'); - const label = hasMinus ? propAndVal[0].substring(1) : propAndVal[0]; - const value = propAndVal[1]; - if (!label.match(/^[a-zA-Z]+$/)) { - continue; - } - if (!this.validateSpecificQueryPart(label, value)) { - continue; - } - if (label === 'no' && value === 'label' && labels.length > 0) { - // special case for no:label as we shouldn't have both no:label and label:label - continue; - } - } - reformedQuery = `${reformedQuery} ${part}`; - } - - const validFreeForm = this.validateFreeForm(reformedQuery, labels, freeForm); - - reformedQuery = `${reformedQuery} ${labels.map(label => `label:${label}`).join(' ')} ${validFreeForm}`; - return reformedQuery.trim(); - } - - private postProcess(queryPart: string, freeForm: string, labels: string[]): ConvertToQuerySyntaxResult | undefined { - const query = this.findQuery(queryPart); - if (!query) { - return; - } - const fixedLabels = this.validateQuery(query, labels, freeForm); - const fixedRepo = this.fixRepo(fixedLabels); - return fixedRepo; - } - - private fixRepo(query: string): ConvertToQuerySyntaxResult { - const repoRegex = /repo:([^ ]+)/; - const orgRegex = /org:([^ ]+)/; - const repoMatch = query.match(repoRegex); - const orgMatch = query.match(orgRegex); - let newQuery = query.trim(); - let owner: string | undefined; - let name: string | undefined; - if (repoMatch) { - const originalRepo = repoMatch[1]; - if (originalRepo.includes('/')) { - const ownerAndRepo = originalRepo.split('/'); - owner = ownerAndRepo[0]; - name = ownerAndRepo[1]; - } - - if (orgMatch && originalRepo.includes('/')) { - // remove the org match - newQuery = query.replace(orgRegex, ''); - } else if (orgMatch) { - // We need to add the org into the repo - newQuery = query.replace(repoRegex, `repo:${orgMatch[1]}/${originalRepo}`); - owner = orgMatch[1]; - name = originalRepo; - } - } - return { - query: newQuery, - repo: owner && name ? { owner, name } : undefined - }; - } - - private findQuery(result: string): string | undefined { - // if there's a code block, then that's all we take - if (result.includes('```')) { - const start = result.indexOf('```'); - const end = result.indexOf('```', start + 3); - return result.substring(start + 3, end); - } - // if it's only one line, we take that - const lines = result.split('\n'); - if (lines.length <= 1) { - return lines.length === 0 ? result : lines[0]; - } - // if there are multiple lines, we take the first line that has a colon - for (const line of lines) { - if (line.includes(':')) { - return line; - } - } - } - - private async generateLabelQuery(folderManager: FolderRepositoryManager, labels: ILabel[], chatOptions: vscode.LanguageModelChatRequestOptions, model: vscode.LanguageModelChat, naturalLanguageString: string, token: vscode.CancellationToken): Promise { - const messages = [vscode.LanguageModelChatMessage.Assistant(await this.labelsAssistantPrompt(folderManager, labels))]; - messages.push(vscode.LanguageModelChatMessage.User(this.labelsUserPrompt(naturalLanguageString))); - const response = await model.sendRequest(messages, chatOptions, token); - return concatAsyncIterable(response.text); - } - - private async generateFreeFormQuery(folderManager: FolderRepositoryManager, chatOptions: vscode.LanguageModelChatRequestOptions, model: vscode.LanguageModelChat, naturalLanguageString: string, labels: string[], token: vscode.CancellationToken): Promise { - const messages = [vscode.LanguageModelChatMessage.Assistant(this.freeFormAssistantPrompt())]; - messages.push(vscode.LanguageModelChatMessage.User(this.freeFormUserPrompt(labels, naturalLanguageString))); - const response = await model.sendRequest(messages, chatOptions, token); - return concatAsyncIterable(response.text); - } - - private async generateQuery(folderManager: FolderRepositoryManager, chatOptions: vscode.LanguageModelChatRequestOptions, model: vscode.LanguageModelChat, naturalLanguageString: string, token: vscode.CancellationToken): Promise { - const messages = [vscode.LanguageModelChatMessage.Assistant(await this.fullQueryAssistantPrompt(folderManager))]; - messages.push(vscode.LanguageModelChatMessage.User(this.fullQueryUserPrompt(naturalLanguageString))); - const response = await model.sendRequest(messages, chatOptions, token); - return concatAsyncIterable(response.text); - } - - async prepareInvocation(_options: vscode.LanguageModelToolInvocationPrepareOptions): Promise { - return { - invocationMessage: vscode.l10n.t('Converting to search syntax') - }; - } - - async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken): Promise { - const { owner, name, folderManager } = await this.getRepoInfo({ owner: options.input.repo?.owner, name: options.input.repo?.name }); - const firstUserMessage = `${this.chatParticipantState.firstUserMessage?.value}, ${options.input.naturalLanguageString}`; - - const allLabels = await folderManager.getLabels(undefined, { owner, repo: name }); - - const models = await vscode.lm.selectChatModels({ - vendor: 'copilot', - family: 'gpt-4o' - }); - const model = models[0]; - const chatOptions: vscode.LanguageModelChatRequestOptions = { - justification: 'Answering user questions pertaining to GitHub.' - }; - const [query, labelsList] = await Promise.all([this.generateQuery(folderManager, chatOptions, model, firstUserMessage, token), this.generateLabelQuery(folderManager, allLabels, chatOptions, model, firstUserMessage, token)]); - const validatedLabels = this.validateLabelsList(labelsList, allLabels); - const freeForm = await this.generateFreeFormQuery(folderManager, chatOptions, model, firstUserMessage, validatedLabels, token); - const result = this.postProcess(query, freeForm, validatedLabels); - if (!result) { - throw new Error('Unable to form a query.'); - } - Logger.debug(`Query \`${result.query}\``, ConvertToSearchSyntaxTool.ID); - const json: ConvertToQuerySyntaxResult = { - query: result.query, - repo: { - owner, - name - } - }; - return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(JSON.stringify(json)), - new vscode.LanguageModelTextPart('Above is the query in stringified json format. You can pass this VERBATIM to a tool that knows how to search.')]); - } -} - type SearchToolParameters = ConvertToQuerySyntaxResult; export interface IssueSearchResultAccount { diff --git a/src/lm/tools/suggestFixTool.ts b/src/lm/tools/suggestFixTool.ts deleted file mode 100644 index d3dfdac364..0000000000 --- a/src/lm/tools/suggestFixTool.ts +++ /dev/null @@ -1,96 +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 { CredentialStore } from '../../github/credentials'; -import { RepositoriesManager } from '../../github/repositoriesManager'; -import { ChatParticipantState } from '../participants'; -import { IssueResult, IssueToolParameters, RepoToolBase } from './toolsUtils'; - -export class SuggestFixTool extends RepoToolBase { - public static readonly toolId = 'github-pull-request_suggest-fix'; - - constructor(credentialStore: CredentialStore, repositoriesManager: RepositoriesManager, chatParticipantState: ChatParticipantState) { - super(credentialStore, repositoriesManager, chatParticipantState); - } - - async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions): Promise { - return { - invocationMessage: options.input.issueNumber ? vscode.l10n.t('Suggesting a fix for issue #{0}', options.input.issueNumber) : vscode.l10n.t('Suggesting a fix for the issue') - }; - } - - async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken): Promise { - const repo = options.input.repo; - const owner = repo?.owner; - const name = repo?.name; - const issueNumber = options.input.issueNumber; - if (!repo || !owner || !name || !issueNumber) { - return undefined; - } - const { folderManager } = await this.getRepoInfo(repo); - if (!folderManager) { - throw new Error(`No folder manager found for ${repo.owner}/${repo.name}. Make sure to have the repository open.`); - } - const issue = await folderManager.resolveIssue(owner, name, issueNumber, true); - if (!issue) { - throw new Error(`No issue found for ${repo.owner}/${repo.name}/${options.input.issueNumber}. Make sure the issue exists.`); - } - - const result: IssueResult = { - title: issue.title, - body: issue.body, - comments: issue.item.comments?.map(c => ({ body: c.body })) ?? [] - }; - - const messages: vscode.LanguageModelChatMessage[] = []; - messages.push(vscode.LanguageModelChatMessage.Assistant(`You are a world-class developer who is capable of solving very difficult bugs and issues.`)); - messages.push(vscode.LanguageModelChatMessage.Assistant(`The user will give you an issue title, body and a list of comments from GitHub. The user wants you to suggest a fix.`)); - messages.push(vscode.LanguageModelChatMessage.Assistant(`Analyze the issue content, the workspace context below and using all this information suggest a fix.`)); - messages.push(vscode.LanguageModelChatMessage.Assistant(`Where possible output code-blocks and reference real files in the workspace with the fix.`)); - messages.push(vscode.LanguageModelChatMessage.User(`The issue content is as follows: `)); - messages.push(vscode.LanguageModelChatMessage.User(`Issue Title: ${result.title}`)); - messages.push(vscode.LanguageModelChatMessage.User(`Issue Body: ${result.body}`)); - result.comments.forEach((comment, index) => { - messages.push(vscode.LanguageModelChatMessage.User(`Comment ${index}: ${comment.body}`)); - }); - - const codeSearchTool = vscode.lm.tools.find(value => value.tags.includes('vscode_codesearch')); - if (!codeSearchTool) { - throw new Error('Could not find the code search tool'); - } - - const copilotCodebaseResult = await vscode.lm.invokeTool(codeSearchTool.name, { - toolInvocationToken: undefined, - input: { - query: result.title - } - }, token); - - const plainTextResult = copilotCodebaseResult.content[0]; - if (plainTextResult instanceof vscode.LanguageModelTextPart) { - messages.push(vscode.LanguageModelChatMessage.User(`Below is some potential relevant workspace context to the issue. The user cannot see this result, so you should explain it to the user if referencing it in your answer.`)); - const toolMessage = vscode.LanguageModelChatMessage.User(''); - toolMessage.content = [plainTextResult]; - messages.push(toolMessage); - } - - const models = await vscode.lm.selectChatModels({ - vendor: 'copilot', - family: 'gpt-4o' - }); - const model = models[0]; - const response = await model.sendRequest(messages, {}, token); - - let responseResult = ''; - for await (const chunk of response.text) { - responseResult += chunk; - } - return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(responseResult)]); - } - - -} \ No newline at end of file diff --git a/src/lm/tools/summarizeIssueTool.ts b/src/lm/tools/summarizeIssueTool.ts deleted file mode 100644 index a0c6fb71d8..0000000000 --- a/src/lm/tools/summarizeIssueTool.ts +++ /dev/null @@ -1,94 +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 { FetchIssueResult } from './fetchIssueTool'; -import { concatAsyncIterable } from './toolsUtils'; - -export class IssueSummarizationTool implements vscode.LanguageModelTool { - public static readonly toolId = 'github-pull-request_issue_summarize'; - - async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions): Promise { - if (!options.input.title) { - return { - invocationMessage: vscode.l10n.t('Summarizing issue') - }; - } - const shortenedTitle = options.input.title.length > 40; - const maxLengthTitle = shortenedTitle ? options.input.title.substring(0, 40) : options.input.title; - return { - invocationMessage: vscode.l10n.t('Summarizing "{0}', maxLengthTitle) - }; - } - - async invoke(options: vscode.LanguageModelToolInvocationOptions, _token: vscode.CancellationToken): Promise { - let issueOrPullRequestInfo: string = ` -Title : ${options.input.title} -Body : ${options.input.body} -`; - const fileChanges = options.input.fileChanges; - if (fileChanges) { - issueOrPullRequestInfo += ` -The following are the files changed: -`; - for (const fileChange of fileChanges.values()) { - issueOrPullRequestInfo += ` -File : ${fileChange.fileName} -Patch: ${fileChange.patch} -`; - } - } - const comments = options.input.comments; - if (comments) { - for (const [index, comment] of comments.entries()) { - issueOrPullRequestInfo += ` -Comment ${index} : -Author: ${comment.author} -Body: ${comment.body} -`; - } - } - const models = await vscode.lm.selectChatModels({ - vendor: 'copilot', - family: 'gpt-4o' - }); - const model = models[0]; - const repo = options.input.repo; - const owner = options.input.owner; - - if (model && repo && owner) { - const messages = [vscode.LanguageModelChatMessage.User(this.summarizeInstructions(repo, owner))]; - messages.push(vscode.LanguageModelChatMessage.User(`The issue or pull request information is as follows:`)); - messages.push(vscode.LanguageModelChatMessage.User(issueOrPullRequestInfo)); - const response = await model.sendRequest(messages, {}); - const responseText = await concatAsyncIterable(response.text); - return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(responseText)]); - } else { - return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(issueOrPullRequestInfo)]); - } - } - - private summarizeInstructions(repo: string, owner: string): string { - return ` -You are an AI assistant who is very proficient in summarizing issues and pull requests (PRs). -You will be given information relative to an issue or PR : the title, the body and the comments. In the case of a PR you will also be given patches of the PR changes. -Your task is to output a summary of all this information. -Do not output code. When you try to summarize PR changes, summarize in a textual format. -Output references to other issues and PRs as Markdown links. The current issue has owner ${owner} and is in the repo ${repo}. -If a comment references for example issue or PR #123, then output either of the following in the summary depending on if it is an issue or a PR: - -[#123](https://github.com/${owner}/${repo}/issues/123) -[#123](https://github.com/${owner}/${repo}/pull/123) - -When you summarize comments, always give a summary of each comment and always mention the author clearly before the comment. If the author is called 'joe' and the comment is 'this is a comment', then the output should be: - -joe: this is a comment - -Make sure the summary is at least as short or shorter than the issue or PR with the comments and the patches if there are. -`; - } - -} \ No newline at end of file diff --git a/src/lm/tools/summarizeNotificationsTool.ts b/src/lm/tools/summarizeNotificationsTool.ts deleted file mode 100644 index 7a4891f312..0000000000 --- a/src/lm/tools/summarizeNotificationsTool.ts +++ /dev/null @@ -1,144 +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 { FetchNotificationResult } from './fetchNotificationTool'; -import { concatAsyncIterable, TOOL_COMMAND_RESULT } from './toolsUtils'; - -export class NotificationSummarizationTool implements vscode.LanguageModelTool { - public static readonly toolId = 'github-pull-request_notification_summarize'; - - async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions): Promise { - const parameters = options.input; - if (!parameters.itemType || !parameters.itemNumber) { - return { - invocationMessage: vscode.l10n.t('Summarizing notification') - }; - } - const type = parameters.itemType === 'issue' ? 'issues' : 'pull'; - const url = `https://github.com/${parameters.owner}/${parameters.repo}/${type}/${parameters.itemNumber}`; - return { - invocationMessage: new vscode.MarkdownString(vscode.l10n.t('Summarizing item [#{0}]({1})', parameters.itemNumber, url)) - }; - } - - async invoke(options: vscode.LanguageModelToolInvocationOptions, _token: vscode.CancellationToken): Promise { - let notificationInfo: string = ''; - const lastReadAt = options.input.lastReadAt; - if (!lastReadAt) { - // First time the thread is viewed, so no lastReadAt field - notificationInfo += `This thread is viewed for the first time. Here is the main item information of the thread:`; - } - notificationInfo += ` -Title : ${options.input.title} -Body : ${options.input.body} -`; - const fileChanges = options.input.fileChanges; - if (fileChanges) { - notificationInfo += ` -The following are the files changed: -`; - for (const fileChange of fileChanges.values()) { - notificationInfo += ` -File : ${fileChange.fileName} -Patch: ${fileChange.patch} -`; - } - } - - const unreadComments = options.input.comments; - if (unreadComments && unreadComments.length > 0) { - notificationInfo += ` -The following are the unread comments of the thread: -`; - for (const [index, comment] of unreadComments.entries()) { - notificationInfo += ` -Comment ${index} : -Author: ${comment.author} -Body: ${comment.body} -`; - } - } - const models = await vscode.lm.selectChatModels({ - vendor: 'copilot', - family: 'gpt-4o' - }); - const model = models[0]; - const content: vscode.LanguageModelTextPart[] = []; - const threadId = options.input.threadId; - const notificationKey = options.input.notificationKey; - if (threadId && notificationKey) { - const markAsReadCommand = { - title: 'Mark As Read', - command: 'notification.markAsRead', - arguments: [{ threadId, notificationKey }] - }; - const markAsDoneCommand: vscode.Command = { - title: 'Mark As Done', - command: 'notification.markAsDone', - arguments: [{ threadId, notificationKey }] - }; - content.push(new vscode.LanguageModelTextPart(TOOL_COMMAND_RESULT)); - content.push(new vscode.LanguageModelTextPart(JSON.stringify(markAsReadCommand))); - content.push(new vscode.LanguageModelTextPart(TOOL_COMMAND_RESULT)); - content.push(new vscode.LanguageModelTextPart(JSON.stringify(markAsDoneCommand))); - } - const owner = options.input.owner; - const repo = options.input.repo; - if (model && owner && repo) { - const messages = [vscode.LanguageModelChatMessage.User(this.summarizeInstructions(owner, repo))]; - messages.push(vscode.LanguageModelChatMessage.User(`The notification information is as follows:`)); - messages.push(vscode.LanguageModelChatMessage.User(notificationInfo)); - const response = await model.sendRequest(messages, {}); - const responseText = await concatAsyncIterable(response.text); - content.push(new vscode.LanguageModelTextPart(responseText)); - } else { - content.push(new vscode.LanguageModelTextPart(notificationInfo)); - } - content.push(new vscode.LanguageModelTextPart('Above is a summary of the notification. Extract and output this notification summary directly as is to the user. Do not output the result from the call to the fetch notification tool.')); - return new vscode.LanguageModelToolResult(content); - } - - private summarizeInstructions(owner: string, repo: string): string { - return ` -You are an AI assistant who is very proficient in summarizing notification threads. -You will be given information relative to a notification thread : the title, the body and the comments. In the case of a PR you will also be given patches of the PR changes. -Since you are reviewing a notification thread, part of the content is by definition unread. You will be told what part of the content is yet unread. This can be the comments or it can be both the thread issue/PR as well as the comments. -Your task is to output a summary of all this notification thread information and give an update to the user concerning the unread part of the thread. -Output references to issues and PRs as Markdown links. The current notification is for a thread that has owner ${owner} and is in the repo ${repo}. -If a comment references for example issue or PR #123, then output either of the following in the summary depending on if it is an issue or a PR: - -[#123](https://github.com/${owner}/${repo}/issues/123) -[#123](https://github.com/${owner}/${repo}/pull/123) - -When you summarize comments, always give a summary of each comment and always mention the author clearly before the comment. If the author is called 'joe' and the comment is 'this is a comment', then the output should be: - -joe: this is a comment - -Always include in your output, which part of the thread is unread by prefixing that part with the markdown heading of level 1 with text "Unread Thread" or "Unread Comments". -Make sure the summary is at least as short or shorter than the issue or PR with the comments and the patches if there are. -Example output: - -# Unread Thread - - - -or: - - -# Unread Comments - - -Both 'Unread Thread' and 'Unread Comments' should not appear at the same time as markdown titles. The following is incorrect: - -# Unread Thread - -# Unread Comments - -`; - } - -} \ No newline at end of file diff --git a/src/lm/tools/tools.ts b/src/lm/tools/tools.ts index 09c833a7a9..5d122caa42 100644 --- a/src/lm/tools/tools.ts +++ b/src/lm/tools/tools.ts @@ -5,53 +5,34 @@ 'use strict'; import * as vscode from 'vscode'; -import { ITelemetry } from '../../common/telemetry'; -import { CopilotRemoteAgentManager } from '../../github/copilotRemoteAgent'; -import { CredentialStore } from '../../github/credentials'; -import { RepositoriesManager } from '../../github/repositoriesManager'; -import { ChatParticipantState } from '../participants'; import { ActivePullRequestTool } from './activePullRequestTool'; -import { CopilotRemoteAgentTool } from './copilotRemoteAgentTool'; -import { DisplayIssuesTool } from './displayIssuesTool'; +import { CreatePullRequestTool } from './createPullRequestTool'; import { FetchIssueTool } from './fetchIssueTool'; +import { FetchLabelsTool } from './fetchLabelsTool'; import { FetchNotificationTool } from './fetchNotificationTool'; import { OpenPullRequestTool } from './openPullRequestTool'; -import { ConvertToSearchSyntaxTool, SearchTool } from './searchTools'; -import { SuggestFixTool } from './suggestFixTool'; -import { IssueSummarizationTool } from './summarizeIssueTool'; -import { NotificationSummarizationTool } from './summarizeNotificationsTool'; -import { PrsTreeModel } from '../../view/prsTreeModel'; - -export function registerTools(context: vscode.ExtensionContext, credentialStore: CredentialStore, repositoriesManager: RepositoriesManager, chatParticipantState: ChatParticipantState, copilotRemoteAgentManager: CopilotRemoteAgentManager, telemetry: ITelemetry, prsTreeModel: PrsTreeModel) { - registerFetchingTools(context, credentialStore, repositoriesManager, chatParticipantState); - registerSummarizationTools(context); - registerSuggestFixTool(context, credentialStore, repositoriesManager, chatParticipantState); - registerSearchTools(context, credentialStore, repositoriesManager, chatParticipantState); - registerCopilotAgentTools(context, copilotRemoteAgentManager, telemetry, prsTreeModel); - context.subscriptions.push(vscode.lm.registerTool(ActivePullRequestTool.toolId, new ActivePullRequestTool(repositoriesManager, copilotRemoteAgentManager))); - context.subscriptions.push(vscode.lm.registerTool(OpenPullRequestTool.toolId, new OpenPullRequestTool(repositoriesManager, copilotRemoteAgentManager))); -} - -function registerFetchingTools(context: vscode.ExtensionContext, credentialStore: CredentialStore, repositoriesManager: RepositoriesManager, chatParticipantState: ChatParticipantState) { - context.subscriptions.push(vscode.lm.registerTool(FetchIssueTool.toolId, new FetchIssueTool(credentialStore, repositoriesManager, chatParticipantState))); - context.subscriptions.push(vscode.lm.registerTool(FetchNotificationTool.toolId, new FetchNotificationTool(credentialStore, repositoriesManager, chatParticipantState))); -} - -function registerSummarizationTools(context: vscode.ExtensionContext) { - context.subscriptions.push(vscode.lm.registerTool(IssueSummarizationTool.toolId, new IssueSummarizationTool())); - context.subscriptions.push(vscode.lm.registerTool(NotificationSummarizationTool.toolId, new NotificationSummarizationTool())); -} +import { PullRequestStatusChecksTool } from './pullRequestStatusChecksTool'; +import { ResolveReviewThreadTool } from './resolveReviewThreadTool'; +import { SearchTool } from './searchTools'; +import { CredentialStore } from '../../github/credentials'; +import { RepositoriesManager } from '../../github/repositoriesManager'; -function registerSuggestFixTool(context: vscode.ExtensionContext, credentialStore: CredentialStore, repositoriesManager: RepositoriesManager, chatParticipantState: ChatParticipantState) { - context.subscriptions.push(vscode.lm.registerTool(SuggestFixTool.toolId, new SuggestFixTool(credentialStore, repositoriesManager, chatParticipantState))); +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 registerCopilotAgentTools(context: vscode.ExtensionContext, copilotRemoteAgentManager: CopilotRemoteAgentManager, telemetry: ITelemetry, prsTreeModel: PrsTreeModel) { - context.subscriptions.push(vscode.lm.registerTool(CopilotRemoteAgentTool.toolId, new CopilotRemoteAgentTool(copilotRemoteAgentManager, telemetry, prsTreeModel))); +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, chatParticipantState: ChatParticipantState) { - context.subscriptions.push(vscode.lm.registerTool(ConvertToSearchSyntaxTool.toolId, new ConvertToSearchSyntaxTool(credentialStore, repositoriesManager, chatParticipantState))); - context.subscriptions.push(vscode.lm.registerTool(SearchTool.toolId, new SearchTool(credentialStore, repositoriesManager, chatParticipantState))); - context.subscriptions.push(vscode.lm.registerTool(DisplayIssuesTool.toolId, new DisplayIssuesTool(context, chatParticipantState))); +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 index 9b855bbe84..07692d35d3 100644 --- a/src/lm/tools/toolsUtils.ts +++ b/src/lm/tools/toolsUtils.ts @@ -9,7 +9,6 @@ import { CredentialStore, GitHub } from '../../github/credentials'; import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; import { RepositoriesManager } from '../../github/repositoriesManager'; import { hasEnterpriseUri } from '../../github/utils'; -import { ChatParticipantState } from '../participants'; export interface IToolCall { tool: vscode.LanguageModelToolInformation; @@ -37,7 +36,7 @@ export interface IssueResult { } export abstract class ToolBase implements vscode.LanguageModelTool { - constructor(protected readonly chatParticipantState: ChatParticipantState) { } + constructor() { } abstract invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken): vscode.ProviderResult; } @@ -50,8 +49,8 @@ export async function concatAsyncIterable(asyncIterable: AsyncIterable): } export abstract class RepoToolBase extends ToolBase { - constructor(private readonly credentialStore: CredentialStore, private readonly repositoriesManager: RepositoriesManager, chatParticipantState: ChatParticipantState) { - super(chatParticipantState); + 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 }> { diff --git a/src/notifications/notificationsFeatureRegistar.ts b/src/notifications/notificationsFeatureRegistar.ts index 708233f040..9ce8f846fa 100644 --- a/src/notifications/notificationsFeatureRegistar.ts +++ b/src/notifications/notificationsFeatureRegistar.ts @@ -93,7 +93,7 @@ export class NotificationsFeatureRegister extends Disposable { "notification.chatSummarizeNotification" : {} */ this._telemetry.sendTelemetryEvent('notification.chatSummarizeNotification'); - vscode.commands.executeCommand(chatCommand(), vscode.l10n.t('@githubpr Summarize notification with thread ID #{0}', notification.notification.id)); + vscode.commands.executeCommand(chatCommand(), vscode.l10n.t('Summarize notification with thread ID #{0}', notification.notification.id)); }) ); this._register( diff --git a/src/notifications/notificationsManager.ts b/src/notifications/notificationsManager.ts index 86fdfcaeab..7c8bea0289 100644 --- a/src/notifications/notificationsManager.ts +++ b/src/notifications/notificationsManager.ts @@ -9,11 +9,11 @@ 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 { ISSUES_SETTINGS_NAMESPACE, NOTIFICATION_SETTING, NotificationVariants, PR_SETTINGS_NAMESPACE, SHOW_ISSUE_NUMBER_IN_TREE, SHOW_PULL_REQUEST_NUMBER_IN_TREE } from '../common/settingKeys'; import { EventType, TimelineEvent } from '../common/timelineEvent'; import { toNotificationUri } from '../common/uri'; import { CredentialStore } from '../github/credentials'; -import { NotificationSubjectType } from '../github/interface'; +import { AccountType, NotificationSubjectType } from '../github/interface'; import { IssueModel } from '../github/issueModel'; import { issueMarkdown } from '../github/markdownUtils'; import { PullRequestModel } from '../github/pullRequestModel'; @@ -35,6 +35,9 @@ export enum NotificationsSortMethod { 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; @@ -70,6 +73,10 @@ export class NotificationsManager extends Disposable implements vscode.TreeDataP this._startPolling(); } } + if (e.affectsConfiguration(`${ISSUES_SETTINGS_NAMESPACE}.${SHOW_ISSUE_NUMBER_IN_TREE}`) + || e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${SHOW_PULL_REQUEST_NUMBER_IN_TREE}`)) { + this._onDidChangeTreeData.fire(); + } })); this._register(PullRequestOverviewPanel.onVisible(e => { this.markPrNotificationsAsRead(e); @@ -113,7 +120,7 @@ export class NotificationsManager extends Disposable implements vscode.TreeDataP } private _resolveNotificationTreeItem(element: NotificationTreeItem): vscode.TreeItem { - const label = element.notification.subject.title; + const label = this._notificationLabel(element); const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.None); const notification = element.notification; const model = element.model; @@ -121,11 +128,15 @@ export class NotificationsManager extends Disposable implements vscode.TreeDataP if (notification.subject.type === NotificationSubjectType.Issue && model instanceof IssueModel) { item.iconPath = element.model.isOpen ? new vscode.ThemeIcon('issues', new vscode.ThemeColor('issues.open')) - : new vscode.ThemeIcon('issue-closed', new vscode.ThemeColor('issues.closed')); + : (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 - ? new vscode.ThemeIcon('git-pull-request', new vscode.ThemeColor('pullRequests.open')) + ? (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'))); @@ -149,6 +160,27 @@ export class NotificationsManager extends Disposable implements vscode.TreeDataP return item; } + private _notificationLabel(element: NotificationTreeItem): string { + let setting: string | undefined; + let namespace: string | undefined; + if (element.notification.subject.type === NotificationSubjectType.Issue) { + setting = SHOW_ISSUE_NUMBER_IN_TREE; + namespace = ISSUES_SETTINGS_NAMESPACE; + } else if (element.notification.subject.type === NotificationSubjectType.PullRequest) { + setting = SHOW_PULL_REQUEST_NUMBER_IN_TREE; + namespace = PR_SETTINGS_NAMESPACE; + } + + if (setting !== undefined && namespace !== undefined) { + const showNumber = vscode.workspace.getConfiguration(namespace).get(setting, false); + if (showNumber) { + return `#${element.model.number}: ${element.notification.subject.title}`; + } + } + + return element.notification.subject.title; + } + private _resolveLoadMoreNotificationsTreeItem(): vscode.TreeItem { const item = new vscode.TreeItem(vscode.l10n.t('Load More Notifications...'), vscode.TreeItemCollapsibleState.None); item.command = { @@ -323,6 +355,22 @@ export class NotificationsManager extends Disposable implements vscode.TreeDataP } } + 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) { @@ -333,17 +381,17 @@ export class NotificationsManager extends Disposable implements vscode.TreeDataP }; if (event.event === EventType.Committed) { - if (userCheck(event.author.login)) { + if (!this._isBot(event.author) && userCheck(event.author.login)) { return new Date(event.committedDate); } } else if (event.event === EventType.Commented) { - if (userCheck(event.user?.login)) { + 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 (userCheck(event.user?.login)) { + if (event.user && !this._isBot(event.user) && userCheck(event.user.login)) { return new Date(event.submittedAt); } } @@ -352,7 +400,7 @@ export class NotificationsManager extends Disposable implements vscode.TreeDataP public async markPullRequests(markAsDone: boolean = false): Promise { const filteredNotifications = Array.from(this._notifications.values()).filter(notification => notification.notification.subject.type === NotificationSubjectType.PullRequest); - const timlines = await Promise.all(filteredNotifications.map(notification => (notification.model as PullRequestModel).getTimelineEvents())); + const timelines = await Promise.all(filteredNotifications.map(notification => (notification.model as PullRequestModel).getActivityTimelineEvents())); const markPromises: Promise[] = []; @@ -360,7 +408,7 @@ export class NotificationsManager extends Disposable implements vscode.TreeDataP 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 = timlines[index]; + const timeline = timelines[index]; let userLastEvent: Date | undefined = undefined; let nonUserLastEvent: Date | undefined = undefined; for (let i = timeline.length - 1; i >= 0; i--) { diff --git a/src/notifications/notificationsProvider.ts b/src/notifications/notificationsProvider.ts index e1120bc882..ba101c353b 100644 --- a/src/notifications/notificationsProvider.ts +++ b/src/notifications/notificationsProvider.ts @@ -8,7 +8,7 @@ import { NotificationTreeItem } from './notificationItem'; import { AuthProvider } from '../common/authentication'; import { Disposable } from '../common/lifecycle'; import Logger from '../common/logger'; -import { EXPERIMENTAL_NOTIFICATIONS_PAGE_SIZE, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { 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'; @@ -137,6 +137,9 @@ export class NotificationsProvider extends Disposable { } 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) { diff --git a/src/test/builders/graphql/latestReviewCommitBuilder.ts b/src/test/builders/graphql/latestReviewCommitBuilder.ts index 07ab147fbd..49c9a42f6f 100644 --- a/src/test/builders/graphql/latestReviewCommitBuilder.ts +++ b/src/test/builders/graphql/latestReviewCommitBuilder.ts @@ -10,16 +10,13 @@ import { RateLimitBuilder } from './rateLimitBuilder'; type Repository = NonNullable; type PullRequest = Repository['pullRequest']; -type ViewerLatestReview = PullRequest['viewerLatestReview']; -type Commit = ViewerLatestReview['commit']; +type Reviews = PullRequest['reviews']; export const LatestReviewCommitBuilder = createBuilderClass()({ repository: createLink()({ pullRequest: createLink()({ - viewerLatestReview: createLink()({ - commit: createLink()({ - oid: { default: 'abc' }, - }), + reviews: createLink()({ + nodes: { default: [] }, }), }), }), 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..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/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 aabe5b77d3..b7e096775e 100644 --- a/src/test/common/utils.test.ts +++ b/src/test/common/utils.test.ts @@ -51,4 +51,230 @@ describe('utils', () => { 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 `link text`; + } + + 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 = 'example'; + 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 = `link`; + const result = await utils.processPermalinks(html, repoName, authority, async () => true); + + assert.strictEqual(result, html); + }); + + it('should skip already processed links', async () => { + const html = `link`; + 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 `link text`; + } + + it('should add data attributes when hash maps to a file', async () => { + const hashMap: Record = { [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 = { [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 = { [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 = {}; + 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 = { [diffHash]: 'src/file.ts' }; + const html = 'example'; + 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 = { [diffHash]: 'src/file.ts' }; + const html = `link`; + 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 = { [diffHash]: 'src/file.ts' }; + const html = `link`; + 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 = { [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 = { [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 = { [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('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('